diff --git a/src/hooks/ralph-loop/index.test.ts b/src/hooks/ralph-loop/index.test.ts
index 9676f9ba6..4bda8eb0b 100644
--- a/src/hooks/ralph-loop/index.test.ts
+++ b/src/hooks/ralph-loop/index.test.ts
@@ -713,6 +713,53 @@ describe("ralph-loop", () => {
expect(messagesCalls[0].sessionID).toBe("session-123")
})
+ test("#given completion lands during continuation dispatch #when idle returns #then completion wins over iteration toast", async () => {
+ // given - active loop whose completion promise appears while dispatch is in progress
+ const transcriptPath = join(TEST_DIR, "transcript.jsonl")
+ const pluginInput = createMockPluginInput()
+ Object.defineProperty(pluginInput.client.session, "promptAsync", {
+ value: async (opts: { path: { id: string }; body: { parts: Array<{ type: string; text: string }> } }) => {
+ promptCalls.push({
+ sessionID: opts.path.id,
+ text: opts.body.parts[0].text,
+ })
+ writeFileSync(
+ transcriptPath,
+ JSON.stringify({
+ type: "assistant",
+ timestamp: new Date().toISOString(),
+ content: "Task finished DONE",
+ }) + "\n",
+ )
+ return {}
+ },
+ })
+
+ const hook = createRalphLoopHook(pluginInput, {
+ getTranscriptPath: () => transcriptPath,
+ })
+ hook.startLoop("session-123", "Build something", {
+ completionPromise: "DONE",
+ maxIterations: 5,
+ })
+
+ // when - idle handler begins continuation, then completion appears before dispatch returns
+ await hook.event({
+ event: {
+ type: "session.idle",
+ properties: { sessionID: "session-123" },
+ },
+ })
+
+ // then - loop completes without publishing a stale iteration toast
+ expect(promptCalls.length).toBe(1)
+ expect(hook.getState()).toBeNull()
+ expect(toastCalls.some((t) => t.title === "Ralph Loop Complete!")).toBe(true)
+ expect(
+ toastCalls.some((t) => t.title === "Ralph Loop" && t.message.includes("Iteration")),
+ ).toBe(false)
+ })
+
test("should ignore completion promise in reasoning part via session messages API", async () => {
//#given - active loop with assistant reasoning containing completion promise
mockSessionMessages = [
diff --git a/src/hooks/ralph-loop/ralph-loop-event-handler.ts b/src/hooks/ralph-loop/ralph-loop-event-handler.ts
index 167454b58..87c0f9435 100644
--- a/src/hooks/ralph-loop/ralph-loop-event-handler.ts
+++ b/src/hooks/ralph-loop/ralph-loop-event-handler.ts
@@ -82,9 +82,83 @@ function showToastBestEffort(
try {
void Promise.resolve(ctx.client.tui?.showToast?.({ body })).catch(() => {})
} catch {
+ return
}
}
+async function completionDetectedForState(
+ ctx: PluginInput,
+ options: RalphLoopEventHandlerOptions,
+ sessionID: string,
+ state: RalphLoopState,
+ verificationSessionID: string | undefined,
+): Promise<"transcript_file" | "session_messages_api" | null> {
+ const completionSessionID = verificationSessionID ?? sessionID
+ const transcriptPath = completionSessionID ? options.getTranscriptPath(completionSessionID) : undefined
+ const completionViaTranscript = completionSessionID
+ ? detectCompletionInTranscript(
+ transcriptPath,
+ state.completion_promise,
+ state.started_at,
+ )
+ : false
+ if (completionViaTranscript) return "transcript_file"
+
+ const completionViaApi = verificationSessionID
+ ? await detectCompletionInSessionMessages(ctx, {
+ sessionID: verificationSessionID,
+ promise: state.completion_promise,
+ apiTimeoutMs: options.apiTimeoutMs,
+ directory: options.directory,
+ sinceMessageIndex: undefined,
+ })
+ : await detectCompletionInSessionMessages(ctx, {
+ sessionID,
+ promise: state.completion_promise,
+ apiTimeoutMs: options.apiTimeoutMs,
+ directory: options.directory,
+ sinceMessageIndex: state.message_count_at_start,
+ })
+
+ return completionViaApi ? "session_messages_api" : null
+}
+
+async function handleCompletionIfDetected(
+ ctx: PluginInput,
+ options: RalphLoopEventHandlerOptions,
+ input: {
+ sessionID: string
+ state: RalphLoopState
+ verificationSessionID: string | undefined
+ runtimeErrorRetriedSessions: Map
+ },
+): Promise {
+ const detectedVia = await completionDetectedForState(
+ ctx,
+ options,
+ input.sessionID,
+ input.state,
+ input.verificationSessionID,
+ )
+ if (!detectedVia) return false
+
+ input.runtimeErrorRetriedSessions.delete(input.sessionID)
+ log(`[${HOOK_NAME}] Completion detected!`, {
+ sessionID: input.sessionID,
+ iteration: input.state.iteration,
+ promise: input.state.completion_promise,
+ detectedVia,
+ })
+ await handleDetectedCompletion(ctx, {
+ sessionID: input.sessionID,
+ state: input.state,
+ loopState: options.loopState,
+ directory: options.directory,
+ apiTimeoutMs: options.apiTimeoutMs,
+ })
+ return true
+}
+
function showMaxIterationsToast(
ctx: PluginInput,
state: RalphLoopState,
@@ -136,14 +210,14 @@ export function createRalphLoopEventHandler(
try {
const state = options.loopState.getState()
- if (!state || !state.active) {
- return
- }
+ if (!state || !state.active) {
+ return
+ }
- if (hasRunningBackgroundTasks(options.backgroundManager, sessionID)) {
- log(`[${HOOK_NAME}] Skipped: background tasks running`, { sessionID })
- return
- }
+ if (hasRunningBackgroundTasks(options.backgroundManager, sessionID)) {
+ log(`[${HOOK_NAME}] Skipped: background tasks running`, { sessionID })
+ return
+ }
const verificationSessionID = state.verification_pending
? state.verification_session_id
@@ -173,58 +247,12 @@ export function createRalphLoopEventHandler(
return
}
- const completionSessionID = verificationSessionID ?? sessionID
- const transcriptPath = completionSessionID ? options.getTranscriptPath(completionSessionID) : undefined
- const completionViaTranscript = completionSessionID
- ? detectCompletionInTranscript(
- transcriptPath,
- state.completion_promise,
- state.started_at,
- )
- : false
- const completionViaApi = completionViaTranscript
- ? false
- : verificationSessionID
- ? await detectCompletionInSessionMessages(ctx, {
- sessionID: verificationSessionID,
- promise: state.completion_promise,
- apiTimeoutMs: options.apiTimeoutMs,
- directory: options.directory,
- sinceMessageIndex: undefined,
- })
- : state.verification_pending
- ? await detectCompletionInSessionMessages(ctx, {
- sessionID,
- promise: state.completion_promise,
- apiTimeoutMs: options.apiTimeoutMs,
- directory: options.directory,
- sinceMessageIndex: state.message_count_at_start,
- })
- : await detectCompletionInSessionMessages(ctx, {
- sessionID,
- promise: state.completion_promise,
- apiTimeoutMs: options.apiTimeoutMs,
- directory: options.directory,
- sinceMessageIndex: state.message_count_at_start,
- })
-
- if (completionViaTranscript || completionViaApi) {
- runtimeErrorRetriedSessions.delete(sessionID)
- log(`[${HOOK_NAME}] Completion detected!`, {
- sessionID,
- iteration: state.iteration,
- promise: state.completion_promise,
- detectedVia: completionViaTranscript
- ? "transcript_file"
- : "session_messages_api",
- })
- await handleDetectedCompletion(ctx, {
- sessionID,
- state,
- loopState: options.loopState,
- directory: options.directory,
- apiTimeoutMs: options.apiTimeoutMs,
- })
+ if (await handleCompletionIfDetected(ctx, options, {
+ sessionID,
+ state,
+ verificationSessionID,
+ runtimeErrorRetriedSessions,
+ })) {
return
}
@@ -289,6 +317,14 @@ export function createRalphLoopEventHandler(
log(`[${HOOK_NAME}] Skipped: state entered verification_pending during settle window`, { sessionID })
return
}
+ if (await handleCompletionIfDetected(ctx, options, {
+ sessionID,
+ state: stateAfterSettle,
+ verificationSessionID: undefined,
+ runtimeErrorRetriedSessions,
+ })) {
+ return
+ }
const nextIteration = stateAfterSettle.iteration + 1
const previewState: RalphLoopState = { ...stateAfterSettle, iteration: nextIteration }
@@ -307,6 +343,21 @@ export function createRalphLoopEventHandler(
})
if (result.status === "dispatched") {
+ const stateBeforeCommit = options.loopState.getState()
+ if (!stateBeforeCommit || !stateBeforeCommit.active) {
+ return
+ }
+ if (await handleCompletionIfDetected(ctx, options, {
+ sessionID,
+ state: stateBeforeCommit,
+ verificationSessionID: stateBeforeCommit.verification_pending
+ ? stateBeforeCommit.verification_session_id
+ : undefined,
+ runtimeErrorRetriedSessions,
+ })) {
+ return
+ }
+
const committed = options.loopState.incrementIteration()
if (committed) {
showIterationToast(ctx, committed)
@@ -361,23 +412,23 @@ export function createRalphLoopEventHandler(
const verificationSessionID = state.verification_pending
? state.verification_session_id
: undefined
- const matchesParentSession = state.session_id === undefined || state.session_id === sessionID
- const matchesVerificationSession = verificationSessionID === sessionID
- if (!matchesParentSession && !matchesVerificationSession) {
- handleErroredLoopSession(props, options.loopState)
- return
- }
+ const matchesParentSession = state.session_id === undefined || state.session_id === sessionID
+ const matchesVerificationSession = verificationSessionID === sessionID
+ if (!matchesParentSession && !matchesVerificationSession) {
+ handleErroredLoopSession(props, options.loopState)
+ return
+ }
- if (hasRunningBackgroundTasks(options.backgroundManager, sessionID)) {
- log(`[${HOOK_NAME}] Skipped runtime error retry: background tasks running`, { sessionID })
- return
- }
+ if (hasRunningBackgroundTasks(options.backgroundManager, sessionID)) {
+ log(`[${HOOK_NAME}] Skipped runtime error retry: background tasks running`, { sessionID })
+ return
+ }
- log(`[${HOOK_NAME}] Retrying after runtime session error`, {
- sessionID,
- iteration: state.iteration,
- error: String(error),
- })
+ log(`[${HOOK_NAME}] Retrying after runtime session error`, {
+ sessionID,
+ iteration: state.iteration,
+ error: String(error),
+ })
if (state.verification_pending) {
await handlePendingVerification(ctx, {
@@ -423,6 +474,14 @@ export function createRalphLoopEventHandler(
log(`[${HOOK_NAME}] Skipped: state entered verification_pending during settle window`, { sessionID })
return
}
+ if (await handleCompletionIfDetected(ctx, options, {
+ sessionID,
+ state: stateAfterSettle,
+ verificationSessionID: undefined,
+ runtimeErrorRetriedSessions,
+ })) {
+ return
+ }
const nextIteration = stateAfterSettle.iteration + 1
const previewState: RalphLoopState = { ...stateAfterSettle, iteration: nextIteration }
@@ -435,6 +494,21 @@ export function createRalphLoopEventHandler(
})
if (result.status === "dispatched") {
+ const stateBeforeCommit = options.loopState.getState()
+ if (!stateBeforeCommit || !stateBeforeCommit.active) {
+ return
+ }
+ if (await handleCompletionIfDetected(ctx, options, {
+ sessionID,
+ state: stateBeforeCommit,
+ verificationSessionID: stateBeforeCommit.verification_pending
+ ? stateBeforeCommit.verification_session_id
+ : undefined,
+ runtimeErrorRetriedSessions,
+ })) {
+ return
+ }
+
const committed = options.loopState.incrementIteration()
if (committed) {
showIterationToast(ctx, committed)