From 97ccbf1da379d9330ced0815ff52dd5187c77d32 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 5 Apr 2026 15:34:22 +0900 Subject: [PATCH] test: add comprehensive tests for boulder lineage and completion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add tests for descendant session detection, agent mismatch handling, background task retry logic, and start-work functionality. 🤖 Generated with assistance of OhMyOpenCode --- src/cli/run/completion-continuation.test.ts | 101 +++++++ src/hooks/atlas/background-task-retry.test.ts | 253 ++++++++++++++++++ src/hooks/start-work/index.test.ts | 136 ++++++++++ 3 files changed, 490 insertions(+) diff --git a/src/cli/run/completion-continuation.test.ts b/src/cli/run/completion-continuation.test.ts index afb58b85d..36cd8a6f3 100644 --- a/src/cli/run/completion-continuation.test.ts +++ b/src/cli/run/completion-continuation.test.ts @@ -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(() => {}) diff --git a/src/hooks/atlas/background-task-retry.test.ts b/src/hooks/atlas/background-task-retry.test.ts index 3e90b083d..72c7d2919 100644 --- a/src/hooks/atlas/background-task-retry.test.ts +++ b/src/hooks/atlas/background-task-retry.test.ts @@ -23,6 +23,20 @@ describe("atlas background task retry", () => { await Promise.resolve() } + function createDeferred(): { + promise: Promise + resolve: (value: T | PromiseLike) => void + reject: (reason?: unknown) => void + } { + let resolve!: (value: T | PromiseLike) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + return { promise, resolve, reject } + } + async function firePendingTimers(): Promise { 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[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[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() + 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[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[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) + }) }) diff --git a/src/hooks/start-work/index.test.ts b/src/hooks/start-work/index.test.ts index 193d83bb8..d52a4edab 100644 --- a/src/hooks/start-work/index.test.ts +++ b/src/hooks/start-work/index.test.ts @@ -1,9 +1,12 @@ +/// + 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[0] + const startWorkHook = createStartWorkHook(ctx) + const atlasHook = createAtlasHook(ctx) + const output = { + message: {} as Record, + 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() + 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 + } + + return originalSetTimeout(callback as Parameters[0], delay) + }) as unknown as typeof setTimeout + + globalThis.clearTimeout = ((id?: number | ReturnType) => { + if (typeof id === "number" && capturedTimers.has(id)) { + capturedTimers.get(id)!.cleared = true + capturedTimers.delete(id) + return + } + + originalClearTimeout(id as Parameters[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[0] + const startWorkHook = createStartWorkHook(ctx) + const atlasHook = createAtlasHook(ctx, { + directory: testDir, + backgroundManager: { + getTasksByParentSession: () => backgroundRunning ? [{ status: "running" }] : [], + } as unknown as NonNullable[1]>["backgroundManager"], + }) + const output = { + message: {} as Record, + parts: [{ type: "text", text: createStartWorkPrompt({ userRequest: "atlas-plan" }) }], + } + + async function firePendingTimers(): Promise { + 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", () => {