diff --git a/src/hooks/atlas/index.test.ts b/src/hooks/atlas/index.test.ts index 109ed3de..a1b165e7 100644 --- a/src/hooks/atlas/index.test.ts +++ b/src/hooks/atlas/index.test.ts @@ -858,8 +858,8 @@ describe("atlas hook", () => { expect(callArgs.body.parts[0].text).toContain("2 remaining") }) - test("should not inject when last agent is not Atlas", async () => { - // given - boulder state with incomplete plan, but last agent is NOT Atlas + test("should not inject when last agent does not match boulder agent", async () => { + // given - boulder state with incomplete plan, but last agent does NOT match const planPath = join(TEST_DIR, "test-plan.md") writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2") @@ -868,10 +868,11 @@ describe("atlas hook", () => { started_at: "2026-01-02T10:00:00Z", session_ids: [MAIN_SESSION_ID], plan_name: "test-plan", + agent: "atlas", } writeBoulderState(TEST_DIR, state) - // given - last agent is NOT Atlas + // given - last agent is NOT the boulder agent cleanupMessageStorage(MAIN_SESSION_ID) setupMessageStorage(MAIN_SESSION_ID, "sisyphus") @@ -886,10 +887,44 @@ describe("atlas hook", () => { }, }) - // then - should NOT call prompt because agent is not Atlas + // then - should NOT call prompt because agent does not match expect(mockInput._promptMock).not.toHaveBeenCalled() }) + test("should inject when last agent matches boulder agent even if non-Atlas", async () => { + // given - boulder state expects sisyphus and last agent is sisyphus + const planPath = join(TEST_DIR, "test-plan.md") + writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2") + + const state: BoulderState = { + active_plan: planPath, + started_at: "2026-01-02T10:00:00Z", + session_ids: [MAIN_SESSION_ID], + plan_name: "test-plan", + agent: "sisyphus", + } + writeBoulderState(TEST_DIR, state) + + cleanupMessageStorage(MAIN_SESSION_ID) + setupMessageStorage(MAIN_SESSION_ID, "sisyphus") + + const mockInput = createMockPluginInput() + const hook = createAtlasHook(mockInput) + + // when + await hook.handler({ + event: { + type: "session.idle", + properties: { sessionID: MAIN_SESSION_ID }, + }, + }) + + // then - should call prompt for sisyphus + expect(mockInput._promptMock).toHaveBeenCalled() + const callArgs = mockInput._promptMock.mock.calls[0][0] + expect(callArgs.body.agent).toBe("sisyphus") + }) + test("should debounce rapid continuation injections (prevent infinite loop)", async () => { // given - boulder state with incomplete plan const planPath = join(TEST_DIR, "test-plan.md") diff --git a/src/hooks/atlas/index.ts b/src/hooks/atlas/index.ts index 2583606e..3bf4e78a 100644 --- a/src/hooks/atlas/index.ts +++ b/src/hooks/atlas/index.ts @@ -26,6 +26,13 @@ function isSisyphusPath(filePath: string): boolean { const WRITE_EDIT_TOOLS = ["Write", "Edit", "write", "edit"] +function getLastAgentFromSession(sessionID: string): string | null { + const messageDir = getMessageDir(sessionID) + if (!messageDir) return null + const nearest = findNearestMessageWithFields(messageDir) + return nearest?.agent?.toLowerCase() ?? null +} + const DIRECT_WORK_REMINDER = ` --- @@ -549,8 +556,14 @@ export function createAtlasHook( return } - if (!isCallerOrchestrator(sessionID)) { - log(`[${HOOK_NAME}] Skipped: last agent is not Atlas`, { sessionID }) + const requiredAgent = (boulderState.agent ?? "atlas").toLowerCase() + const lastAgent = getLastAgentFromSession(sessionID) + if (!lastAgent || lastAgent !== requiredAgent) { + log(`[${HOOK_NAME}] Skipped: last agent does not match boulder agent`, { + sessionID, + lastAgent: lastAgent ?? "unknown", + requiredAgent, + }) return }