From 69c37e3965c97c72fcae8ec6ba3dd6288fa55f6c Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 27 Apr 2026 18:22:53 +0900 Subject: [PATCH] test(ralph-loop): cover non-abort error continuation Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/hooks/ralph-loop/index.test.ts | 11 ++- .../non-abort-error-continuation.test.ts | 96 +++++++++++++++++++ 2 files changed, 102 insertions(+), 5 deletions(-) create mode 100644 src/hooks/ralph-loop/non-abort-error-continuation.test.ts diff --git a/src/hooks/ralph-loop/index.test.ts b/src/hooks/ralph-loop/index.test.ts index fa7d0032c..88b7433b5 100644 --- a/src/hooks/ralph-loop/index.test.ts +++ b/src/hooks/ralph-loop/index.test.ts @@ -386,8 +386,8 @@ describe("ralph-loop", () => { expect(hook.getState()).not.toBeNull() }) - test("should skip injection during recovery", async () => { - // given - active loop and session in recovery + test("should continue after non-abort session error", async () => { + // given - active loop and non-abort session error const hook = createRalphLoopHook(createMockPluginInput()) hook.startLoop("session-123", "Test task") @@ -398,7 +398,7 @@ describe("ralph-loop", () => { }, }) - // when - session goes idle immediately + // when - session goes idle immediately after the error await hook.event({ event: { type: "session.idle", @@ -406,8 +406,9 @@ describe("ralph-loop", () => { }, }) - // then - no continuation injected - expect(promptCalls.length).toBe(0) + // then - continuation is injected without a recovery skip + expect(promptCalls.length).toBe(1) + expect(hook.getState()?.iteration).toBe(2) }) test("should clear state on session deletion", async () => { diff --git a/src/hooks/ralph-loop/non-abort-error-continuation.test.ts b/src/hooks/ralph-loop/non-abort-error-continuation.test.ts new file mode 100644 index 000000000..470aa8326 --- /dev/null +++ b/src/hooks/ralph-loop/non-abort-error-continuation.test.ts @@ -0,0 +1,96 @@ +/// +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { existsSync, mkdirSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { createRalphLoopHook } from "./index" +import { clearState } from "./storage" + +describe("ralph-loop non-abort error continuation", () => { + const testDirectory = join(tmpdir(), `ralph-loop-non-abort-error-${Date.now()}`) + let promptCalls: Array<{ sessionID: string; text: string }> + let messagesCalls: Array<{ sessionID: string }> + + beforeEach(() => { + promptCalls = [] + messagesCalls = [] + mkdirSync(testDirectory, { recursive: true }) + clearState(testDirectory) + }) + + afterEach(() => { + clearState(testDirectory) + if (existsSync(testDirectory)) { + rmSync(testDirectory, { recursive: true, force: true }) + } + }) + + test("continues on next idle after non-abort session error", async () => { + // given - an active Ralph Loop receives a recoverable command error + const hook = createRalphLoopHook({ + directory: testDirectory, + project: testDirectory, + worktree: testDirectory, + serverUrl: "http://localhost:4096", + $: async () => ({}), + client: { + session: { + messages: async (options: { path: { id: string } }) => { + messagesCalls.push({ sessionID: options.path.id }) + return { data: [] } + }, + promptAsync: async (options: { + path: { id: string } + body: { parts: Array<{ type: string; text: string }> } + }) => { + promptCalls.push({ + sessionID: options.path.id, + text: options.body.parts[0]?.text ?? "", + }) + return {} + }, + prompt: async (options: { + path: { id: string } + body: { parts: Array<{ type: string; text: string }> } + }) => { + promptCalls.push({ + sessionID: options.path.id, + text: options.body.parts[0]?.text ?? "", + }) + return {} + }, + }, + tui: { + showToast: async () => ({}), + }, + }, + } as never) + + hook.startLoop("session-123", "Keep working", { + messageCountAtStart: 0, + maxIterations: 5, + }) + + await hook.event({ + event: { + type: "session.error", + properties: { + sessionID: "session-123", + error: { name: "CommandFailedError" }, + }, + }, + }) + + // when - OpenCode emits the idle event caused by that failed command + await hook.event({ + event: { type: "session.idle", properties: { sessionID: "session-123" } }, + }) + + // then - the loop should continue instead of skipping idle as recovery + expect(promptCalls).toHaveLength(1) + expect(promptCalls[0]?.sessionID).toBe("session-123") + expect(promptCalls[0]?.text).toContain("Keep working") + expect(messagesCalls.length).toBeGreaterThan(0) + expect(hook.getState()?.iteration).toBe(2) + }) +})