From ee938aa09751de9448e6847af7311304a86e3653 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 7 May 2026 17:47:53 +0900 Subject: [PATCH] fix full-suite isolation regressions --- .../builtin-agents/resolve-file-uri.test.ts | 21 +- src/features/background-agent/manager.test.ts | 234 +++++++++--------- src/features/background-agent/manager.ts | 5 +- .../hook-message-injector/injector.ts | 4 +- .../agents-skills-global.test.ts | 29 +-- .../config-source-discovery.ts | 2 +- .../loaded-skill-from-path.ts | 2 +- src/features/opencode-skill-loader/loader.ts | 8 +- .../skill-directory-loader.ts | 2 +- .../opencode-skill-loader/skill-mcp-config.ts | 2 +- src/features/team-mode/integration.test.ts | 5 - .../team-mode/team-layout-tmux/layout.test.ts | 26 +- .../team-mode/team-layout-tmux/layout.ts | 72 ++++-- .../team-mode/team-mailbox/inbox.test.ts | 1 + .../team-mode/team-registry/loader.test.ts | 22 +- .../team-mode/team-registry/paths.test.ts | 1 + .../team-mode/team-runtime/delete-team.ts | 21 +- .../team-mode/team-runtime/shutdown.test.ts | 28 ++- .../team-mode/team-state-store/locks.test.ts | 24 +- .../team-mode/team-state-store/locks.ts | 3 +- .../tools/lifecycle-inline-spec.test.ts | 63 +++-- .../team-mode/tools/lifecycle-test-fixture.ts | 8 +- .../team-mode/tools/lifecycle.test.ts | 80 +++--- src/features/team-mode/tools/lifecycle.ts | 75 ++++-- .../tools/messaging-missing-session.test.ts | 35 ++- src/features/team-mode/tools/messaging.ts | 36 ++- src/features/team-mode/tools/query.test.ts | 19 +- src/features/team-mode/tools/query.ts | 25 +- src/features/team-mode/tools/tasks.test.ts | 18 +- src/features/team-mode/tools/tasks.ts | 43 +++- src/features/tmux-subagent/manager.test.ts | 68 +++-- src/features/tmux-subagent/manager.ts | 146 +++++------ .../pane-state-querier-runner.test.ts | 38 +-- .../tmux-subagent/pane-state-querier.ts | 27 +- .../aggressive-truncation-strategy.test.ts | 22 +- src/hooks/atlas/atlas-hook.ts | 16 +- .../atlas/final-wave-approval-gate.test.ts | 62 +---- src/hooks/atlas/index.test.ts | 227 ++++++++--------- .../recent-model-resolver-fallback.test.ts | 47 ++-- src/hooks/atlas/recent-model-resolver.ts | 25 +- src/hooks/atlas/tool-execute-after.ts | 5 +- src/hooks/atlas/tool-execute-before.ts | 4 +- src/hooks/atlas/types.ts | 1 + src/hooks/ralph-loop/index.test.ts | 26 ++ .../ralph-loop/ralph-loop-event-handler.ts | 51 ++-- src/hooks/write-existing-file-guard/hook.ts | 15 +- .../write-existing-file-guard/index.test.ts | 10 +- .../lazy-canonical-path-init.test.ts | 30 ++- .../tool-execute-before-handler.ts | 16 +- src/plugin/event.test.ts | 2 +- src/shared/posthog.test.ts | 51 ++-- src/shared/posthog.ts | 31 ++- .../tmux/tmux-utils/pane-spawn-runner.test.ts | 36 ++- src/shared/tmux/tmux-utils/pane-spawn.ts | 38 ++- .../tmux/tmux-utils/session-spawn.test.ts | 34 ++- src/shared/tmux/tmux-utils/session-spawn.ts | 37 ++- .../tmux/tmux-utils/window-spawn.test.ts | 34 ++- src/shared/tmux/tmux-utils/window-spawn.ts | 38 ++- .../background-executor.test.ts | 6 - src/tools/look-at/missing-file-error.test.ts | 28 +++ src/tools/look-at/missing-file-error.ts | 45 ++++ src/tools/look-at/tools.ts | 7 + 62 files changed, 1238 insertions(+), 899 deletions(-) create mode 100644 src/tools/look-at/missing-file-error.test.ts create mode 100644 src/tools/look-at/missing-file-error.ts diff --git a/src/agents/builtin-agents/resolve-file-uri.test.ts b/src/agents/builtin-agents/resolve-file-uri.test.ts index 9fb5c9550..5460585b6 100644 --- a/src/agents/builtin-agents/resolve-file-uri.test.ts +++ b/src/agents/builtin-agents/resolve-file-uri.test.ts @@ -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", () => { diff --git a/src/features/background-agent/manager.test.ts b/src/features/background-agent/manager.test.ts index 17f06a292..5f622466b 100644 --- a/src/features/background-agent/manager.test.ts +++ b/src/features/background-agent/manager.test.ts @@ -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(value: unknown): T { return value as T } +function createPluginInput(client: unknown, directory = tmpdir()): PluginInput { + return cast({ 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[0]>): BackgroundManager { @@ -215,7 +213,7 @@ function createBackgroundManagerWithOptions(options: Partial { 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({ 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({ 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({ 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({ 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({ 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({ 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({ 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({ 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({ 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({ 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({ 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({ 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({ 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({ 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({ 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 diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index 2500ae9f6..10dce905d 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -182,6 +182,7 @@ export interface BackgroundManagerConfig { onShutdown?: () => void | Promise enableParentSessionNotifications?: boolean modelFallbackControllerAccessor?: ModelFallbackControllerAccessor + log?: typeof log } export class BackgroundManager { @@ -215,6 +216,7 @@ export class BackgroundManager { private preStartDescendantReservations: Set 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), diff --git a/src/features/hook-message-injector/injector.ts b/src/features/hook-message-injector/injector.ts index 84ecddf0e..b9a8a7590 100644 --- a/src/features/hook-message-injector/injector.ts +++ b/src/features/hook-message-injector/injector.ts @@ -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 diff --git a/src/features/opencode-skill-loader/agents-skills-global.test.ts b/src/features/opencode-skill-loader/agents-skills-global.test.ts index 290272273..be290e543 100644 --- a/src/features/opencode-skill-loader/agents-skills-global.test.ts +++ b/src/features/opencode-skill-loader/agents-skills-global.test.ts @@ -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 diff --git a/src/features/opencode-skill-loader/config-source-discovery.ts b/src/features/opencode-skill-loader/config-source-discovery.ts index b290c8b30..c317e1821 100644 --- a/src/features/opencode-skill-loader/config-source-discovery.ts +++ b/src/features/opencode-skill-loader/config-source-discovery.ts @@ -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" diff --git a/src/features/opencode-skill-loader/loaded-skill-from-path.ts b/src/features/opencode-skill-loader/loaded-skill-from-path.ts index 4097f6917..4400bd7e4 100644 --- a/src/features/opencode-skill-loader/loaded-skill-from-path.ts +++ b/src/features/opencode-skill-loader/loaded-skill-from-path.ts @@ -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" diff --git a/src/features/opencode-skill-loader/loader.ts b/src/features/opencode-skill-loader/loader.ts index 6f0c44c3b..3768eaa34 100644 --- a/src/features/opencode-skill-loader/loader.ts +++ b/src/features/opencode-skill-loader/loader.ts @@ -56,8 +56,8 @@ export async function loadProjectAgentsSkills(directory?: string): Promise> { - const agentsGlobalDir = join(homedir(), ".agents", "skills") +export async function loadGlobalAgentsSkills(homeDirectory: string = homedir()): Promise> { + 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 { - const agentsGlobalDir = join(homedir(), ".agents", "skills") +export async function discoverGlobalAgentsSkills(homeDirectory: string = homedir()): Promise { + const agentsGlobalDir = join(homeDirectory, ".agents", "skills") return loadSkillsFromDir({ skillsDir: agentsGlobalDir, scope: "user" }) } diff --git a/src/features/opencode-skill-loader/skill-directory-loader.ts b/src/features/opencode-skill-loader/skill-directory-loader.ts index 13c859d6b..0f12defbe 100644 --- a/src/features/opencode-skill-loader/skill-directory-loader.ts +++ b/src/features/opencode-skill-loader/skill-directory-loader.ts @@ -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" diff --git a/src/features/opencode-skill-loader/skill-mcp-config.ts b/src/features/opencode-skill-loader/skill-mcp-config.ts index 211940f46..144211872 100644 --- a/src/features/opencode-skill-loader/skill-mcp-config.ts +++ b/src/features/opencode-skill-loader/skill-mcp-config.ts @@ -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" diff --git a/src/features/team-mode/integration.test.ts b/src/features/team-mode/integration.test.ts index ffd33f653..fb307a645 100644 --- a/src/features/team-mode/integration.test.ts +++ b/src/features/team-mode/integration.test.ts @@ -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") diff --git a/src/features/team-mode/team-layout-tmux/layout.test.ts b/src/features/team-mode/team-layout-tmux/layout.test.ts index 3ef809823..775bad4e6 100644 --- a/src/features/team-mode/team-layout-tmux/layout.test.ts +++ b/src/features/team-mode/team-layout-tmux/layout.test.ts @@ -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[1], tmuxMgr: Parameters[2]) => { + return createTeamLayout(teamRunId, members, tmuxMgr, deps) + }, + removeTeamLayout: ( + teamRunId: string, + cleanupTarget: TeamLayoutCleanupTarget | undefined, + tmuxMgr: Parameters[2], + ) => removeTeamLayout(teamRunId, cleanupTarget, tmuxMgr, deps), + } } type TmuxMgrLike = { getServerUrl: () => string } diff --git a/src/features/team-mode/team-layout-tmux/layout.ts b/src/features/team-mode/team-layout-tmux/layout.ts index 78b340330..2709b0603 100644 --- a/src/features/team-mode/team-layout-tmux/layout.ts +++ b/src/features/team-mode/team-layout-tmux/layout.ts @@ -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> + +export type TeamLayoutDeps = { + runTmuxCommand: (tmuxPath: string, args: Array, options?: Parameters[2]) => Promise + 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> { - const result = await runTmuxCommand(tmuxPath, ["list-panes", "-t", windowTarget, "-F", "#{pane_id}"]) +async function listPanesInWindow(tmuxPath: string, windowTarget: string, deps: TeamLayoutDeps): Promise> { + 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, serverUrl: string, + deps: TeamLayoutDeps, ): Promise<{ focusWindowId: string; focusPanesByMember: Record } | null> { const panesByMember: Record = {} - 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, tmuxMgr: TmuxSessionManager): Promise { +export async function createTeamLayout(teamRunId: string, members: Array, tmuxMgr: TmuxSessionManager, deps: TeamLayoutDeps = defaultDeps): Promise { 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 -export async function removeTeamLayout( - teamRunId: string, - _cleanupTarget: TeamLayoutCleanupTarget | undefined, - _tmuxMgr: TmuxSessionManager, -): Promise export async function removeTeamLayout( teamRunId: string, tmuxMgrOrCleanupTarget: TmuxSessionManager | TeamLayoutCleanupTarget | undefined, - _tmuxMgr?: TmuxSessionManager, + tmuxMgrOrDeps?: TmuxSessionManager | TeamLayoutDeps, + deps: TeamLayoutDeps = defaultDeps, ): Promise { 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 } diff --git a/src/features/team-mode/team-mailbox/inbox.test.ts b/src/features/team-mode/team-mailbox/inbox.test.ts index 26b483ce7..3afed0f4a 100644 --- a/src/features/team-mode/team-mailbox/inbox.test.ts +++ b/src/features/team-mode/team-mailbox/inbox.test.ts @@ -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) diff --git a/src/features/team-mode/team-registry/loader.test.ts b/src/features/team-mode/team-registry/loader.test.ts index cee6cb029..c43d2ef10 100644 --- a/src/features/team-mode/team-registry/loader.test.ts +++ b/src/features/team-mode/team-registry/loader.test.ts @@ -1,6 +1,6 @@ /// -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 () => { diff --git a/src/features/team-mode/team-registry/paths.test.ts b/src/features/team-mode/team-registry/paths.test.ts index c2262eed1..1fd80f4bb 100644 --- a/src/features/team-mode/team-registry/paths.test.ts +++ b/src/features/team-mode/team-registry/paths.test.ts @@ -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) diff --git a/src/features/team-mode/team-runtime/delete-team.ts b/src/features/team-mode/team-runtime/delete-team.ts index 336cd50c3..bd8e8bb9e 100644 --- a/src/features/team-mode/team-runtime/delete-team.ts +++ b/src/features/team-mode/team-runtime/delete-team.ts @@ -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([ "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) } } diff --git a/src/features/team-mode/team-runtime/shutdown.test.ts b/src/features/team-mode/team-runtime/shutdown.test.ts index 2f0b8c2c1..89682fa8a 100644 --- a/src/features/team-mode/team-runtime/shutdown.test.ts +++ b/src/features/team-mode/team-runtime/shutdown.test.ts @@ -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 () => { diff --git a/src/features/team-mode/team-state-store/locks.test.ts b/src/features/team-mode/team-state-store/locks.test.ts index 8664929a1..a1d2ad0c8 100644 --- a/src/features/team-mode/team-state-store/locks.test.ts +++ b/src/features/team-mode/team-state-store/locks.test.ts @@ -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 { 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 }) }) diff --git a/src/features/team-mode/team-state-store/locks.ts b/src/features/team-mode/team-state-store/locks.ts index 4e9f38250..0fca98494 100644 --- a/src/features/team-mode/team-state-store/locks.ts +++ b/src/features/team-mode/team-state-store/locks.ts @@ -108,6 +108,7 @@ export async function reapStaleLock(lockPath: string): Promise { export async function atomicWrite( filePath: string, content: string | Buffer, + deps: { rename: typeof rename } = { rename }, ): Promise { 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 diff --git a/src/features/team-mode/tools/lifecycle-inline-spec.test.ts b/src/features/team-mode/tools/lifecycle-inline-spec.test.ts index 0d707922c..3999fb427 100644 --- a/src/features/team-mode/tools/lifecycle-inline-spec.test.ts +++ b/src/features/team-mode/tools/lifecycle-inline-spec.test.ts @@ -13,9 +13,6 @@ import type { RuntimeState, TeamSpec } from "../types" const runtimes = new Map() let nextTeamRunNumber = 1 -const lifecycleSpecifier = import.meta.resolve("./lifecycle") -const teamRuntimeCreateSpecifier = import.meta.resolve("../team-runtime/create") - function clone(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 { - 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, + executorConfig?: Parameters[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: {}, }, diff --git a/src/features/team-mode/tools/lifecycle-test-fixture.ts b/src/features/team-mode/tools/lifecycle-test-fixture.ts index 5f608df58..203ad40b7 100644 --- a/src/features/team-mode/tools/lifecycle-test-fixture.ts +++ b/src/features/team-mode/tools/lifecycle-test-fixture.ts @@ -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 }) diff --git a/src/features/team-mode/tools/lifecycle.test.ts b/src/features/team-mode/tools/lifecycle.test.ts index c709619ee..e0ae123df 100644 --- a/src/features/team-mode/tools/lifecycle.test.ts +++ b/src/features/team-mode/tools/lifecycle.test.ts @@ -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")) diff --git a/src/features/team-mode/tools/lifecycle.ts b/src/features/team-mode/tools/lifecycle.ts index 392405a28..5fd4f0787 100644 --- a/src/features/team-mode/tools/lifecycle.ts +++ b/src/features/team-mode/tools/lifecycle.ts @@ -116,17 +116,38 @@ function parseInlineTeamSpec( return parsedSpec } -async function findParticipantRuntime(sessionID: string, config: TeamModeConfig): Promise { - 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 { + 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" }) }, }) diff --git a/src/features/team-mode/tools/messaging-missing-session.test.ts b/src/features/team-mode/tools/messaging-missing-session.test.ts index 3dceab444..59f52d182 100644 --- a/src/features/team-mode/tools/messaging-missing-session.test.ts +++ b/src/features/team-mode/tools/messaging-missing-session.test.ts @@ -1,6 +1,6 @@ /// -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[2]> - const { createTeamSendMessageTool } = await import("./messaging") - type LiveDeliveryClient = Parameters[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({ diff --git a/src/features/team-mode/tools/messaging.ts b/src/features/team-mode/tools/messaging.ts index bee322ed1..d04105974 100644 --- a/src/features/team-mode/tools/messaging.ts +++ b/src/features/team-mode/tools/messaging.ts @@ -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> -async function resolveTeamRuntimeDetails(teamRunId: string, sessionID: string, config: TeamModeConfig): Promise { +async function resolveTeamRuntimeDetails( + teamRunId: string, + sessionID: string, + config: TeamModeConfig, + deps: TeamSendMessageToolDeps, +): Promise { 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 { - 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( 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), diff --git a/src/features/team-mode/tools/query.test.ts b/src/features/team-mode/tools/query.test.ts index 23ddc90b1..76f13a77b 100644 --- a/src/features/team-mode/tools/query.test.ts +++ b/src/features/team-mode/tools/query.test.ts @@ -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) => aggregateStatusImplementation(...args), -})) - -mock.module("../team-registry/paths", () => ({ discoverTeamSpecs: (...args: Parameters) => discoverTeamSpecsImplementation(...args), -})) - -mock.module("../team-registry/loader", () => ({ loadTeamSpec: (...args: Parameters) => loadTeamSpecImplementation(...args), -})) - -mock.module("../team-state-store/store", () => ({ listActiveTeams: (...args: Parameters) => 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())) diff --git a/src/features/team-mode/tools/query.ts b/src/features/team-mode/tools/query.ts index 7718a4231..76f860dd1 100644 --- a/src/features/team-mode/tools/query.ts +++ b/src/features/team-mode/tools/query.ts @@ -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[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 })), ) diff --git a/src/features/team-mode/tools/tasks.test.ts b/src/features/team-mode/tools/tasks.test.ts index 3781ee6a3..c0625564b 100644 --- a/src/features/team-mode/tools/tasks.test.ts +++ b/src/features/team-mode/tools/tasks.test.ts @@ -39,14 +39,14 @@ const loadRuntimeStateMock = mock(async (): Promise => ({ }, })) -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")) diff --git a/src/features/team-mode/tools/tasks.ts b/src/features/team-mode/tools/tasks.ts index 5fb35305e..6fc3348ff 100644 --- a/src/features/team-mode/tools/tasks.ts +++ b/src/features/team-mode/tools/tasks.ts @@ -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 { - 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 { + 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 => { - 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 => { - 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 => { - 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 => { - const task = await getTask(args.teamRunId, args.taskId, config) + const task = await deps.getTask(args.teamRunId, args.taskId, config) return JSON.stringify({ task }) }, }) diff --git a/src/features/tmux-subagent/manager.test.ts b/src/features/tmux-subagent/manager.test.ts index 11f96d593..6284bc59e 100644 --- a/src/features/tmux-subagent/manager.test.ts +++ b/src/features/tmux-subagent/manager.test.ts @@ -22,6 +22,22 @@ type SessionReadyWaitParams = { sessionId: string } +type TmuxSessionManagerContext = ConstructorParameters[0] + +type TmuxSessionManagerInternals = { + serverUrl: string + deferredQueue: string[] + tryAttachDeferredSession: () => Promise +} + +function cast(value: unknown): TValue { + return value as TValue +} + +function getManagerInternals(manager: TmuxSessionManagerType): TmuxSessionManagerInternals { + return cast(manager) +} + const mockQueryWindowState = mock<(paneId: string) => Promise>( 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() function createMockContext(overrides?: { sessionStatusResult?: { data?: Record } sessionMessagesResult?: { data?: unknown[] } -}) { - return { +}): TmuxSessionManagerContext { + return cast({ 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() }) diff --git a/src/features/tmux-subagent/manager.ts b/src/features/tmux-subagent/manager.ts index 0da723a1c..212985cbd 100644 --- a/src/features/tmux-subagent/manager.ts +++ b/src/features/tmux-subagent/manager.ts @@ -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 + waitForSessionReady: (params: { client: OpencodeClient; sessionId: string }) => Promise + 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 = {}) { 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 { 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 { 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 { 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): Promise { 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 { @@ -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 { diff --git a/src/features/tmux-subagent/pane-state-querier-runner.test.ts b/src/features/tmux-subagent/pane-state-querier-runner.test.ts index e3965c34b..a51e72d32 100644 --- a/src/features/tmux-subagent/pane-state-querier-runner.test.ts +++ b/src/features/tmux-subagent/pane-state-querier-runner.test.ts @@ -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 => ({ success: true, @@ -14,25 +10,12 @@ const runTmuxCommandMock = mock(async (): Promise => ({ stderr: "", exitCode: 0, })) -const getTmuxPathMock = mock(async (): Promise => "sh") +const getTmuxPathMock = mock(async (): Promise => "sh") const logMock = mock(() => undefined) -async function loadQueryWindowState(): Promise { - 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() diff --git a/src/features/tmux-subagent/pane-state-querier.ts b/src/features/tmux-subagent/pane-state-querier.ts index add949bc3..3dfa911ef 100644 --- a/src/features/tmux-subagent/pane-state-querier.ts +++ b/src/features/tmux-subagent/pane-state-querier.ts @@ -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 { - const tmux = await getTmuxPath() +type QueryWindowStateDeps = { + getTmuxPath: typeof getTmuxPath + runTmuxCommand: (tmuxPath: string, args: string[]) => Promise + log: typeof log +} + +export async function queryWindowStateWithDeps(sourcePaneId: string, deps: QueryWindowStateDeps): Promise { + 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 p.paneId), }) @@ -58,7 +64,7 @@ export async function queryWindowState(sourcePaneId: string): Promise 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 { + const { runTmuxCommand } = await import("../../shared/tmux") + return queryWindowStateWithDeps(sourcePaneId, { getTmuxPath, runTmuxCommand, log }) +} diff --git a/src/hooks/anthropic-context-window-limit-recovery/aggressive-truncation-strategy.test.ts b/src/hooks/anthropic-context-window-limit-recovery/aggressive-truncation-strategy.test.ts index db1810074..2c8e1ae49 100644 --- a/src/hooks/anthropic-context-window-limit-recovery/aggressive-truncation-strategy.test.ts +++ b/src/hooks/anthropic-context-window-limit-recovery/aggressive-truncation-strategy.test.ts @@ -35,19 +35,7 @@ mock.module("../../features/hook-message-injector", () => ({ findNearestMessageWithFields: findNearestMessageWithFieldsMock, })) -const sessionAgentMap = new Map() -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 { 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({ diff --git a/src/hooks/atlas/atlas-hook.ts b/src/hooks/atlas/atlas-hook.ts index ca71bb8d9..aa9e13c4e 100644 --- a/src/hooks/atlas/atlas-hook.ts +++ b/src/hooks/atlas/atlas-hook.ts @@ -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, + }), } } diff --git a/src/hooks/atlas/final-wave-approval-gate.test.ts b/src/hooks/atlas/final-wave-approval-gate.test.ts index 608a53235..42e8d0763 100644 --- a/src/hooks/atlas/final-wave-approval-gate.test.ts +++ b/src/hooks/atlas/final-wave-approval-gate.test.ts @@ -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[0] type PromptMock = ReturnType @@ -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) }) }) diff --git a/src/hooks/atlas/index.test.ts b/src/hooks/atlas/index.test.ts index b33de7ea0..51e42600c 100644 --- a/src/hooks/atlas/index.test.ts +++ b/src/hooks/atlas/index.test.ts @@ -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() +type MockAtlasInput = Parameters[0] & { + _promptMock: ReturnType + _sessionGetMock: ReturnType +} describe("atlas hook", () => { let TEST_DIR: string @@ -47,7 +29,7 @@ describe("atlas hook", () => { function createMockPluginInput(overrides?: { promptMock?: ReturnType sessionGetMock?: ReturnType - }) { + }): 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[0]["project"], + worktree: TEST_DIR, + serverUrl: new URL("http://localhost"), + $: {} as Parameters[0]["$"], + client, _promptMock: promptMock, _sessionGetMock: sessionGetMock, - } as Parameters[0] & { - _promptMock: ReturnType - _sessionGetMock: ReturnType } } 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 = {}, + ): ReturnType { + 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[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 => 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[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 } }, diff --git a/src/hooks/atlas/recent-model-resolver-fallback.test.ts b/src/hooks/atlas/recent-model-resolver-fallback.test.ts index b2e09736e..0a47b9c90 100644 --- a/src/hooks/atlas/recent-model-resolver-fallback.test.ts +++ b/src/hooks/atlas/recent-model-resolver-fallback.test.ts @@ -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 } | 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; 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" }) diff --git a/src/hooks/atlas/recent-model-resolver.ts b/src/hooks/atlas/recent-model-resolver.ts index e3acf1699..463efe7d5 100644 --- a/src/hooks/atlas/recent-model-resolver.ts +++ b/src/hooks/atlas/recent-model-resolver.ts @@ -11,9 +11,24 @@ type PromptContext = { tools?: Record } +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 { 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) diff --git a/src/hooks/atlas/tool-execute-after.ts b/src/hooks/atlas/tool-execute-after.ts index 0a847bc8e..3869c291f 100644 --- a/src/hooks/atlas/tool-execute-after.ts +++ b/src/hooks/atlas/tool-execute-after.ts @@ -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 autoCommit: boolean getState: (sessionID: string) => SessionState + isCallerOrchestrator?: (sessionID: string | undefined) => Promise }): (toolInput: ToolExecuteAfterInput, toolOutput: ToolExecuteAfterOutput | undefined) => Promise { const { ctx, pendingFilePaths, pendingTaskRefs, autoCommit, getState } = input + const resolveIsCallerOrchestrator = input.isCallerOrchestrator ?? ((sessionID) => isCallerOrchestrator(sessionID, ctx.client)) return async (toolInput, toolOutput): Promise => { // 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 } diff --git a/src/hooks/atlas/tool-execute-before.ts b/src/hooks/atlas/tool-execute-before.ts index e6ccae501..dd31f1c40 100644 --- a/src/hooks/atlas/tool-execute-before.ts +++ b/src/hooks/atlas/tool-execute-before.ts @@ -13,18 +13,20 @@ export function createToolExecuteBeforeHandler(input: { ctx: PluginInput pendingFilePaths: Map pendingTaskRefs: Map + isCallerOrchestrator?: (sessionID: string | undefined) => Promise }): ( toolInput: { tool: string; sessionID?: string; callID?: string }, toolOutput: { args: Record; message?: string } ) => Promise { 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 => { - if (!(await isCallerOrchestrator(toolInput.sessionID, ctx.client))) { + if (!(await resolveIsCallerOrchestrator(toolInput.sessionID))) { return } diff --git a/src/hooks/atlas/types.ts b/src/hooks/atlas/types.ts index f82f8ca14..84955884e 100644 --- a/src/hooks/atlas/types.ts +++ b/src/hooks/atlas/types.ts @@ -11,6 +11,7 @@ export interface AtlasHookOptions { directory: string backgroundManager?: BackgroundTaskStatusProvider isContinuationStopped?: (sessionID: string) => boolean + isCallerOrchestrator?: (sessionID: string | undefined) => Promise agentOverrides?: AgentOverrides /** Enable auto-commit after each atomic task completion (default: true) */ autoCommit?: boolean diff --git a/src/hooks/ralph-loop/index.test.ts b/src/hooks/ralph-loop/index.test.ts index 88b7433b5..7418f4f2e 100644 --- a/src/hooks/ralph-loop/index.test.ts +++ b/src/hooks/ralph-loop/index.test.ts @@ -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(), { diff --git a/src/hooks/ralph-loop/ralph-loop-event-handler.ts b/src/hooks/ralph-loop/ralph-loop-event-handler.ts index a0ed83498..788aa2594 100644 --- a/src/hooks/ralph-loop/ralph-loop-event-handler.ts +++ b/src/hooks/ralph-loop/ralph-loop-event-handler.ts @@ -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 { - 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 { - 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, diff --git a/src/hooks/write-existing-file-guard/hook.ts b/src/hooks/write-existing-file-guard/hook.ts index ab7bd9aef..19b72eb73 100644 --- a/src/hooks/write-existing-file-guard/hook.ts +++ b/src/hooks/write-existing-file-guard/hook.ts @@ -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 | 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>() const sessionLastAccess = new Map() + 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 } }) => { diff --git a/src/hooks/write-existing-file-guard/index.test.ts b/src/hooks/write-existing-file-guard/index.test.ts index bd3290cc2..ca0bd5199 100644 --- a/src/hooks/write-existing-file-guard/index.test.ts +++ b/src/hooks/write-existing-file-guard/index.test.ts @@ -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 => { - 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", diff --git a/src/hooks/write-existing-file-guard/lazy-canonical-path-init.test.ts b/src/hooks/write-existing-file-guard/lazy-canonical-path-init.test.ts index 0f1d4bb88..b61a62e56 100644 --- a/src/hooks/write-existing-file-guard/lazy-canonical-path-init.test.ts +++ b/src/hooks/write-existing-file-guard/lazy-canonical-path-init.test.ts @@ -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> + let realpathNativeMock: ReturnType> 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") diff --git a/src/hooks/write-existing-file-guard/tool-execute-before-handler.ts b/src/hooks/write-existing-file-guard/tool-execute-before-handler.ts index 848238a8a..d9172f653 100644 --- a/src/hooks/write-existing-file-guard/tool-execute-before-handler.ts +++ b/src/hooks/write-existing-file-guard/tool-execute-before-handler.ts @@ -44,6 +44,7 @@ function registerReadPermission(params: { readPermissionsBySession: Map> sessionLastAccess: Map 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 getCanonicalSessionRoot: () => string maxTrackedSessions: number + maxTrackedPathsPerSession?: number }): Promise { - 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 } diff --git a/src/plugin/event.test.ts b/src/plugin/event.test.ts index bd5bb54a8..49ed2c031 100644 --- a/src/plugin/event.test.ts +++ b/src/plugin/event.test.ts @@ -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", diff --git a/src/shared/posthog.test.ts b/src/shared/posthog.test.ts index b945a7e31..8ccfcbb21 100644 --- a/src/shared/posthog.test.ts +++ b/src/shared/posthog.test.ts @@ -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 () => { diff --git a/src/shared/posthog.ts b/src/shared/posthog.ts index 1d66988f9..553c0c0e5 100644 --- a/src/shared/posthog.ts +++ b/src/shared/posthog.ts @@ -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 +let osProviderOverride: OsProvider | null = null function resolveActivityState(): ReturnType { 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 { const cpus = safeCpus() + const osProvider = resolveOsProvider() return { platform: "oh-my-opencode", @@ -85,13 +102,13 @@ function getSharedProperties(source: PostHogSource): NonNullable + 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 => ({ })) const isInsideTmuxMock = mock((): boolean => true) const isServerRunningMock = mock(async (): Promise => true) -const getTmuxPathMock = mock(async (): Promise => "sh") +const getTmuxPathMock = mock(async (): Promise => "sh") const logMock = mock(() => undefined) function toStringArray(value: unknown): string[] { @@ -64,23 +61,24 @@ function getSplitWindowCommand(): string { return splitCommand } +function createDeps(): NonNullable[7]> { + return { + log: logMock, + runTmuxCommand: runTmuxCommandMock, + isInsideTmux: isInsideTmuxMock, + isServerRunning: isServerRunningMock, + getTmuxPath: getTmuxPathMock, + } +} + async function loadSpawnTmuxPane(): Promise { 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'") diff --git a/src/shared/tmux/tmux-utils/pane-spawn.ts b/src/shared/tmux/tmux-utils/pane-spawn.ts index a4b00d2ff..87e845a73 100644 --- a/src/shared/tmux/tmux-utils/pane-spawn.ts +++ b/src/shared/tmux/tmux-utils/pane-spawn.ts @@ -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): Promise { + 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, ): Promise { - 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 } diff --git a/src/shared/tmux/tmux-utils/session-spawn.test.ts b/src/shared/tmux/tmux-utils/session-spawn.test.ts index f5bf38aca..315c6f150 100644 --- a/src/shared/tmux/tmux-utils/session-spawn.test.ts +++ b/src/shared/tmux/tmux-utils/session-spawn.test.ts @@ -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 => ({ })) const isInsideTmuxMock = mock((): boolean => true) const isServerRunningMock = mock(async (): Promise => true) -const getTmuxPathMock = mock(async (): Promise => "sh") +const getTmuxPathMock = mock(async (): Promise => "sh") const logMock = mock(() => undefined) function toStringArray(value: unknown): string[] { @@ -64,23 +59,24 @@ function getSpawnCommand(): string { return newSessionCommand } +function createDeps(): NonNullable[6]> { + return { + log: logMock, + runTmuxCommand: runTmuxCommandMock, + isInsideTmux: isInsideTmuxMock, + isServerRunning: isServerRunningMock, + getTmuxPath: getTmuxPathMock, + } +} + async function loadSpawnTmuxSession(): Promise { 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'") diff --git a/src/shared/tmux/tmux-utils/session-spawn.ts b/src/shared/tmux/tmux-utils/session-spawn.ts index 16aab5012..333330bc5 100644 --- a/src/shared/tmux/tmux-utils/session-spawn.ts +++ b/src/shared/tmux/tmux-utils/session-spawn.ts @@ -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): Promise { + 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, ): Promise { - 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 } diff --git a/src/shared/tmux/tmux-utils/window-spawn.test.ts b/src/shared/tmux/tmux-utils/window-spawn.test.ts index 984078e7c..a8abdcf92 100644 --- a/src/shared/tmux/tmux-utils/window-spawn.test.ts +++ b/src/shared/tmux/tmux-utils/window-spawn.test.ts @@ -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 => ({ })) const isInsideTmuxMock = mock((): boolean => true) const isServerRunningMock = mock(async (): Promise => true) -const getTmuxPathMock = mock(async (): Promise => "sh") +const getTmuxPathMock = mock(async (): Promise => "sh") const logMock = mock(() => undefined) function toStringArray(value: unknown): string[] { @@ -64,23 +59,24 @@ function getNewWindowCommand(): string { return newWindowCommand } +function createDeps(): NonNullable[5]> { + return { + log: logMock, + runTmuxCommand: runTmuxCommandMock, + isInsideTmux: isInsideTmuxMock, + isServerRunning: isServerRunningMock, + getTmuxPath: getTmuxPathMock, + } +} + async function loadSpawnTmuxWindow(): Promise { 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'") diff --git a/src/shared/tmux/tmux-utils/window-spawn.ts b/src/shared/tmux/tmux-utils/window-spawn.ts index f50c2dbcc..8c07faef9 100644 --- a/src/shared/tmux/tmux-utils/window-spawn.ts +++ b/src/shared/tmux/tmux-utils/window-spawn.ts @@ -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): Promise { + 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, ): Promise { - 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 } diff --git a/src/tools/call-omo-agent/background-executor.test.ts b/src/tools/call-omo-agent/background-executor.test.ts index 9e526e82d..d92722245 100644 --- a/src/tools/call-omo-agent/background-executor.test.ts +++ b/src/tools/call-omo-agent/background-executor.test.ts @@ -1,11 +1,5 @@ /// 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" diff --git a/src/tools/look-at/missing-file-error.test.ts b/src/tools/look-at/missing-file-error.test.ts new file mode 100644 index 000000000..7682903e5 --- /dev/null +++ b/src/tools/look-at/missing-file-error.test.ts @@ -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") + }) +}) diff --git a/src/tools/look-at/missing-file-error.ts b/src/tools/look-at/missing-file-error.ts new file mode 100644 index 000000000..3d8be9c86 --- /dev/null +++ b/src/tools/look-at/missing-file-error.ts @@ -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") +} diff --git a/src/tools/look-at/tools.ts b/src/tools/look-at/tools.ts index d6fbb3b01..fff7308f0 100644 --- a/src/tools/look-at/tools.ts +++ b/src/tools/look-at/tools.ts @@ -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}`