diff --git a/src/hooks/ralph-loop/completion-promise-detector.ts b/src/hooks/ralph-loop/completion-promise-detector.ts index 5f60d208..d2b89b10 100644 --- a/src/hooks/ralph-loop/completion-promise-detector.ts +++ b/src/hooks/ralph-loop/completion-promise-detector.ts @@ -63,10 +63,19 @@ export async function detectCompletionInSessionMessages( options.apiTimeoutMs, ) - const messages = (response as { data?: unknown[] }).data ?? [] - if (!Array.isArray(messages)) return false + const messagesResponse: unknown = response + const responseData = + typeof messagesResponse === "object" && messagesResponse !== null && "data" in messagesResponse + ? (messagesResponse as { data?: unknown }).data + : undefined - const assistantMessages = (messages as OpenCodeSessionMessage[]).filter((msg) => msg.info?.role === "assistant") + const messageArray: unknown[] = Array.isArray(messagesResponse) + ? messagesResponse + : Array.isArray(responseData) + ? responseData + : [] + + const assistantMessages = (messageArray as OpenCodeSessionMessage[]).filter((msg) => msg.info?.role === "assistant") if (assistantMessages.length === 0) return false const pattern = buildPromisePattern(options.promise) @@ -74,10 +83,11 @@ export async function detectCompletionInSessionMessages( for (const assistant of recentAssistants) { if (!assistant.parts) continue - const responseText = assistant.parts - .filter((p) => p.type === "text" || p.type === "reasoning") - .map((p) => p.text ?? "") - .join("\n") + let responseText = "" + for (const part of assistant.parts) { + if (part.type !== "text") continue + responseText += `${responseText ? "\n" : ""}${part.text ?? ""}` + } if (pattern.test(responseText)) { return true diff --git a/src/hooks/ralph-loop/index.test.ts b/src/hooks/ralph-loop/index.test.ts index 851e0ce1..219728c4 100644 --- a/src/hooks/ralph-loop/index.test.ts +++ b/src/hooks/ralph-loop/index.test.ts @@ -1,3 +1,4 @@ +/// import { describe, expect, test, beforeEach, afterEach } from "bun:test" import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs" import { join } from "node:path" @@ -12,6 +13,7 @@ describe("ralph-loop", () => { 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 }> }> + let mockMessagesApiResponseShape: "data" | "array" function createMockPluginInput() { return { @@ -33,7 +35,7 @@ describe("ralph-loop", () => { }, messages: async (opts: { path: { id: string } }) => { messagesCalls.push({ sessionID: opts.path.id }) - return { data: mockSessionMessages } + return mockMessagesApiResponseShape === "array" ? mockSessionMessages : { data: mockSessionMessages } }, }, tui: { @@ -56,6 +58,7 @@ describe("ralph-loop", () => { toastCalls = [] messagesCalls = [] mockSessionMessages = [] + mockMessagesApiResponseShape = "data" if (!existsSync(TEST_DIR)) { mkdirSync(TEST_DIR, { recursive: true }) @@ -511,7 +514,37 @@ describe("ralph-loop", () => { expect(messagesCalls[0].sessionID).toBe("session-123") }) - test("should detect completion promise in reasoning part via session messages API", async () => { + test("should detect completion promise via session messages API when API returns array", async () => { + // given - active loop with assistant message containing completion promise + mockMessagesApiResponseShape = "array" + 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 ignore completion promise in reasoning part via session messages API", async () => { //#given - active loop with assistant reasoning containing completion promise mockSessionMessages = [ { info: { role: "user" }, parts: [{ type: "text", text: "Build something" }] }, @@ -527,6 +560,7 @@ describe("ralph-loop", () => { }) hook.startLoop("session-123", "Build something", { completionPromise: "REASONING_DONE", + maxIterations: 10, }) //#when - session goes idle @@ -537,10 +571,13 @@ describe("ralph-loop", () => { }, }) - //#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 - completion promise in reasoning is ignored, continuation injected + expect(promptCalls.length).toBe(1) + expect(toastCalls.some((t) => t.title === "Ralph Loop Complete!")).toBe(false) + + const state = hook.getState() + expect(state).not.toBeNull() + expect(state?.iteration).toBe(2) }) test("should handle multiple iterations correctly", async () => {