diff --git a/src/hooks/atlas/index.test.ts b/src/hooks/atlas/index.test.ts index c7c8d9b28..9835bb0ed 100644 --- a/src/hooks/atlas/index.test.ts +++ b/src/hooks/atlas/index.test.ts @@ -1950,6 +1950,112 @@ session_id: ses_untrusted_999 expect(callArgs.body.parts[0].text).toContain("ses_auth_flow_123") }) + test("#given blocked-task continuation #when prompt is built #then it requires a plan edit to mark the task blocked", async () => { + // given - boulder state with an incomplete externally blockable task + const planPath = join(TEST_DIR, "blocked-plan.md") + writeFileSync(planPath, "# Plan\n- [ ] Wait for external credentials") + + writeBoulderState(TEST_DIR, { + active_plan: planPath, + started_at: "2026-01-02T10:00:00Z", + session_ids: [MAIN_SESSION_ID], + plan_name: "blocked-plan", + }) + + const mockInput = createMockPluginInput() + const hook = createTestAtlasHook(mockInput) + + // when + await hook.handler({ + event: { + type: "session.idle", + properties: { sessionID: MAIN_SESSION_ID }, + }, + }) + + // then - prompt enforces the behavioral invariant instead of allowing text-only blocker reports + const callArgs = mockInput._promptMock.mock.calls[0][0] + const promptText = callArgs.body.parts[0].text + expect(promptText).toContain("- [~]") + expect(promptText).toMatch(/edit the plan file/i) + expect(promptText).toMatch(/text-only explanation.*not progress/i) + }) + + test("#given continuation emits text without tool progress #when three continuation iterations repeat #then Atlas stalls instead of looping", async () => { + // given - boulder state with one externally blocked task that never receives a tool edit + const planPath = join(TEST_DIR, "blocked-loop-plan.md") + writeFileSync(planPath, "# Plan\n- [ ] Wait for external approval") + + writeBoulderState(TEST_DIR, { + active_plan: planPath, + started_at: "2026-01-02T10:00:00Z", + session_ids: [MAIN_SESSION_ID], + plan_name: "blocked-loop-plan", + }) + + const mockInput = createMockPluginInput() + const hook = createTestAtlasHook(mockInput) + + const originalDateNow = Date.now + let now = 0 + Date.now = () => now + + try { + // when - each idle represents a completed text-only continuation turn with no bash/edit/write progress + for (let iteration = 0; iteration < 4; iteration += 1) { + await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } }) + await flushMicrotasks() + now += 6000 + } + + // then - K=3 continuations are attempted, and the fourth idle aborts cleanly instead of injecting again + expect(mockInput._promptMock).toHaveBeenCalledTimes(3) + } finally { + Date.now = originalDateNow + } + }) + + test("#given continuation makes tangible tool progress #when idle repeats #then no-progress stall counter resets", async () => { + // given - boulder state with incomplete work and a successful edit between continuation turns + const planPath = join(TEST_DIR, "progress-plan.md") + writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2") + + writeBoulderState(TEST_DIR, { + active_plan: planPath, + started_at: "2026-01-02T10:00:00Z", + session_ids: [MAIN_SESSION_ID], + plan_name: "progress-plan", + }) + + const mockInput = createMockPluginInput() + const hook = createTestAtlasHook(mockInput) + + const originalDateNow = Date.now + let now = 0 + Date.now = () => now + + try { + // when - the first continuation is followed by a successful edit tool result + await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } }) + await hook["tool.execute.after"]( + { tool: "edit", sessionID: MAIN_SESSION_ID, callID: "progress-edit" }, + { title: "Edited file", output: "Updated plan", metadata: { filePath: planPath } }, + ) + now += 6000 + + for (let iteration = 0; iteration < 3; iteration += 1) { + await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } }) + await flushMicrotasks() + now += 6000 + } + + // then - one progress reset plus three no-progress continuation attempts are allowed + expect(mockInput._promptMock).toHaveBeenCalledTimes(4) + } finally { + Date.now = originalDateNow + } + }) + test("should inject when last agent is sisyphus and boulder targets atlas explicitly", async () => { // given - boulder explicitly set to atlas, but last agent is sisyphus (initial state after /start-work) const planPath = join(TEST_DIR, "test-plan.md") diff --git a/src/hooks/atlas/system-reminder-templates.ts b/src/hooks/atlas/system-reminder-templates.ts index 5ce0dab9e..4df66990d 100644 --- a/src/hooks/atlas/system-reminder-templates.ts +++ b/src/hooks/atlas/system-reminder-templates.ts @@ -31,7 +31,8 @@ RULES: - Proceed without asking for permission - Use the notepad at .omo/notepads/{PLAN_NAME}/ to record learnings - Do not stop until all tasks are complete -- If blocked, document the blocker and move to the next task` +- If a task is blocked by missing external input, unavailable credentials, access limits, or a decision only the user can make, you MUST edit the plan file in this turn and change that task's checkbox from \`- [ ]\` to \`- [~]\` before moving on +- A text-only explanation of a blocker is NOT progress. The \`- [~]\` checkbox edit is mandatory and must happen via a real file-editing tool call` export const BOULDER_COMPLETE_PROMPT = ` BOULDER COMPLETE: plan "{PLAN_NAME}" is fully checked.