diff --git a/src/hooks/ralph-loop/index.test.ts b/src/hooks/ralph-loop/index.test.ts index 885ecc2..cf48ec9 100644 --- a/src/hooks/ralph-loop/index.test.ts +++ b/src/hooks/ralph-loop/index.test.ts @@ -10,6 +10,8 @@ describe("ralph-loop", () => { const TEST_DIR = join(tmpdir(), "ralph-loop-test-" + Date.now()) let promptCalls: Array<{ sessionID: string; text: string }> let toastCalls: Array<{ title: string; message: string; variant: string }> + let messagesCalls: Array<{ sessionID: string }> + let mockSessionMessages: Array<{ info?: { role?: string }; parts?: Array<{ type: string; text?: string }> }> function createMockPluginInput() { return { @@ -22,6 +24,10 @@ describe("ralph-loop", () => { }) return {} }, + messages: async (opts: { path: { id: string } }) => { + messagesCalls.push({ sessionID: opts.path.id }) + return { data: mockSessionMessages } + }, }, tui: { showToast: async (opts: { body: { title: string; message: string; variant: string } }) => { @@ -35,12 +41,14 @@ describe("ralph-loop", () => { }, }, directory: TEST_DIR, - } as Parameters[0] + } as unknown as Parameters[0] } beforeEach(() => { promptCalls = [] toastCalls = [] + messagesCalls = [] + mockSessionMessages = [] if (!existsSync(TEST_DIR)) { mkdirSync(TEST_DIR, { recursive: true }) @@ -351,6 +359,35 @@ describe("ralph-loop", () => { expect(hook.getState()).toBeNull() }) + test("should detect completion promise via session messages API", async () => { + // #given - active loop with assistant message containing completion promise + mockSessionMessages = [ + { info: { role: "user" }, parts: [{ type: "text", text: "Build something" }] }, + { info: { role: "assistant" }, parts: [{ type: "text", text: "I have completed the task. API_DONE" }] }, + ] + const hook = createRalphLoopHook(createMockPluginInput(), { + getTranscriptPath: () => join(TEST_DIR, "nonexistent.jsonl"), + }) + hook.startLoop("session-123", "Build something", { completionPromise: "API_DONE" }) + + // #when - session goes idle + await hook.event({ + event: { + type: "session.idle", + properties: { sessionID: "session-123" }, + }, + }) + + // #then - loop completed via API detection, no continuation + expect(promptCalls.length).toBe(0) + expect(toastCalls.some((t) => t.title === "Ralph Loop Complete!")).toBe(true) + expect(hook.getState()).toBeNull() + + // #then - messages API was called with correct session ID + expect(messagesCalls.length).toBe(1) + expect(messagesCalls[0].sessionID).toBe("session-123") + }) + test("should handle multiple iterations correctly", async () => { // #given - active loop const hook = createRalphLoopHook(createMockPluginInput()) diff --git a/src/hooks/ralph-loop/index.ts b/src/hooks/ralph-loop/index.ts index 80da2d0..dc1a500 100644 --- a/src/hooks/ralph-loop/index.ts +++ b/src/hooks/ralph-loop/index.ts @@ -18,6 +18,17 @@ interface SessionState { isRecovering?: boolean } +interface OpenCodeSessionMessage { + info?: { + role?: string + } + parts?: Array<{ + type: string + text?: string + [key: string]: unknown + }> +} + const CONTINUATION_PROMPT = `[RALPH LOOP - ITERATION {{ITERATION}}/{{MAX}}] Your previous attempt did not output the completion promise. Continue working on the task. @@ -81,6 +92,41 @@ export function createRalphLoopHook( return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") } + async function detectCompletionInSessionMessages( + sessionID: string, + promise: string + ): Promise { + try { + const response = await ctx.client.session.messages({ + path: { id: sessionID }, + query: { directory: ctx.directory }, + }) + + const messages = (response as { data?: unknown[] }).data ?? [] + + if (!Array.isArray(messages)) return false + + const pattern = new RegExp(`\\s*${escapeRegex(promise)}\\s*`, "is") + + for (const msg of messages as OpenCodeSessionMessage[]) { + if (msg.info?.role !== "assistant") continue + + for (const part of msg.parts || []) { + if (part.type === "text" && part.text) { + if (pattern.test(part.text)) { + return true + } + } + } + } + + return false + } catch (err) { + log(`[${HOOK_NAME}] Failed to fetch session messages`, { sessionID, error: String(err) }) + return false + } + } + const startLoop = ( sessionID: string, prompt: string, @@ -151,14 +197,20 @@ export function createRalphLoopHook( return } - // Generate transcript path from sessionID - OpenCode doesn't pass it in event properties - const transcriptPath = getTranscriptPath(sessionID) + const completionDetectedViaApi = await detectCompletionInSessionMessages( + sessionID, + state.completion_promise + ) - if (detectCompletionPromise(transcriptPath, state.completion_promise)) { + const transcriptPath = getTranscriptPath(sessionID) + const completionDetectedViaTranscript = detectCompletionPromise(transcriptPath, state.completion_promise) + + if (completionDetectedViaApi || completionDetectedViaTranscript) { log(`[${HOOK_NAME}] Completion detected!`, { sessionID, iteration: state.iteration, promise: state.completion_promise, + detectedVia: completionDetectedViaApi ? "session_messages_api" : "transcript_file", }) clearState(ctx.directory, stateDir)