Files
oh-my-opencode/src/tools/delegate-task/sync-task.test.ts
T

430 lines
13 KiB
TypeScript
Raw Normal View History

const { describe, test, expect, beforeEach, afterEach, mock, spyOn } = require("bun:test")
function clearRequireCache(modulePath: string): void {
const resolvedPath = require.resolve(modulePath)
if (require.cache?.[resolvedPath]) {
delete require.cache[resolvedPath]
}
}
describe("executeSyncTask - cleanup on error paths", () => {
let removeTaskCalls: string[] = []
let addTaskCalls: any[] = []
let deleteCalls: string[] = []
let addCalls: string[] = []
let resetToastManager: (() => void) | null = null
beforeEach(() => {
//#given - configure fast timing for all tests
const { __setTimingConfig } = require("./timing")
__setTimingConfig({
POLL_INTERVAL_MS: 10,
MIN_STABILITY_TIME_MS: 0,
STABILITY_POLLS_REQUIRED: 1,
MAX_POLL_TIME_MS: 100,
})
//#given - reset call tracking
removeTaskCalls = []
addTaskCalls = []
deleteCalls = []
addCalls = []
clearRequireCache("./sync-task")
//#given - initialize real task toast manager (avoid global module mocks)
const { initTaskToastManager, _resetTaskToastManagerForTesting } = require("../../features/task-toast-manager/manager")
_resetTaskToastManagerForTesting()
resetToastManager = _resetTaskToastManagerForTesting
const toastManager = initTaskToastManager({
tui: { showToast: mock(() => Promise.resolve()) },
})
spyOn(toastManager, "addTask").mockImplementation((task: any) => {
addTaskCalls.push(task)
})
spyOn(toastManager, "removeTask").mockImplementation((id: string) => {
removeTaskCalls.push(id)
})
//#given - mock subagentSessions
const { subagentSessions } = require("../../features/claude-code-session-state")
spyOn(subagentSessions, "add").mockImplementation((id: string) => {
addCalls.push(id)
})
spyOn(subagentSessions, "delete").mockImplementation((id: string) => {
deleteCalls.push(id)
})
})
afterEach(() => {
//#given - reset timing after each test
const { __resetTimingConfig } = require("./timing")
__resetTimingConfig()
mock.restore()
resetToastManager?.()
resetToastManager = null
})
test("cleans up toast and subagentSessions when fetchSyncResult returns ok: false", async () => {
const mockClient = {
session: {
create: async () => ({ data: { id: "ses_test_12345678" } }),
},
}
const { executeSyncTask } = require("./sync-task")
const deps = {
createSyncSession: async () => ({ ok: true, sessionID: "ses_test_12345678" }),
sendSyncPrompt: async () => null,
pollSyncSession: async () => null,
fetchSyncResult: async () => ({ ok: false as const, error: "Fetch failed" }),
}
const mockCtx = {
sessionID: "parent-session",
callID: "call-123",
metadata: () => {},
}
const mockExecutorCtx = {
client: mockClient,
directory: "/tmp",
onSyncSessionCreated: null,
}
const args = {
prompt: "test prompt",
description: "test task",
category: "test",
load_skills: [],
run_in_background: false,
command: null,
}
//#when - executeSyncTask with fetchSyncResult failing
const result = await executeSyncTask(args, mockCtx, mockExecutorCtx, {
sessionID: "parent-session",
2026-02-20 00:02:17 +02:00
}, "test-agent", undefined, undefined, undefined, undefined, deps)
//#then - should return error and cleanup resources
expect(result).toBe("Fetch failed")
expect(removeTaskCalls.length).toBe(1)
expect(removeTaskCalls[0]).toBe("sync_ses_test")
expect(deleteCalls.length).toBe(1)
expect(deleteCalls[0]).toBe("ses_test_12345678")
})
test("rolls back reserved descendant quota when sync session creation fails", async () => {
const mockClient = {
session: {
create: async () => ({ data: { id: "ses_test_12345678" } }),
},
}
const { executeSyncTask } = require("./sync-task")
const commit = mock(() => 1)
const rollback = mock(() => {})
const reserveSubagentSpawn = mock(async () => ({
spawnContext: { rootSessionID: "parent-session", parentDepth: 0, childDepth: 1 },
descendantCount: 1,
commit,
rollback,
}))
const deps = {
createSyncSession: async () => ({ ok: false as const, error: "Failed to create session" }),
sendSyncPrompt: async () => null,
pollSyncSession: async () => null,
fetchSyncResult: async () => ({ ok: true as const, textContent: "Result" }),
}
const mockCtx = {
sessionID: "parent-session",
callID: "call-123",
metadata: () => {},
}
const mockExecutorCtx = {
manager: { reserveSubagentSpawn },
client: mockClient,
directory: "/tmp",
onSyncSessionCreated: null,
}
const args = {
prompt: "test prompt",
description: "test task",
category: "test",
load_skills: [],
run_in_background: false,
command: null,
}
//#when
const result = await executeSyncTask(args, mockCtx, mockExecutorCtx, {
sessionID: "parent-session",
}, "test-agent", undefined, undefined, undefined, undefined, deps)
//#then
expect(result).toBe("Failed to create session")
expect(reserveSubagentSpawn).toHaveBeenCalledWith("parent-session")
expect(commit).toHaveBeenCalledTimes(0)
expect(rollback).toHaveBeenCalledTimes(1)
})
test("cleans up toast and subagentSessions when pollSyncSession returns error", async () => {
const mockClient = {
session: {
create: async () => ({ data: { id: "ses_test_12345678" } }),
},
}
const { executeSyncTask } = require("./sync-task")
const deps = {
createSyncSession: async () => ({ ok: true, sessionID: "ses_test_12345678" }),
sendSyncPrompt: async () => null,
pollSyncSession: async () => "Poll error",
fetchSyncResult: async () => ({ ok: true as const, textContent: "Result" }),
}
const mockCtx = {
sessionID: "parent-session",
callID: "call-123",
metadata: () => {},
}
const mockExecutorCtx = {
client: mockClient,
directory: "/tmp",
onSyncSessionCreated: null,
}
const args = {
prompt: "test prompt",
description: "test task",
category: "test",
load_skills: [],
run_in_background: false,
command: null,
}
//#when - executeSyncTask with pollSyncSession failing
const result = await executeSyncTask(args, mockCtx, mockExecutorCtx, {
sessionID: "parent-session",
2026-02-20 00:02:17 +02:00
}, "test-agent", undefined, undefined, undefined, undefined, deps)
//#then - should return error and cleanup resources
expect(result).toBe("Poll error")
expect(removeTaskCalls.length).toBe(1)
expect(removeTaskCalls[0]).toBe("sync_ses_test")
expect(deleteCalls.length).toBe(1)
expect(deleteCalls[0]).toBe("ses_test_12345678")
})
test("cleans up toast and subagentSessions on successful completion", async () => {
const mockClient = {
session: {
create: async () => ({ data: { id: "ses_test_12345678" } }),
},
}
const { executeSyncTask } = require("./sync-task")
const deps = {
createSyncSession: async () => ({ ok: true, sessionID: "ses_test_12345678" }),
sendSyncPrompt: async () => null,
pollSyncSession: async () => null,
fetchSyncResult: async () => ({ ok: true as const, textContent: "Result" }),
}
const mockCtx = {
sessionID: "parent-session",
callID: "call-123",
metadata: () => {},
}
const commit = mock(() => 1)
const rollback = mock(() => {})
const mockExecutorCtx = {
manager: {
reserveSubagentSpawn: mock(async () => ({
spawnContext: { rootSessionID: "parent-session", parentDepth: 0, childDepth: 1 },
descendantCount: 1,
commit,
rollback,
})),
},
client: mockClient,
directory: "/tmp",
onSyncSessionCreated: null,
}
const args = {
prompt: "test prompt",
description: "test task",
category: "test",
load_skills: [],
run_in_background: false,
command: null,
}
//#when - executeSyncTask completes successfully
const result = await executeSyncTask(args, mockCtx, mockExecutorCtx, {
sessionID: "parent-session",
2026-02-20 00:02:17 +02:00
}, "test-agent", undefined, undefined, undefined, undefined, deps)
//#then - should complete and cleanup resources
expect(result).toContain("Task completed")
expect(mockExecutorCtx.manager.reserveSubagentSpawn).toHaveBeenCalledWith("parent-session")
expect(commit).toHaveBeenCalledTimes(1)
expect(rollback).toHaveBeenCalledTimes(0)
expect(removeTaskCalls.length).toBe(1)
expect(removeTaskCalls[0]).toBe("sync_ses_test")
expect(deleteCalls.length).toBe(1)
expect(deleteCalls[0]).toBe("ses_test_12345678")
})
test("depth regression: blocks spawn when reserveSubagentSpawn throws depth limit error", async () => {
// This is a smoke test guarding against regressions where the depth limit
// would be silently bypassed (e.g. via a fallback path that hardcodes
// childDepth: 1).
const mockClient = {
session: {
create: async () => ({ data: { id: "ses_test_12345678" } }),
},
}
const { executeSyncTask } = require("./sync-task")
const reserveSubagentSpawn = mock(async () => {
throw new Error(
"Subagent spawn blocked: child depth 4 exceeds background_task.maxDepth=3. Parent session: parent. Root session: root. Continue in an existing subagent session instead of spawning another."
)
})
const deps = {
createSyncSession: async () => ({ ok: true, sessionID: "ses_test_12345678" }),
sendSyncPrompt: async () => null,
pollSyncSession: async () => null,
fetchSyncResult: async () => ({ ok: true as const, textContent: "Result" }),
}
const mockCtx = {
sessionID: "parent-session",
callID: "call-123",
metadata: () => {},
}
const mockExecutorCtx = {
manager: { reserveSubagentSpawn },
client: mockClient,
directory: "/tmp",
onSyncSessionCreated: null,
}
const args = {
prompt: "test prompt",
description: "test task",
category: "test",
load_skills: [],
run_in_background: false,
command: null,
}
//#when - executeSyncTask is called from a session at max depth
const result = await executeSyncTask(args, mockCtx, mockExecutorCtx, {
sessionID: "parent-session",
}, "test-agent", undefined, undefined, undefined, undefined, deps)
//#then - should propagate the depth limit error and NOT create the session
expect(result).toContain("Subagent spawn blocked")
expect(result).toContain("child depth 4")
expect(result).toContain("maxDepth=3")
expect(reserveSubagentSpawn).toHaveBeenCalledWith("parent-session")
// critical: createSyncSession must NOT have been called -- if it was,
// the depth guard was bypassed.
expect(addCalls.length).toBe(0)
})
test("depth regression: does not silently fall back to childDepth: 1 when manager methods are present", async () => {
// Guards against the dangerous fallback path in sync-task.ts that
// hardcodes childDepth: 1 if reserveSubagentSpawn / assertCanSpawn are
// not functions. With a real manager present, the fallback must NOT be
// taken.
const mockClient = {
session: {
create: async () => ({ data: { id: "ses_test_12345678" } }),
},
}
const { executeSyncTask } = require("./sync-task")
let reservedDepth: number | undefined
const commit = mock(() => 1)
const rollback = mock(() => {})
const reserveSubagentSpawn = mock(async () => {
// Return a depth that proves the real manager was consulted
reservedDepth = 3
return {
spawnContext: { rootSessionID: "root", parentDepth: 2, childDepth: 3 },
descendantCount: 5,
commit,
rollback,
}
})
const deps = {
createSyncSession: async () => ({ ok: true, sessionID: "ses_test_12345678" }),
sendSyncPrompt: async () => null,
pollSyncSession: async () => null,
fetchSyncResult: async () => ({ ok: true as const, textContent: "Result" }),
}
const metadataCalls: any[] = []
const mockCtx = {
sessionID: "parent-session",
callID: "call-123",
metadata: (input: any) => { metadataCalls.push(input) },
}
const mockExecutorCtx = {
manager: { reserveSubagentSpawn },
client: mockClient,
directory: "/tmp",
onSyncSessionCreated: null,
}
const args = {
prompt: "test prompt",
description: "test task",
category: "test",
load_skills: [],
run_in_background: false,
command: null,
}
//#when
await executeSyncTask(args, mockCtx, mockExecutorCtx, {
sessionID: "parent-session",
}, "test-agent", undefined, undefined, undefined, undefined, deps)
//#then - the spawnDepth recorded in metadata MUST match what reserveSubagentSpawn returned
expect(reservedDepth).toBe(3)
const taskMeta = metadataCalls.find((c) => c.metadata?.spawnDepth !== undefined)
expect(taskMeta).toBeDefined()
expect(taskMeta.metadata.spawnDepth).toBe(3) // NOT 1 (the fallback value)
})
})
export {}