From 0de6bd7253932dd5d8dc87c7110cefe678f37995 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 12 May 2026 12:06:49 +0900 Subject: [PATCH] fix(ralph-loop): reject promptAsync error responses --- .../continuation-prompt-injector.ts | 38 +++++- .../dispatch-failure-invariant.test.ts | 119 ++++++++++++++++++ src/shared/index.ts | 1 + 3 files changed, 157 insertions(+), 1 deletion(-) diff --git a/src/hooks/ralph-loop/continuation-prompt-injector.ts b/src/hooks/ralph-loop/continuation-prompt-injector.ts index fc1405c74..85c74693a 100644 --- a/src/hooks/ralph-loop/continuation-prompt-injector.ts +++ b/src/hooks/ralph-loop/continuation-prompt-injector.ts @@ -5,6 +5,7 @@ import { getMessageDir } from "./message-storage-directory" import { withTimeout } from "./with-timeout" import { createInternalAgentTextPart, + isRecord, normalizeSDKResponse, resolveInheritedPromptTools, } from "../../shared" @@ -18,6 +19,37 @@ type MessageInfo = { tools?: Record } +function extractPromptAsyncError(response: unknown): unknown | undefined { + if (!isRecord(response) || !Object.hasOwn(response, "error")) { + return undefined + } + + return response.error ?? "Unknown promptAsync error" +} + +function describePromptAsyncError(error: unknown): string { + if (error instanceof Error) { + return error.message + } + + if (typeof error === "string") { + return error + } + + if (isRecord(error)) { + const message = error.message + if (typeof message === "string") { + return message + } + } + + try { + return JSON.stringify(error) + } catch { + return String(error) + } +} + export async function injectContinuationPrompt( ctx: PluginInput, options: { @@ -77,7 +109,7 @@ export async function injectContinuationPrompt( : undefined const launchVariant = model?.variant - await ctx.client.session.promptAsync({ + const response = await ctx.client.session.promptAsync({ path: { id: options.sessionID }, body: { ...(cleanAgent !== undefined ? { agent: cleanAgent } : {}), @@ -88,6 +120,10 @@ export async function injectContinuationPrompt( }, query: { directory: options.directory }, }) + const promptError = extractPromptAsyncError(response) + if (promptError !== undefined) { + throw new Error(`promptAsync returned error: ${describePromptAsyncError(promptError)}`) + } log("[ralph-loop] continuation injected", { sessionID: options.sessionID }) } diff --git a/src/hooks/ralph-loop/dispatch-failure-invariant.test.ts b/src/hooks/ralph-loop/dispatch-failure-invariant.test.ts index 096a01aad..a932fe4c5 100644 --- a/src/hooks/ralph-loop/dispatch-failure-invariant.test.ts +++ b/src/hooks/ralph-loop/dispatch-failure-invariant.test.ts @@ -77,6 +77,53 @@ describe("ralph-loop dispatch failure invariants", () => { expect(toastCalls.some((toast) => toast.title === "Ralph Loop Failed" && toast.message.includes("dispatch_rejected"))).toBe(true) }) + test("#given idle path #when promptAsync resolves SDK error #then no state or toast advance", async () => { + // given + const hook = createRalphLoopHook({ + directory: testDirectory, + project: testDirectory, + worktree: testDirectory, + serverUrl: "http://localhost:4096", + $: async () => ({}), + client: { + session: { + messages: async (options: { path: { id: string } }) => { + messagesCalls.push({ sessionID: options.path.id }) + return { data: [] } + }, + promptAsync: async () => ({ + error: { message: "prompt rejected by OpenCode" }, + response: { status: 400 }, + }), + prompt: async () => ({}), + create: async () => ({ data: { id: "new-session-id" } }), + }, + tui: { + showToast: async (options: { body: { title: string; message: string; variant: string } }) => { + toastCalls.push(options.body) + return {} + }, + }, + }, + } as never) + + hook.startLoop("session-123", "Keep working", { + messageCountAtStart: 0, + maxIterations: 5, + }) + expect(hook.getState()?.iteration).toBe(1) + + // when + await hook.event({ + event: { type: "session.idle", properties: { sessionID: "session-123" } }, + }) + + // then + expect(toastCalls.some((toast) => toast.title === "Ralph Loop" && toast.message.includes("Iteration"))).toBe(false) + expect(hook.getState()).toBeNull() + expect(toastCalls.some((toast) => toast.title === "Ralph Loop Failed" && toast.message.includes("prompt rejected by OpenCode"))).toBe(true) + }) + test("#given error retry path #when promptAsync throws #then no state or toast advance", async () => { // given const hook = createRalphLoopHook({ @@ -198,6 +245,78 @@ describe("ralph-loop dispatch failure invariants", () => { expect(toastCalls.some((toast) => toast.title === "Ralph Loop Failed" && toast.message.includes("Verification continuation rejected"))).toBe(true) }) + test("#given verification-failure path #when promptAsync resolves SDK error #then continuation toast is not shown", async () => { + // given + const parentTranscriptPath = join(testDirectory, "transcript-parent.jsonl") + const oracleTranscriptPath = join(testDirectory, "transcript-oracle.jsonl") + const hook = createRalphLoopHook({ + directory: testDirectory, + project: testDirectory, + worktree: testDirectory, + serverUrl: "http://localhost:4096", + $: async () => ({}), + client: { + session: { + messages: async (options: { path: { id: string } }) => { + messagesCalls.push({ sessionID: options.path.id }) + if (options.path.id === "session-123") { + return { data: [{}, {}, {}] } + } + return { data: [] } + }, + promptAsync: async (options: { body: { parts: Array<{ type: string; text: string }> } }) => { + if (options.body.parts[0]?.text.includes("Verification failed")) { + return { + error: { message: "verification continuation rejected by OpenCode" }, + response: { status: 400 }, + } + } + return {} + }, + prompt: async () => ({}), + abort: async () => ({}), + create: async () => ({ data: { id: "new-session-id" } }), + }, + tui: { + showToast: async (options: { body: { title: string; message: string; variant: string } }) => { + toastCalls.push(options.body) + return {} + }, + }, + }, + } as never, { + getTranscriptPath: (sessionID): string => sessionID === "ses-oracle" ? oracleTranscriptPath : parentTranscriptPath, + }) + + hook.startLoop("session-123", "Build API", { ultrawork: true }) + writeState(testDirectory, { + ...hook.getState()!, + iteration: 2, + verification_pending: true, + verification_session_id: "ses-oracle", + completion_promise: ULTRAWORK_VERIFICATION_PROMISE, + initial_completion_promise: "DONE", + }) + writeFileSync( + oracleTranscriptPath, + `${JSON.stringify({ type: "tool_result", timestamp: new Date().toISOString(), tool_output: { output: "verification failed" } })}\n`, + ) + + // when + await hook.event({ event: { type: "session.idle", properties: { sessionID: "ses-oracle" } } }) + + // then + expect(hook.getState()).toBeNull() + expect(toastCalls.some((toast) => toast.title === "ULTRAWORK LOOP")).toBe(false) + expect( + toastCalls.some( + (toast) => + toast.title === "Ralph Loop Failed" + && toast.message.includes("verification continuation rejected by OpenCode"), + ), + ).toBe(true) + }) + test("#given reset strategy #when createIterationSession returns null #then dispatch failure surfaces", async () => { // given const hook = createRalphLoopHook({ diff --git a/src/shared/index.ts b/src/shared/index.ts index e99234c33..3e981f974 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -68,6 +68,7 @@ export * from "./opencode-message-dir" export * from "./opencode-command-dirs" export * from "./project-discovery-dirs" export * from "./normalize-sdk-response" +export * from "./record-type-guard" export * from "./session-directory-resolver" export * from "./prompt-tools" export * from "./compaction-marker"