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 () => {