diff --git a/src/hooks/ralph-loop/completion-promise-detector.test.ts b/src/hooks/ralph-loop/completion-promise-detector.test.ts
index 814684068..d59151a7b 100644
--- a/src/hooks/ralph-loop/completion-promise-detector.test.ts
+++ b/src/hooks/ralph-loop/completion-promise-detector.test.ts
@@ -58,6 +58,29 @@ describe("detectCompletionInSessionMessages", () => {
// #then
expect(detected).toBe(true)
})
+
+ test("#when sinceMessageIndex equals current message count #then should NOT rescan old DONE", async () => {
+ // #given
+ const messages = [
+ {
+ info: { role: "assistant" },
+ parts: [{ type: "text", text: "Old completion DONE" }],
+ },
+ ]
+ const ctx = createPluginInput(messages)
+
+ // #when
+ const detected = await detectCompletionInSessionMessages(ctx, {
+ sessionID: "session-123",
+ promise: "DONE",
+ apiTimeoutMs: 1000,
+ directory: "/tmp",
+ sinceMessageIndex: messages.length,
+ })
+
+ // #then
+ expect(detected).toBe(false)
+ })
})
describe("#given no sinceMessageIndex (backward compat)", () => {
diff --git a/src/hooks/ralph-loop/completion-promise-detector.ts b/src/hooks/ralph-loop/completion-promise-detector.ts
index 65718e67e..6c915a2c3 100644
--- a/src/hooks/ralph-loop/completion-promise-detector.ts
+++ b/src/hooks/ralph-loop/completion-promise-detector.ts
@@ -130,8 +130,8 @@ export async function detectCompletionInSessionMessages(
: []
const scopedMessages =
- typeof options.sinceMessageIndex === "number" && options.sinceMessageIndex >= 0 && options.sinceMessageIndex < messageArray.length
- ? messageArray.slice(options.sinceMessageIndex)
+ typeof options.sinceMessageIndex === "number" && options.sinceMessageIndex >= 0
+ ? messageArray.slice(Math.min(options.sinceMessageIndex, messageArray.length))
: messageArray
const assistantMessages = (scopedMessages as OpenCodeSessionMessage[]).filter((msg) => msg.info?.role === "assistant")
diff --git a/src/hooks/ralph-loop/index.test.ts b/src/hooks/ralph-loop/index.test.ts
index 172363473..d131744b7 100644
--- a/src/hooks/ralph-loop/index.test.ts
+++ b/src/hooks/ralph-loop/index.test.ts
@@ -288,7 +288,7 @@ describe("ralph-loop", () => {
await hook.event({
event: {
type: "session.idle",
- properties: { sessionID: "session-123" },
+ properties: { sessionID: "session-123", synthetic: true },
},
})
@@ -304,6 +304,62 @@ describe("ralph-loop", () => {
expect(state?.iteration).toBe(2)
})
+ test("#given synthetic and real idle arrive back-to-back #then only one continuation is injected for the same iteration", async () => {
+ // given
+ const hook = createRalphLoopHook(createMockPluginInput(), { idleSettleMs: 0 })
+ hook.startLoop("session-123", "Build a feature", { maxIterations: 10 })
+
+ // when
+ await hook.event({
+ event: {
+ type: "session.idle",
+ properties: { sessionID: "session-123", synthetic: true },
+ },
+ })
+ await hook.event({
+ event: {
+ type: "session.idle",
+ properties: { sessionID: "session-123" },
+ },
+ })
+
+ // then
+ expect(promptCalls.length).toBe(1)
+ expect(promptCalls[0].sessionID).toBe("session-123")
+ expect(hook.getState()?.iteration).toBe(2)
+ })
+
+ test("#given new activity after an idle continuation #when session idles again #then next iteration can continue", async () => {
+ // given
+ const hook = createRalphLoopHook(createMockPluginInput(), { idleSettleMs: 0 })
+ hook.startLoop("session-123", "Build a feature", { maxIterations: 10 })
+
+ await hook.event({
+ event: {
+ type: "session.idle",
+ properties: { sessionID: "session-123" },
+ },
+ })
+
+ // when
+ await hook.event({
+ event: {
+ type: "message.part.updated",
+ properties: { sessionID: "session-123" },
+ },
+ })
+ await hook.event({
+ event: {
+ type: "session.idle",
+ properties: { sessionID: "session-123" },
+ },
+ })
+
+ // then
+ expect(promptCalls.length).toBe(2)
+ expect(hook.getState()?.iteration).toBe(3)
+ })
+
test("should inject continuation when idle event carries session id in info", async () => {
// given - active loop state and nested session event shape
const hook = createRalphLoopHook(createMockPluginInput())
diff --git a/src/hooks/ralph-loop/ralph-loop-event-handler.ts b/src/hooks/ralph-loop/ralph-loop-event-handler.ts
index c64cf2ef0..ddbef7fe6 100644
--- a/src/hooks/ralph-loop/ralph-loop-event-handler.ts
+++ b/src/hooks/ralph-loop/ralph-loop-event-handler.ts
@@ -12,6 +12,8 @@ import { continueIteration } from "./iteration-continuation"
import { handlePendingVerification } from "./pending-verification-handler"
import { handleDeletedLoopSession, handleErroredLoopSession } from "./session-event-handler"
+const RAPID_IDLE_DEDUP_MS = 500
+
type LoopStateController = {
getState: () => RalphLoopState | null
clear: () => boolean
@@ -62,6 +64,10 @@ function getRuntimeRetryActivitySessionID(
return undefined
}
+function isSyntheticIdle(props: Record | undefined): boolean {
+ return props?.synthetic === true
+}
+
function isAbortError(error: unknown): boolean {
return typeof error === "object"
&& error !== null
@@ -183,17 +189,20 @@ export function createRalphLoopEventHandler(
) {
const inFlightSessions = new Set()
const runtimeErrorRetriedSessions = new Map()
+ const recentHandledSyntheticIdleAt = new Map()
return async ({ event }: { event: { type: string; properties?: unknown } }): Promise => {
const props = event.properties as Record | undefined
const runtimeRetryActivitySessionID = getRuntimeRetryActivitySessionID(event.type, props)
if (runtimeRetryActivitySessionID) {
runtimeErrorRetriedSessions.delete(runtimeRetryActivitySessionID)
+ recentHandledSyntheticIdleAt.delete(runtimeRetryActivitySessionID)
}
if (event.type === "session.idle") {
const sessionID = resolveSessionEventID(props)
if (!sessionID) return
+ const syntheticIdle = isSyntheticIdle(props)
if (inFlightSessions.has(sessionID)) {
log(`[${HOOK_NAME}] Skipped: handler in flight`, { sessionID })
@@ -241,6 +250,17 @@ export function createRalphLoopEventHandler(
return
}
+ const lastHandledSyntheticIdleAt = recentHandledSyntheticIdleAt.get(sessionID)
+ const now = Date.now()
+ if (!syntheticIdle && lastHandledSyntheticIdleAt !== undefined && now - lastHandledSyntheticIdleAt < RAPID_IDLE_DEDUP_MS) {
+ recentHandledSyntheticIdleAt.delete(sessionID)
+ log(`[${HOOK_NAME}] Skipped: duplicate real idle after synthetic idle`, { sessionID })
+ return
+ }
+ if (syntheticIdle) {
+ recentHandledSyntheticIdleAt.set(sessionID, now)
+ }
+
if (await handleCompletionIfDetected(ctx, options, {
sessionID,
state,
diff --git a/src/plugin/session-status-normalizer.test.ts b/src/plugin/session-status-normalizer.test.ts
index cfb99ec6d..043a85ce1 100644
--- a/src/plugin/session-status-normalizer.test.ts
+++ b/src/plugin/session-status-normalizer.test.ts
@@ -25,6 +25,7 @@ describe("normalizeSessionStatusToIdle", () => {
type: "session.idle",
properties: {
sessionID: "ses_abc123",
+ synthetic: true,
},
},
})
diff --git a/src/plugin/session-status-normalizer.ts b/src/plugin/session-status-normalizer.ts
index 6089bf5c8..79c3bdb85 100644
--- a/src/plugin/session-status-normalizer.ts
+++ b/src/plugin/session-status-normalizer.ts
@@ -18,7 +18,7 @@ export function normalizeSessionStatusToIdle(input: EventInput): EventInput | nu
return {
event: {
type: "session.idle",
- properties: { sessionID },
+ properties: { sessionID, synthetic: true },
},
}
}