diff --git a/src/hooks/ralph-loop/index.test.ts b/src/hooks/ralph-loop/index.test.ts
index cf48ec9..cbc53e2 100644
--- a/src/hooks/ralph-loop/index.test.ts
+++ b/src/hooks/ralph-loop/index.test.ts
@@ -423,5 +423,162 @@ describe("ralph-loop", () => {
expect(promptCalls[0].text).toContain("Create a calculator app")
expect(promptCalls[0].text).toContain("CALCULATOR_DONE")
})
+
+ test("should clear loop state on user abort (MessageAbortedError)", async () => {
+ // #given - active loop
+ const hook = createRalphLoopHook(createMockPluginInput())
+ hook.startLoop("session-123", "Build something")
+ expect(hook.getState()).not.toBeNull()
+
+ // #when - user aborts (Ctrl+C)
+ await hook.event({
+ event: {
+ type: "session.error",
+ properties: {
+ sessionID: "session-123",
+ error: { name: "MessageAbortedError", message: "User aborted" },
+ },
+ },
+ })
+
+ // #then - loop state should be cleared immediately
+ expect(hook.getState()).toBeNull()
+ })
+
+ test("should NOT set recovery mode on user abort", async () => {
+ // #given - active loop
+ const hook = createRalphLoopHook(createMockPluginInput())
+ hook.startLoop("session-123", "Build something")
+
+ // #when - user aborts (Ctrl+C)
+ await hook.event({
+ event: {
+ type: "session.error",
+ properties: {
+ sessionID: "session-123",
+ error: { name: "MessageAbortedError" },
+ },
+ },
+ })
+
+ // Start a new loop
+ hook.startLoop("session-123", "New task")
+
+ // #when - session goes idle immediately (should work, no recovery mode)
+ await hook.event({
+ event: { type: "session.idle", properties: { sessionID: "session-123" } },
+ })
+
+ // #then - continuation should be injected (not blocked by recovery)
+ expect(promptCalls.length).toBe(1)
+ })
+
+ test("should only check LAST assistant message for completion", async () => {
+ // #given - multiple assistant messages, only first has completion promise
+ 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: "user" }, parts: [{ type: "text", text: "Continue" }] },
+ { info: { role: "assistant" }, parts: [{ type: "text", text: "Working on more features..." }] },
+ ]
+ 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 (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)
+ expect(promptCalls.length).toBe(0)
+ expect(toastCalls.some((t) => t.title === "Ralph Loop Complete!")).toBe(true)
+ expect(hook.getState()).toBeNull()
+ })
+
+ test("should check transcript BEFORE API to optimize performance", async () => {
+ // #given - transcript has completion promise
+ const transcriptPath = join(TEST_DIR, "transcript.jsonl")
+ writeFileSync(transcriptPath, JSON.stringify({ content: "DONE" }))
+ mockSessionMessages = [
+ { info: { role: "assistant" }, parts: [{ type: "text", text: "No promise here" }] },
+ ]
+ const hook = createRalphLoopHook(createMockPluginInput(), {
+ getTranscriptPath: () => transcriptPath,
+ })
+ hook.startLoop("session-123", "Build something", { completionPromise: "DONE" })
+
+ // #when - session goes idle
+ await hook.event({
+ event: { type: "session.idle", properties: { sessionID: "session-123" } },
+ })
+
+ // #then - should complete via transcript (API not called when transcript succeeds)
+ expect(promptCalls.length).toBe(0)
+ expect(hook.getState()).toBeNull()
+ // API should NOT be called since transcript found completion
+ expect(messagesCalls.length).toBe(0)
+ })
+ })
+
+ describe("API timeout protection", () => {
+ test("should not hang when session.messages() times out", async () => {
+ // #given - slow API that takes longer than timeout
+ const slowMock = {
+ ...createMockPluginInput(),
+ client: {
+ ...createMockPluginInput().client,
+ session: {
+ ...createMockPluginInput().client.session,
+ messages: async () => {
+ // Simulate slow API (would hang without timeout)
+ await new Promise((resolve) => setTimeout(resolve, 10000))
+ return { data: [] }
+ },
+ },
+ },
+ }
+ const hook = createRalphLoopHook(slowMock as any, {
+ getTranscriptPath: () => join(TEST_DIR, "nonexistent.jsonl"),
+ apiTimeout: 100, // 100ms timeout for test
+ })
+ hook.startLoop("session-123", "Build something")
+
+ // #when - session goes idle (API will timeout)
+ const startTime = Date.now()
+ await hook.event({
+ event: { type: "session.idle", properties: { sessionID: "session-123" } },
+ })
+ const elapsed = Date.now() - startTime
+
+ // #then - should complete within timeout + buffer (not hang for 10s)
+ expect(elapsed).toBeLessThan(500)
+ // #then - loop should continue (API timeout = no completion detected)
+ expect(promptCalls.length).toBe(1)
+ })
})
})
diff --git a/src/hooks/ralph-loop/index.ts b/src/hooks/ralph-loop/index.ts
index dc1a500..8804abe 100644
--- a/src/hooks/ralph-loop/index.ts
+++ b/src/hooks/ralph-loop/index.ts
@@ -53,6 +53,8 @@ export interface RalphLoopHook {
getState: () => RalphLoopState | null
}
+const DEFAULT_API_TIMEOUT = 3000
+
export function createRalphLoopHook(
ctx: PluginInput,
options?: RalphLoopOptions
@@ -61,6 +63,7 @@ export function createRalphLoopHook(
const config = options?.config
const stateDir = config?.state_dir
const getTranscriptPath = options?.getTranscriptPath ?? getDefaultTranscriptPath
+ const apiTimeout = options?.apiTimeout ?? DEFAULT_API_TIMEOUT
function getSessionState(sessionID: string): SessionState {
let state = sessions.get(sessionID)
@@ -97,32 +100,34 @@ export function createRalphLoopHook(
promise: string
): Promise {
try {
- const response = await ctx.client.session.messages({
- path: { id: sessionID },
- query: { directory: ctx.directory },
- })
+ const response = await Promise.race([
+ ctx.client.session.messages({
+ path: { id: sessionID },
+ query: { directory: ctx.directory },
+ }),
+ new Promise((_, reject) =>
+ setTimeout(() => reject(new Error("API timeout")), 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 pattern = new RegExp(`\\s*${escapeRegex(promise)}\\s*`, "is")
+ const responseText = lastAssistant.parts
+ .filter((p) => p.type === "text")
+ .map((p) => p.text ?? "")
+ .join("\n")
- 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
+ return pattern.test(responseText)
} catch (err) {
- log(`[${HOOK_NAME}] Failed to fetch session messages`, { sessionID, error: String(err) })
+ log(`[${HOOK_NAME}] Session messages check failed`, { sessionID, error: String(err) })
return false
}
}
@@ -197,20 +202,19 @@ export function createRalphLoopHook(
return
}
- const completionDetectedViaApi = await detectCompletionInSessionMessages(
- sessionID,
- state.completion_promise
- )
-
const transcriptPath = getTranscriptPath(sessionID)
const completionDetectedViaTranscript = detectCompletionPromise(transcriptPath, state.completion_promise)
- if (completionDetectedViaApi || completionDetectedViaTranscript) {
+ const completionDetectedViaApi = completionDetectedViaTranscript
+ ? false
+ : await detectCompletionInSessionMessages(sessionID, state.completion_promise)
+
+ if (completionDetectedViaTranscript || completionDetectedViaApi) {
log(`[${HOOK_NAME}] Completion detected!`, {
sessionID,
iteration: state.iteration,
promise: state.completion_promise,
- detectedVia: completionDetectedViaApi ? "session_messages_api" : "transcript_file",
+ detectedVia: completionDetectedViaTranscript ? "transcript_file" : "session_messages_api",
})
clearState(ctx.directory, stateDir)
@@ -308,6 +312,20 @@ export function createRalphLoopHook(
if (event.type === "session.error") {
const sessionID = props?.sessionID as string | undefined
+ const error = props?.error as { name?: string } | undefined
+
+ if (error?.name === "MessageAbortedError") {
+ if (sessionID) {
+ const state = readState(ctx.directory, stateDir)
+ if (state?.session_id === sessionID) {
+ clearState(ctx.directory, stateDir)
+ log(`[${HOOK_NAME}] User aborted, loop cleared`, { sessionID })
+ }
+ sessions.delete(sessionID)
+ }
+ return
+ }
+
if (sessionID) {
const sessionState = getSessionState(sessionID)
sessionState.isRecovering = true
diff --git a/src/hooks/ralph-loop/types.ts b/src/hooks/ralph-loop/types.ts
index 08a3ccd..7ec6df7 100644
--- a/src/hooks/ralph-loop/types.ts
+++ b/src/hooks/ralph-loop/types.ts
@@ -13,4 +13,5 @@ export interface RalphLoopState {
export interface RalphLoopOptions {
config?: RalphLoopConfig
getTranscriptPath?: (sessionId: string) => string
+ apiTimeout?: number
}