fix(ralph-loop): ignore synthetic idle replays

This commit is contained in:
YeonGyu-Kim
2026-05-12 15:28:46 +09:00
parent 07797e1975
commit 9ffef79dbb
6 changed files with 104 additions and 4 deletions
@@ -58,6 +58,29 @@ describe("detectCompletionInSessionMessages", () => {
// #then // #then
expect(detected).toBe(true) 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 <promise>DONE</promise>" }],
},
]
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)", () => { describe("#given no sinceMessageIndex (backward compat)", () => {
@@ -130,8 +130,8 @@ export async function detectCompletionInSessionMessages(
: [] : []
const scopedMessages = const scopedMessages =
typeof options.sinceMessageIndex === "number" && options.sinceMessageIndex >= 0 && options.sinceMessageIndex < messageArray.length typeof options.sinceMessageIndex === "number" && options.sinceMessageIndex >= 0
? messageArray.slice(options.sinceMessageIndex) ? messageArray.slice(Math.min(options.sinceMessageIndex, messageArray.length))
: messageArray : messageArray
const assistantMessages = (scopedMessages as OpenCodeSessionMessage[]).filter((msg) => msg.info?.role === "assistant") const assistantMessages = (scopedMessages as OpenCodeSessionMessage[]).filter((msg) => msg.info?.role === "assistant")
+57 -1
View File
@@ -288,7 +288,7 @@ describe("ralph-loop", () => {
await hook.event({ await hook.event({
event: { event: {
type: "session.idle", 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) 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 () => { test("should inject continuation when idle event carries session id in info", async () => {
// given - active loop state and nested session event shape // given - active loop state and nested session event shape
const hook = createRalphLoopHook(createMockPluginInput()) const hook = createRalphLoopHook(createMockPluginInput())
@@ -12,6 +12,8 @@ import { continueIteration } from "./iteration-continuation"
import { handlePendingVerification } from "./pending-verification-handler" import { handlePendingVerification } from "./pending-verification-handler"
import { handleDeletedLoopSession, handleErroredLoopSession } from "./session-event-handler" import { handleDeletedLoopSession, handleErroredLoopSession } from "./session-event-handler"
const RAPID_IDLE_DEDUP_MS = 500
type LoopStateController = { type LoopStateController = {
getState: () => RalphLoopState | null getState: () => RalphLoopState | null
clear: () => boolean clear: () => boolean
@@ -62,6 +64,10 @@ function getRuntimeRetryActivitySessionID(
return undefined return undefined
} }
function isSyntheticIdle(props: Record<string, unknown> | undefined): boolean {
return props?.synthetic === true
}
function isAbortError(error: unknown): boolean { function isAbortError(error: unknown): boolean {
return typeof error === "object" return typeof error === "object"
&& error !== null && error !== null
@@ -183,17 +189,20 @@ export function createRalphLoopEventHandler(
) { ) {
const inFlightSessions = new Set<string>() const inFlightSessions = new Set<string>()
const runtimeErrorRetriedSessions = new Map<string, number>() const runtimeErrorRetriedSessions = new Map<string, number>()
const recentHandledSyntheticIdleAt = new Map<string, number>()
return async ({ event }: { event: { type: string; properties?: unknown } }): Promise<void> => { return async ({ event }: { event: { type: string; properties?: unknown } }): Promise<void> => {
const props = event.properties as Record<string, unknown> | undefined const props = event.properties as Record<string, unknown> | undefined
const runtimeRetryActivitySessionID = getRuntimeRetryActivitySessionID(event.type, props) const runtimeRetryActivitySessionID = getRuntimeRetryActivitySessionID(event.type, props)
if (runtimeRetryActivitySessionID) { if (runtimeRetryActivitySessionID) {
runtimeErrorRetriedSessions.delete(runtimeRetryActivitySessionID) runtimeErrorRetriedSessions.delete(runtimeRetryActivitySessionID)
recentHandledSyntheticIdleAt.delete(runtimeRetryActivitySessionID)
} }
if (event.type === "session.idle") { if (event.type === "session.idle") {
const sessionID = resolveSessionEventID(props) const sessionID = resolveSessionEventID(props)
if (!sessionID) return if (!sessionID) return
const syntheticIdle = isSyntheticIdle(props)
if (inFlightSessions.has(sessionID)) { if (inFlightSessions.has(sessionID)) {
log(`[${HOOK_NAME}] Skipped: handler in flight`, { sessionID }) log(`[${HOOK_NAME}] Skipped: handler in flight`, { sessionID })
@@ -241,6 +250,17 @@ export function createRalphLoopEventHandler(
return 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, { if (await handleCompletionIfDetected(ctx, options, {
sessionID, sessionID,
state, state,
@@ -25,6 +25,7 @@ describe("normalizeSessionStatusToIdle", () => {
type: "session.idle", type: "session.idle",
properties: { properties: {
sessionID: "ses_abc123", sessionID: "ses_abc123",
synthetic: true,
}, },
}, },
}) })
+1 -1
View File
@@ -18,7 +18,7 @@ export function normalizeSessionStatusToIdle(input: EventInput): EventInput | nu
return { return {
event: { event: {
type: "session.idle", type: "session.idle",
properties: { sessionID }, properties: { sessionID, synthetic: true },
}, },
} }
} }