fix(ralph-loop): suppress stale iteration toasts
This commit is contained in:
@@ -713,6 +713,53 @@ describe("ralph-loop", () => {
|
|||||||
expect(messagesCalls[0].sessionID).toBe("session-123")
|
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 <promise>DONE</promise>",
|
||||||
|
}) + "\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 () => {
|
test("should ignore completion promise in reasoning part via session messages API", async () => {
|
||||||
//#given - active loop with assistant reasoning containing completion promise
|
//#given - active loop with assistant reasoning containing completion promise
|
||||||
mockSessionMessages = [
|
mockSessionMessages = [
|
||||||
|
|||||||
@@ -82,9 +82,83 @@ function showToastBestEffort(
|
|||||||
try {
|
try {
|
||||||
void Promise.resolve(ctx.client.tui?.showToast?.({ body })).catch(() => {})
|
void Promise.resolve(ctx.client.tui?.showToast?.({ body })).catch(() => {})
|
||||||
} 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<string, number>
|
||||||
|
},
|
||||||
|
): Promise<boolean> {
|
||||||
|
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(
|
function showMaxIterationsToast(
|
||||||
ctx: PluginInput,
|
ctx: PluginInput,
|
||||||
state: RalphLoopState,
|
state: RalphLoopState,
|
||||||
@@ -136,14 +210,14 @@ export function createRalphLoopEventHandler(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const state = options.loopState.getState()
|
const state = options.loopState.getState()
|
||||||
if (!state || !state.active) {
|
if (!state || !state.active) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasRunningBackgroundTasks(options.backgroundManager, sessionID)) {
|
if (hasRunningBackgroundTasks(options.backgroundManager, sessionID)) {
|
||||||
log(`[${HOOK_NAME}] Skipped: background tasks running`, { sessionID })
|
log(`[${HOOK_NAME}] Skipped: background tasks running`, { sessionID })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const verificationSessionID = state.verification_pending
|
const verificationSessionID = state.verification_pending
|
||||||
? state.verification_session_id
|
? state.verification_session_id
|
||||||
@@ -173,58 +247,12 @@ export function createRalphLoopEventHandler(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const completionSessionID = verificationSessionID ?? sessionID
|
if (await handleCompletionIfDetected(ctx, options, {
|
||||||
const transcriptPath = completionSessionID ? options.getTranscriptPath(completionSessionID) : undefined
|
sessionID,
|
||||||
const completionViaTranscript = completionSessionID
|
state,
|
||||||
? detectCompletionInTranscript(
|
verificationSessionID,
|
||||||
transcriptPath,
|
runtimeErrorRetriedSessions,
|
||||||
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,
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,6 +317,14 @@ export function createRalphLoopEventHandler(
|
|||||||
log(`[${HOOK_NAME}] Skipped: state entered verification_pending during settle window`, { sessionID })
|
log(`[${HOOK_NAME}] Skipped: state entered verification_pending during settle window`, { sessionID })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (await handleCompletionIfDetected(ctx, options, {
|
||||||
|
sessionID,
|
||||||
|
state: stateAfterSettle,
|
||||||
|
verificationSessionID: undefined,
|
||||||
|
runtimeErrorRetriedSessions,
|
||||||
|
})) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const nextIteration = stateAfterSettle.iteration + 1
|
const nextIteration = stateAfterSettle.iteration + 1
|
||||||
const previewState: RalphLoopState = { ...stateAfterSettle, iteration: nextIteration }
|
const previewState: RalphLoopState = { ...stateAfterSettle, iteration: nextIteration }
|
||||||
@@ -307,6 +343,21 @@ export function createRalphLoopEventHandler(
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (result.status === "dispatched") {
|
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()
|
const committed = options.loopState.incrementIteration()
|
||||||
if (committed) {
|
if (committed) {
|
||||||
showIterationToast(ctx, committed)
|
showIterationToast(ctx, committed)
|
||||||
@@ -361,23 +412,23 @@ export function createRalphLoopEventHandler(
|
|||||||
const verificationSessionID = state.verification_pending
|
const verificationSessionID = state.verification_pending
|
||||||
? state.verification_session_id
|
? state.verification_session_id
|
||||||
: undefined
|
: undefined
|
||||||
const matchesParentSession = state.session_id === undefined || state.session_id === sessionID
|
const matchesParentSession = state.session_id === undefined || state.session_id === sessionID
|
||||||
const matchesVerificationSession = verificationSessionID === sessionID
|
const matchesVerificationSession = verificationSessionID === sessionID
|
||||||
if (!matchesParentSession && !matchesVerificationSession) {
|
if (!matchesParentSession && !matchesVerificationSession) {
|
||||||
handleErroredLoopSession(props, options.loopState)
|
handleErroredLoopSession(props, options.loopState)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasRunningBackgroundTasks(options.backgroundManager, sessionID)) {
|
if (hasRunningBackgroundTasks(options.backgroundManager, sessionID)) {
|
||||||
log(`[${HOOK_NAME}] Skipped runtime error retry: background tasks running`, { sessionID })
|
log(`[${HOOK_NAME}] Skipped runtime error retry: background tasks running`, { sessionID })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
log(`[${HOOK_NAME}] Retrying after runtime session error`, {
|
log(`[${HOOK_NAME}] Retrying after runtime session error`, {
|
||||||
sessionID,
|
sessionID,
|
||||||
iteration: state.iteration,
|
iteration: state.iteration,
|
||||||
error: String(error),
|
error: String(error),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (state.verification_pending) {
|
if (state.verification_pending) {
|
||||||
await handlePendingVerification(ctx, {
|
await handlePendingVerification(ctx, {
|
||||||
@@ -423,6 +474,14 @@ export function createRalphLoopEventHandler(
|
|||||||
log(`[${HOOK_NAME}] Skipped: state entered verification_pending during settle window`, { sessionID })
|
log(`[${HOOK_NAME}] Skipped: state entered verification_pending during settle window`, { sessionID })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (await handleCompletionIfDetected(ctx, options, {
|
||||||
|
sessionID,
|
||||||
|
state: stateAfterSettle,
|
||||||
|
verificationSessionID: undefined,
|
||||||
|
runtimeErrorRetriedSessions,
|
||||||
|
})) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const nextIteration = stateAfterSettle.iteration + 1
|
const nextIteration = stateAfterSettle.iteration + 1
|
||||||
const previewState: RalphLoopState = { ...stateAfterSettle, iteration: nextIteration }
|
const previewState: RalphLoopState = { ...stateAfterSettle, iteration: nextIteration }
|
||||||
@@ -435,6 +494,21 @@ export function createRalphLoopEventHandler(
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (result.status === "dispatched") {
|
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()
|
const committed = options.loopState.incrementIteration()
|
||||||
if (committed) {
|
if (committed) {
|
||||||
showIterationToast(ctx, committed)
|
showIterationToast(ctx, committed)
|
||||||
|
|||||||
Reference in New Issue
Block a user