test: add comprehensive tests for boulder lineage and completion

Add tests for descendant session detection, agent mismatch handling,
background task retry logic, and start-work functionality.

🤖 Generated with assistance of OhMyOpenCode
This commit is contained in:
YeonGyu-Kim
2026-04-05 15:34:22 +09:00
parent 91c1c32c13
commit 97ccbf1da3
3 changed files with 490 additions and 0 deletions
+101
View File
@@ -3,11 +3,18 @@ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { tmpdir } from "node:os"
import type { RunContext } from "./types"
import {
_resetForTesting,
registerAgentName,
setSessionAgent,
subagentSessions,
} from "../../features/claude-code-session-state"
import { writeState as writeRalphLoopState } from "../../hooks/ralph-loop/storage"
const testDirs: string[] = []
afterEach(() => {
_resetForTesting()
while (testDirs.length > 0) {
const dir = testDirs.pop()
if (dir) {
@@ -29,6 +36,12 @@ function createMockContext(directory: string): RunContext {
todo: mock(() => Promise.resolve({ data: [] })),
children: mock(() => Promise.resolve({ data: [] })),
status: mock(() => Promise.resolve({ data: {} })),
get: mock(async ({ path }: { path: { id: string } }) => ({
data: {
id: path.id,
parentID: undefined,
},
})),
},
} as unknown as RunContext["client"],
sessionID: "test-session",
@@ -90,6 +103,94 @@ describe("checkCompletionConditions continuation coverage", () => {
expect(result).toBe(true)
})
it("returns false when current session is a descendant of an active boulder session with unchecked plan items", async () => {
// given
spyOn(console, "log").mockImplementation(() => {})
registerAgentName("atlas")
const directory = createTempDir()
const planPath = join(directory, ".sisyphus", "plans", "active-descendant-plan.md")
mkdirSync(join(directory, ".sisyphus", "plans"), { recursive: true })
writeFileSync(planPath, "- [ ] unfinished task\n", "utf-8")
writeBoulderStateFile(directory, planPath, ["root-session"])
const ctx = createMockContext(directory)
ctx.sessionID = "child-session"
subagentSessions.add("child-session")
setSessionAgent("child-session", "atlas")
ctx.client.session.get = mock(async ({ path }: { path: { id: string } }) => ({
data: {
id: path.id,
parentID: path.id === "child-session" ? "root-session" : undefined,
},
})) as unknown as RunContext["client"]["session"]["get"]
const { checkCompletionConditions } = await import("./completion")
// when
const result = await checkCompletionConditions(ctx)
// then
expect(result).toBe(false)
})
it("returns true when current session is only in lineage but is not a registered subagent", async () => {
// given
spyOn(console, "log").mockImplementation(() => {})
registerAgentName("atlas")
const directory = createTempDir()
const planPath = join(directory, ".sisyphus", "plans", "lineage-non-subagent-plan.md")
mkdirSync(join(directory, ".sisyphus", "plans"), { recursive: true })
writeFileSync(planPath, "- [ ] unfinished task\n", "utf-8")
writeBoulderStateFile(directory, planPath, ["root-session"])
const ctx = createMockContext(directory)
ctx.sessionID = "lineage-only-session"
ctx.client.session.get = mock(async ({ path }: { path: { id: string } }) => ({
data: {
id: path.id,
parentID: path.id === "lineage-only-session" ? "root-session" : undefined,
},
})) as unknown as RunContext["client"]["session"]["get"]
const { checkCompletionConditions } = await import("./completion")
// when
const result = await checkCompletionConditions(ctx)
// then
expect(result).toBe(true)
})
it("returns true when descendant subagent has agent mismatch and atlas would not continue it", async () => {
// given
spyOn(console, "log").mockImplementation(() => {})
registerAgentName("atlas")
const directory = createTempDir()
const planPath = join(directory, ".sisyphus", "plans", "lineage-agent-mismatch-plan.md")
mkdirSync(join(directory, ".sisyphus", "plans"), { recursive: true })
writeFileSync(planPath, "- [ ] unfinished task\n", "utf-8")
writeBoulderStateFile(directory, planPath, ["root-session"])
const ctx = createMockContext(directory)
ctx.sessionID = "mismatch-subagent-session"
subagentSessions.add("mismatch-subagent-session")
setSessionAgent("mismatch-subagent-session", "sisyphus-junior")
ctx.client.session.get = mock(async ({ path }: { path: { id: string } }) => ({
data: {
id: path.id,
parentID: path.id === "mismatch-subagent-session" ? "root-session" : undefined,
},
})) as unknown as RunContext["client"]["session"]["get"]
const { checkCompletionConditions } = await import("./completion")
// when
const result = await checkCompletionConditions(ctx)
// then
expect(result).toBe(true)
})
it("returns false when active ralph-loop continuation exists for this session", async () => {
// given
spyOn(console, "log").mockImplementation(() => {})
@@ -23,6 +23,20 @@ describe("atlas background task retry", () => {
await Promise.resolve()
}
function createDeferred<T>(): {
promise: Promise<T>
resolve: (value: T | PromiseLike<T>) => void
reject: (reason?: unknown) => void
} {
let resolve!: (value: T | PromiseLike<T>) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise
reject = rejectPromise
})
return { promise, resolve, reject }
}
async function firePendingTimers(): Promise<void> {
const entries = [...capturedTimers.entries()]
for (const [id, entry] of entries) {
@@ -222,4 +236,243 @@ describe("atlas background task retry", () => {
expect(promptMock).toHaveBeenCalledTimes(1)
expect(capturedTimers.size).toBe(0)
})
test("#given retry gate sees no running task but injector still does #when retry fires #then atlas schedules another retry and does not advance cooldown", async () => {
// given
const planPath = join(testDir, "test-plan.md")
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2")
writeBoulderState(testDir, {
active_plan: planPath,
started_at: "2026-01-02T10:00:00Z",
session_ids: [sessionID],
plan_name: "test-plan",
agent: "atlas",
})
const promptAsyncMock = mock(async () => ({}))
let backgroundCheckCount = 0
const hook = createAtlasHook({
directory: testDir,
client: {
session: {
promptAsync: promptAsyncMock,
messages: async () => ({ data: [] }),
},
},
} as unknown as PluginInput, {
directory: testDir,
backgroundManager: {
getTasksByParentSession: () => {
backgroundCheckCount += 1
if (backgroundCheckCount === 1) {
return []
}
if (backgroundCheckCount === 2) {
return [{ status: "running" }]
}
return []
},
} as unknown as NonNullable<Parameters<typeof createAtlasHook>[1]>["backgroundManager"] & {
getTasksByParentSession: (sessionID: string) => Array<{ status: string }>
},
})
// when
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
expect(capturedTimers.size).toBe(1)
expect(promptAsyncMock).toHaveBeenCalledTimes(0)
await firePendingTimers()
// then
expect(backgroundCheckCount).toBe(4)
expect(capturedTimers.size).toBe(0)
expect(promptAsyncMock).toHaveBeenCalledTimes(1)
})
test("#given a retry timer is pending #when a normal idle event resumes work first #then the stale retry timer does not inject again", async () => {
// given
const planPath = join(testDir, "test-plan.md")
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2")
writeBoulderState(testDir, {
active_plan: planPath,
started_at: "2026-01-02T10:00:00Z",
session_ids: [sessionID],
plan_name: "test-plan",
agent: "atlas",
})
let backgroundRunning = true
const promptAsyncMock = mock(async () => ({}))
const hook = createAtlasHook({
directory: testDir,
client: {
session: {
promptAsync: promptAsyncMock,
messages: async () => ({ data: [] }),
},
},
} as unknown as PluginInput, {
directory: testDir,
backgroundManager: {
getTasksByParentSession: () => backgroundRunning ? [{ status: "running" }] : [],
} as unknown as NonNullable<Parameters<typeof createAtlasHook>[1]>["backgroundManager"] & {
getTasksByParentSession: (sessionID: string) => Array<{ status: string }>
},
})
// when
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
expect(capturedTimers.size).toBe(1)
backgroundRunning = false
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
expect(promptAsyncMock).toHaveBeenCalledTimes(1)
expect(capturedTimers.size).toBe(0)
await firePendingTimers()
// then
expect(promptAsyncMock).toHaveBeenCalledTimes(1)
})
test("#given continuation injection is already in flight #when another idle event arrives #then atlas does not inject twice", async () => {
// given
const planPath = join(testDir, "test-plan.md")
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2")
writeBoulderState(testDir, {
active_plan: planPath,
started_at: "2026-01-02T10:00:00Z",
session_ids: [sessionID],
plan_name: "test-plan",
agent: "atlas",
})
const deferredPrompt = createDeferred<{}>()
const promptAsyncMock = mock(() => deferredPrompt.promise)
const hook = createAtlasHook({
directory: testDir,
client: {
session: {
promptAsync: promptAsyncMock,
messages: async () => ({ data: [] }),
},
},
} as unknown as PluginInput)
// when
const firstIdle = hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await flushMicrotasks()
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
deferredPrompt.resolve({})
await firstIdle
// then
expect(promptAsyncMock).toHaveBeenCalledTimes(1)
})
test("#given a retry timer fires during an in-flight continuation that later fails #when the in-flight guard re-arms retry #then atlas can recover on the next retry", async () => {
// given
const planPath = join(testDir, "test-plan.md")
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2")
writeBoulderState(testDir, {
active_plan: planPath,
started_at: "2026-01-02T10:00:00Z",
session_ids: [sessionID],
plan_name: "test-plan",
agent: "atlas",
})
const deferredPrompt = createDeferred<unknown>()
const promptAsyncMock = mock(() => deferredPrompt.promise)
promptAsyncMock.mockImplementationOnce(() => deferredPrompt.promise)
promptAsyncMock.mockImplementationOnce(async () => ({}))
const hook = createAtlasHook({
directory: testDir,
client: {
session: {
promptAsync: promptAsyncMock,
messages: async () => ({ data: [] }),
},
},
} as unknown as PluginInput, {
directory: testDir,
backgroundManager: {
getTasksByParentSession: () => [],
} as unknown as NonNullable<Parameters<typeof createAtlasHook>[1]>["backgroundManager"] & {
getTasksByParentSession: (sessionID: string) => Array<{ status: string }>
},
})
// when
const firstIdle = hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await flushMicrotasks()
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
expect(capturedTimers.size).toBe(1)
await firePendingTimers()
expect(capturedTimers.size).toBe(1)
deferredPrompt.reject(new Error("slow failure"))
await firstIdle
await firePendingTimers()
// then
expect(promptAsyncMock).toHaveBeenCalledTimes(2)
})
test("#given a retry-driven continuation fails once #when retry handling re-arms the chain #then atlas recovers on the next retry", async () => {
// given
const planPath = join(testDir, "test-plan.md")
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2")
writeBoulderState(testDir, {
active_plan: planPath,
started_at: "2026-01-02T10:00:00Z",
session_ids: [sessionID],
plan_name: "test-plan",
agent: "atlas",
})
let backgroundRunning = true
const promptAsyncMock = mock(async () => ({}))
promptAsyncMock.mockImplementationOnce(async () => {
throw new Error("retry failed once")
})
promptAsyncMock.mockImplementationOnce(async () => ({}))
const hook = createAtlasHook({
directory: testDir,
client: {
session: {
promptAsync: promptAsyncMock,
messages: async () => ({ data: [] }),
},
},
} as unknown as PluginInput, {
directory: testDir,
backgroundManager: {
getTasksByParentSession: () => backgroundRunning ? [{ status: "running" }] : [],
} as unknown as NonNullable<Parameters<typeof createAtlasHook>[1]>["backgroundManager"] & {
getTasksByParentSession: (sessionID: string) => Array<{ status: string }>
},
})
// when
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
backgroundRunning = false
await firePendingTimers()
expect(promptAsyncMock).toHaveBeenCalledTimes(1)
expect(capturedTimers.size).toBe(1)
await firePendingTimers()
// then
expect(promptAsyncMock).toHaveBeenCalledTimes(2)
expect(capturedTimers.size).toBe(0)
})
})
+136
View File
@@ -1,9 +1,12 @@
/// <reference types="bun-types" />
import { describe, expect, test, beforeEach, afterEach, spyOn } 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 { createStartWorkHook } from "./index"
import { createAtlasHook } from "../atlas"
import { getAgentListDisplayName } from "../../shared/agent-display-names"
import {
writeBoulderState,
@@ -538,6 +541,139 @@ You are starting a Sisyphus work session.
expect(output.message.agent).toBe("Sisyphus (Ultraworker)")
expect(readBoulderState(testDir)?.agent).toBe("sisyphus")
})
test("#given start-work hands the session to Atlas #when Atlas later receives session.idle #then the same session continues the selected plan", async () => {
// given
const plansDir = join(testDir, ".sisyphus", "plans")
mkdirSync(plansDir, { recursive: true })
writeFileSync(join(plansDir, "atlas-plan.md"), "# Plan\n- [ ] Task 1\n- [ ] Task 2")
const promptAsyncMock = spyOn({
promptAsync: async (_request: unknown) => undefined,
}, "promptAsync")
const ctx = {
directory: testDir,
client: {
session: {
promptAsync: promptAsyncMock,
prompt: async (_request: unknown) => undefined,
messages: async () => ({ data: [] }),
},
},
} as unknown as Parameters<typeof createAtlasHook>[0]
const startWorkHook = createStartWorkHook(ctx)
const atlasHook = createAtlasHook(ctx)
const output = {
message: {} as Record<string, unknown>,
parts: [{ type: "text", text: createStartWorkPrompt({ userRequest: "atlas-plan" }) }],
}
// when
await startWorkHook["chat.message"]({ sessionID: "session-123" }, output)
await atlasHook.handler({ event: { type: "session.idle", properties: { sessionID: "session-123" } } })
// then
expect(output.message.agent).toBe(getAgentListDisplayName("atlas"))
expect(readBoulderState(testDir)?.session_ids).toContain("session-123")
expect(readBoulderState(testDir)?.agent).toBe("atlas")
expect(promptAsyncMock).toHaveBeenCalledTimes(1)
promptAsyncMock.mockRestore()
})
test("#given start-work hands the session to Atlas but background work is still running #when that work finishes #then Atlas resumes via retry for the same session", async () => {
// given
const plansDir = join(testDir, ".sisyphus", "plans")
mkdirSync(plansDir, { recursive: true })
writeFileSync(join(plansDir, "atlas-plan.md"), "# Plan\n- [ ] Task 1\n- [ ] Task 2")
const capturedTimers = new Map<number, { callback: Function; cleared: boolean }>()
let nextTimerId = 4000
let backgroundRunning = true
const originalSetTimeout = globalThis.setTimeout
const originalClearTimeout = globalThis.clearTimeout
const originalDateNow = Date.now
let fakeNow = 10000
const promptAsyncMock = spyOn({
promptAsync: async (_request: unknown) => undefined,
}, "promptAsync")
globalThis.setTimeout = ((callback: Function, delay?: number, ...args: unknown[]) => {
const normalized = typeof delay === "number" ? delay : 0
if (normalized >= 5000) {
const id = nextTimerId++
capturedTimers.set(id, { callback: () => callback(...args), cleared: false })
return id as unknown as ReturnType<typeof setTimeout>
}
return originalSetTimeout(callback as Parameters<typeof originalSetTimeout>[0], delay)
}) as unknown as typeof setTimeout
globalThis.clearTimeout = ((id?: number | ReturnType<typeof setTimeout>) => {
if (typeof id === "number" && capturedTimers.has(id)) {
capturedTimers.get(id)!.cleared = true
capturedTimers.delete(id)
return
}
originalClearTimeout(id as Parameters<typeof originalClearTimeout>[0])
}) as unknown as typeof clearTimeout
Date.now = () => fakeNow
const ctx = {
directory: testDir,
client: {
session: {
promptAsync: promptAsyncMock,
prompt: async (_request: unknown) => undefined,
messages: async () => ({ data: [] }),
},
},
} as unknown as Parameters<typeof createAtlasHook>[0]
const startWorkHook = createStartWorkHook(ctx)
const atlasHook = createAtlasHook(ctx, {
directory: testDir,
backgroundManager: {
getTasksByParentSession: () => backgroundRunning ? [{ status: "running" }] : [],
} as unknown as NonNullable<Parameters<typeof createAtlasHook>[1]>["backgroundManager"],
})
const output = {
message: {} as Record<string, unknown>,
parts: [{ type: "text", text: createStartWorkPrompt({ userRequest: "atlas-plan" }) }],
}
async function firePendingTimers(): Promise<void> {
for (const [id, entry] of capturedTimers) {
if (!entry.cleared) {
capturedTimers.delete(id)
fakeNow += 6000
await entry.callback()
}
}
}
try {
// when
await startWorkHook["chat.message"]({ sessionID: "session-123" }, output)
await atlasHook.handler({ event: { type: "session.idle", properties: { sessionID: "session-123" } } })
expect(promptAsyncMock).toHaveBeenCalledTimes(0)
expect(capturedTimers.size).toBe(1)
backgroundRunning = false
await firePendingTimers()
// then
expect(output.message.agent).toBe(getAgentListDisplayName("atlas"))
expect(readBoulderState(testDir)?.session_ids).toContain("session-123")
expect(readBoulderState(testDir)?.agent).toBe("atlas")
expect(promptAsyncMock).toHaveBeenCalledTimes(1)
} finally {
globalThis.setTimeout = originalSetTimeout
globalThis.clearTimeout = originalClearTimeout
Date.now = originalDateNow
promptAsyncMock.mockRestore()
}
})
})
describe("worktree support", () => {