diff --git a/src/hooks/ralph-loop/index.test.ts b/src/hooks/ralph-loop/index.test.ts
index 9c7ce4f13..851e0ce1e 100644
--- a/src/hooks/ralph-loop/index.test.ts
+++ b/src/hooks/ralph-loop/index.test.ts
@@ -511,6 +511,38 @@ describe("ralph-loop", () => {
expect(messagesCalls[0].sessionID).toBe("session-123")
})
+ test("should detect 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" }] },
+ {
+ info: { role: "assistant" },
+ parts: [
+ { type: "reasoning", text: "I am done now. REASONING_DONE" },
+ ],
+ },
+ ]
+ const hook = createRalphLoopHook(createMockPluginInput(), {
+ getTranscriptPath: () => join(TEST_DIR, "nonexistent.jsonl"),
+ })
+ hook.startLoop("session-123", "Build something", {
+ completionPromise: "REASONING_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()
+ })
+
test("should handle multiple iterations correctly", async () => {
// given - active loop
const hook = createRalphLoopHook(createMockPluginInput())
@@ -596,13 +628,14 @@ describe("ralph-loop", () => {
expect(promptCalls.length).toBe(1)
})
- test("should only check LAST assistant message for completion", async () => {
- // given - multiple assistant messages, only first has completion promise
+ test("should check last 3 assistant messages for completion", async () => {
+ // given - multiple assistant messages, promise in recent (not last) assistant message
mockSessionMessages = [
{ info: { role: "user" }, parts: [{ type: "text", text: "Start task" }] },
- { info: { role: "assistant" }, parts: [{ type: "text", text: "I'll work on it. DONE" }] },
+ { info: { role: "assistant" }, parts: [{ type: "text", text: "Working on it." }] },
{ info: { role: "user" }, parts: [{ type: "text", text: "Continue" }] },
- { info: { role: "assistant" }, parts: [{ type: "text", text: "Working on more features..." }] },
+ { info: { role: "assistant" }, parts: [{ type: "text", text: "Nearly there... DONE" }] },
+ { info: { role: "assistant" }, parts: [{ type: "text", text: "(extra output after promise)" }] },
]
const hook = createRalphLoopHook(createMockPluginInput(), {
getTranscriptPath: () => join(TEST_DIR, "nonexistent.jsonl"),
@@ -614,35 +647,36 @@ describe("ralph-loop", () => {
event: { type: "session.idle", properties: { sessionID: "session-123" } },
})
- // then - loop should continue (last message has no completion promise)
- expect(promptCalls.length).toBe(1)
- expect(hook.getState()?.iteration).toBe(2)
- })
-
- test("should detect completion only in LAST assistant message", async () => {
- // given - last assistant message has completion promise
- mockSessionMessages = [
- { info: { role: "user" }, parts: [{ type: "text", text: "Start task" }] },
- { info: { role: "assistant" }, parts: [{ type: "text", text: "Starting work..." }] },
- { info: { role: "user" }, parts: [{ type: "text", text: "Continue" }] },
- { info: { role: "assistant" }, parts: [{ type: "text", text: "Task complete! DONE" }] },
- ]
- const hook = createRalphLoopHook(createMockPluginInput(), {
- getTranscriptPath: () => join(TEST_DIR, "nonexistent.jsonl"),
- })
- hook.startLoop("session-123", "Build something", { completionPromise: "DONE" })
-
- // when - session goes idle
- await hook.event({
- event: { type: "session.idle", properties: { sessionID: "session-123" } },
- })
-
- // then - loop should complete (last message has completion promise)
+ // then - loop should complete (promise found within last 3 assistant messages)
expect(promptCalls.length).toBe(0)
expect(toastCalls.some((t) => t.title === "Ralph Loop Complete!")).toBe(true)
expect(hook.getState()).toBeNull()
})
+ test("should NOT detect completion if promise is older than last 3 assistant messages", async () => {
+ // given - promise appears in an assistant message older than last 3
+ mockSessionMessages = [
+ { info: { role: "user" }, parts: [{ type: "text", text: "Start task" }] },
+ { info: { role: "assistant" }, parts: [{ type: "text", text: "Promise early DONE" }] },
+ { info: { role: "assistant" }, parts: [{ type: "text", text: "More work 1" }] },
+ { info: { role: "assistant" }, parts: [{ type: "text", text: "More work 2" }] },
+ { info: { role: "assistant" }, parts: [{ type: "text", text: "More work 3" }] },
+ ]
+ const hook = createRalphLoopHook(createMockPluginInput(), {
+ getTranscriptPath: () => join(TEST_DIR, "nonexistent.jsonl"),
+ })
+ hook.startLoop("session-123", "Build something", { completionPromise: "DONE" })
+
+ // when - session goes idle
+ await hook.event({
+ event: { type: "session.idle", properties: { sessionID: "session-123" } },
+ })
+
+ // then - loop should continue (promise is older than last 3 assistant messages)
+ expect(promptCalls.length).toBe(1)
+ expect(hook.getState()?.iteration).toBe(2)
+ })
+
test("should allow starting new loop while previous loop is active (different session)", async () => {
// given - active loop in session A
const hook = createRalphLoopHook(createMockPluginInput())
@@ -928,7 +962,7 @@ Original task: Build something`
const elapsed = Date.now() - startTime
// then - should complete quickly (not hang for 10s)
- expect(elapsed).toBeLessThan(2000)
+ expect(elapsed).toBeLessThan(6000)
// then - loop should continue (API error = no completion detected)
expect(promptCalls.length).toBe(1)
expect(apiCallCount).toBeGreaterThan(0)
diff --git a/src/hooks/ralph-loop/index.ts b/src/hooks/ralph-loop/index.ts
index 3cc77edd2..693ef37e2 100644
--- a/src/hooks/ralph-loop/index.ts
+++ b/src/hooks/ralph-loop/index.ts
@@ -67,7 +67,7 @@ export interface RalphLoopHook {
getState: () => RalphLoopState | null
}
-const DEFAULT_API_TIMEOUT = 3000
+const DEFAULT_API_TIMEOUT = 5000
export function createRalphLoopHook(
ctx: PluginInput,
@@ -80,6 +80,23 @@ export function createRalphLoopHook(
const apiTimeout = options?.apiTimeout ?? DEFAULT_API_TIMEOUT
const checkSessionExists = options?.checkSessionExists
+ async function withTimeout(promise: Promise, timeoutMs: number): Promise {
+ let timeoutId: ReturnType | undefined
+ const timeoutPromise = new Promise((_, reject) => {
+ timeoutId = setTimeout(() => {
+ reject(new Error("API timeout"))
+ }, timeoutMs)
+ })
+
+ try {
+ return await Promise.race([promise, timeoutPromise])
+ } finally {
+ if (timeoutId !== undefined) {
+ clearTimeout(timeoutId)
+ }
+ }
+ }
+
function getSessionState(sessionID: string): SessionState {
let state = sessions.get(sessionID)
if (!state) {
@@ -126,34 +143,44 @@ export function createRalphLoopHook(
promise: string
): Promise {
try {
- const response = await Promise.race([
+ const response = await withTimeout(
ctx.client.session.messages({
path: { id: sessionID },
query: { directory: ctx.directory },
}),
- new Promise((_, reject) =>
- setTimeout(() => reject(new Error("API timeout")), apiTimeout)
- ),
- ])
+ apiTimeout
+ )
const messages = (response as { data?: unknown[] }).data ?? []
if (!Array.isArray(messages)) return false
- const assistantMessages = (messages as OpenCodeSessionMessage[]).filter(
- (msg) => msg.info?.role === "assistant"
- )
- const lastAssistant = assistantMessages[assistantMessages.length - 1]
- if (!lastAssistant?.parts) return false
+ const assistantMessages = (messages as OpenCodeSessionMessage[]).filter((msg) => msg.info?.role === "assistant")
+ if (assistantMessages.length === 0) return false
const pattern = new RegExp(`\\s*${escapeRegex(promise)}\\s*`, "is")
- const responseText = lastAssistant.parts
- .filter((p) => p.type === "text")
- .map((p) => p.text ?? "")
- .join("\n")
- return pattern.test(responseText)
+ const recentAssistants = assistantMessages.slice(-3)
+ 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")
+
+ if (pattern.test(responseText)) {
+ return true
+ }
+ }
+
+ return false
} catch (err) {
- log(`[${HOOK_NAME}] Session messages check failed`, { sessionID, error: String(err) })
+ setTimeout(() => {
+ log(`[${HOOK_NAME}] Session messages check failed`, {
+ sessionID,
+ error: String(err),
+ })
+ }, 0)
return false
}
}
@@ -343,7 +370,10 @@ export function createRalphLoopHook(
let model: { providerID: string; modelID: string } | undefined
try {
- const messagesResp = await ctx.client.session.messages({ path: { id: sessionID } })
+ const messagesResp = await withTimeout(
+ ctx.client.session.messages({ path: { id: sessionID } }),
+ apiTimeout
+ )
const messages = (messagesResp.data ?? []) as Array<{
info?: { agent?: string; model?: { providerID: string; modelID: string }; modelID?: string; providerID?: string }
}>