From 3d4ed912d7ef80ec58b31c700668e0c916a9b427 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 8 Feb 2026 02:36:27 +0900 Subject: [PATCH 1/2] fix(look-at): use synchronous prompt to fix race condition (#1620 regression) PR #1620 migrated all prompt calls from session.prompt (blocking) to session.promptAsync (fire-and-forget HTTP 204). This broke look_at which needs the multimodal-looker response to be available immediately after the prompt call returns. Fix: add promptSyncWithModelSuggestionRetry() that uses session.prompt (blocking) with model suggestion retry support. look_at now uses this sync variant while all other callers keep using promptAsync. - Add promptSyncWithModelSuggestionRetry to model-suggestion-retry.ts - Switch look_at from promptWithModelSuggestionRetry to sync variant - Add comprehensive tests for the new sync function - No changes to other callers (delegate-task, background-agent) --- src/shared/model-suggestion-retry.test.ts | 127 +++++++++++++++++++++- src/shared/model-suggestion-retry.ts | 39 +++++++ src/tools/look-at/tools.ts | 4 +- 3 files changed, 167 insertions(+), 3 deletions(-) diff --git a/src/shared/model-suggestion-retry.test.ts b/src/shared/model-suggestion-retry.test.ts index 10c7cde8..6ce0699e 100644 --- a/src/shared/model-suggestion-retry.test.ts +++ b/src/shared/model-suggestion-retry.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, mock } from "bun:test" -import { parseModelSuggestion, promptWithModelSuggestionRetry } from "./model-suggestion-retry" +import { parseModelSuggestion, promptWithModelSuggestionRetry, promptSyncWithModelSuggestionRetry } from "./model-suggestion-retry" describe("parseModelSuggestion", () => { describe("structured NamedError format", () => { @@ -377,3 +377,128 @@ describe("promptWithModelSuggestionRetry", () => { expect(promptMock).toHaveBeenCalledTimes(1) }) }) + +describe("promptSyncWithModelSuggestionRetry", () => { + it("should use synchronous prompt (not promptAsync)", async () => { + // given a client with both prompt and promptAsync + const promptMock = mock(() => Promise.resolve()) + const promptAsyncMock = mock(() => Promise.resolve()) + const client = { session: { prompt: promptMock, promptAsync: promptAsyncMock } } + + // when calling promptSyncWithModelSuggestionRetry + await promptSyncWithModelSuggestionRetry(client as any, { + path: { id: "session-1" }, + body: { + parts: [{ type: "text", text: "hello" }], + model: { providerID: "anthropic", modelID: "claude-sonnet-4" }, + }, + }) + + // then should call prompt (sync), NOT promptAsync + expect(promptMock).toHaveBeenCalledTimes(1) + expect(promptAsyncMock).toHaveBeenCalledTimes(0) + }) + + it("should retry with suggested model on ProviderModelNotFoundError", async () => { + // given a client that fails first with model-not-found, then succeeds + const promptMock = mock() + .mockRejectedValueOnce({ + name: "ProviderModelNotFoundError", + data: { + providerID: "anthropic", + modelID: "claude-sonet-4", + suggestions: ["claude-sonnet-4"], + }, + }) + .mockResolvedValueOnce(undefined) + const client = { session: { prompt: promptMock } } + + // when calling promptSyncWithModelSuggestionRetry + await promptSyncWithModelSuggestionRetry(client as any, { + path: { id: "session-1" }, + body: { + parts: [{ type: "text", text: "hello" }], + model: { providerID: "anthropic", modelID: "claude-sonet-4" }, + }, + }) + + // then should call prompt twice (original + retry with suggestion) + expect(promptMock).toHaveBeenCalledTimes(2) + const retryCall = promptMock.mock.calls[1][0] + expect(retryCall.body.model).toEqual({ + providerID: "anthropic", + modelID: "claude-sonnet-4", + }) + }) + + it("should throw original error when no suggestion available", async () => { + // given a client that fails with a non-model error + const originalError = new Error("Connection refused") + const promptMock = mock().mockRejectedValueOnce(originalError) + const client = { session: { prompt: promptMock } } + + // when calling promptSyncWithModelSuggestionRetry + // then should throw the original error + await expect( + promptSyncWithModelSuggestionRetry(client as any, { + path: { id: "session-1" }, + body: { + parts: [{ type: "text", text: "hello" }], + model: { providerID: "anthropic", modelID: "claude-sonnet-4" }, + }, + }) + ).rejects.toThrow("Connection refused") + + expect(promptMock).toHaveBeenCalledTimes(1) + }) + + it("should throw when model-not-found but no model in original request", async () => { + // given a client that fails with model error but no model in body + const promptMock = mock().mockRejectedValueOnce({ + name: "ProviderModelNotFoundError", + data: { + providerID: "anthropic", + modelID: "claude-sonet-4", + suggestions: ["claude-sonnet-4"], + }, + }) + const client = { session: { prompt: promptMock } } + + // when calling without model in body + // then should throw (cannot retry without original model) + await expect( + promptSyncWithModelSuggestionRetry(client as any, { + path: { id: "session-1" }, + body: { + parts: [{ type: "text", text: "hello" }], + }, + }) + ).rejects.toThrow() + + expect(promptMock).toHaveBeenCalledTimes(1) + }) + + it("should pass all body fields through to prompt", async () => { + // given a client where prompt succeeds + const promptMock = mock().mockResolvedValueOnce(undefined) + const client = { session: { prompt: promptMock } } + + // when calling with additional body fields + await promptSyncWithModelSuggestionRetry(client as any, { + path: { id: "session-1" }, + body: { + agent: "multimodal-looker", + tools: { task: false }, + parts: [{ type: "text", text: "analyze" }], + model: { providerID: "google", modelID: "gemini-3-flash" }, + variant: "max", + }, + }) + + // then call should pass all fields through unchanged + const call = promptMock.mock.calls[0][0] + expect(call.body.agent).toBe("multimodal-looker") + expect(call.body.tools).toEqual({ task: false }) + expect(call.body.variant).toBe("max") + }) +}) diff --git a/src/shared/model-suggestion-retry.ts b/src/shared/model-suggestion-retry.ts index 2564059f..d8eb71b6 100644 --- a/src/shared/model-suggestion-retry.ts +++ b/src/shared/model-suggestion-retry.ts @@ -88,3 +88,42 @@ export async function promptWithModelSuggestionRetry( // model errors happen asynchronously server-side and cannot be caught here await client.session.promptAsync(args as Parameters[0]) } + +/** + * Synchronous variant of promptWithModelSuggestionRetry. + * + * Uses `session.prompt` (blocking HTTP call that waits for the LLM response) + * instead of `promptAsync` (fire-and-forget HTTP 204). + * + * Required by callers that need the response to be available immediately after + * the call returns — e.g. look_at, which reads session messages right away. + */ +export async function promptSyncWithModelSuggestionRetry( + client: Client, + args: PromptArgs, +): Promise { + try { + await client.session.prompt(args as Parameters[0]) + } catch (error) { + const suggestion = parseModelSuggestion(error) + if (!suggestion || !args.body.model) { + throw error + } + + log("[model-suggestion-retry] Model not found, retrying with suggestion", { + original: `${suggestion.providerID}/${suggestion.modelID}`, + suggested: suggestion.suggestion, + }) + + await client.session.prompt({ + ...args, + body: { + ...args.body, + model: { + providerID: suggestion.providerID, + modelID: suggestion.suggestion, + }, + }, + } as Parameters[0]) + } +} diff --git a/src/tools/look-at/tools.ts b/src/tools/look-at/tools.ts index 2cf7ef69..28e6edf8 100644 --- a/src/tools/look-at/tools.ts +++ b/src/tools/look-at/tools.ts @@ -3,7 +3,7 @@ import { pathToFileURL } from "node:url" import { tool, type PluginInput, type ToolDefinition } from "@opencode-ai/plugin" import { LOOK_AT_DESCRIPTION, MULTIMODAL_LOOKER_AGENT } from "./constants" import type { LookAtArgs } from "./types" -import { log, promptWithModelSuggestionRetry } from "../../shared" +import { log, promptSyncWithModelSuggestionRetry } from "../../shared" interface LookAtArgsWithAlias extends LookAtArgs { path?: string @@ -223,7 +223,7 @@ Original error: ${createResult.error}` log(`[look_at] Sending prompt with ${isBase64Input ? "base64 image" : "file"} to session ${sessionID}`) try { - await promptWithModelSuggestionRetry(ctx.client, { + await promptSyncWithModelSuggestionRetry(ctx.client, { path: { id: sessionID }, body: { agent: MULTIMODAL_LOOKER_AGENT, From b2661be83342d6a92d5b53eb83f2022c1870ebef Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 8 Feb 2026 02:41:29 +0900 Subject: [PATCH 2/2] test: fix ralph-loop tests by adding promptAsync to mock The ralph-loop hook calls promptAsync in the implementation, but the test mock only defined prompt(). Added promptAsync with identical behavior to make tests pass. - All 38 ralph-loop tests now pass - Total test suite: 2361 pass, 3 fail (unrelated to this change) --- bun.lock | 28 ++++++++++++++-------------- src/hooks/ralph-loop/index.test.ts | 7 +++++++ 2 files changed, 21 insertions(+), 14 deletions(-) diff --git a/bun.lock b/bun.lock index c65491b0..7c5f969e 100644 --- a/bun.lock +++ b/bun.lock @@ -28,13 +28,13 @@ "typescript": "^5.7.3", }, "optionalDependencies": { - "oh-my-opencode-darwin-arm64": "3.2.4", - "oh-my-opencode-darwin-x64": "3.2.4", - "oh-my-opencode-linux-arm64": "3.2.4", - "oh-my-opencode-linux-arm64-musl": "3.2.4", - "oh-my-opencode-linux-x64": "3.2.4", - "oh-my-opencode-linux-x64-musl": "3.2.4", - "oh-my-opencode-windows-x64": "3.2.4", + "oh-my-opencode-darwin-arm64": "3.3.0", + "oh-my-opencode-darwin-x64": "3.3.0", + "oh-my-opencode-linux-arm64": "3.3.0", + "oh-my-opencode-linux-arm64-musl": "3.3.0", + "oh-my-opencode-linux-x64": "3.3.0", + "oh-my-opencode-linux-x64-musl": "3.3.0", + "oh-my-opencode-windows-x64": "3.3.0", }, }, }, @@ -226,19 +226,19 @@ "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], - "oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.2.4", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-6vG49R/nkbZYhAqN2oStA+8reZRo2KPPHSbhQd4htdEpzS4ipVz6pW/YTj/TDwunQO7hy66AhP9hOR4pJcoDeA=="], + "oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.3.0", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-P2kZKJqZaA4j0qtGM3I8+ZeH204ai27ni/OXLjtFdOewRjJgrahxaC1XslgK7q/KU9fXz6BQfEqAjbvyPf/rgQ=="], - "oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.2.4", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-Utfpclg8xHj93+faX2L4dpkzhM6D58YEtjkVlHq4CxZ8MdpYCs2l4NtY/b9T1GWmtQWFxZQhmIdAcwe1qApgpQ=="], + "oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.3.0", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-RopOorbW1WyhMQJ+ipuqiOA1GICS+3IkOwNyEe0KZlCLpoEDTyFopIL87HSns+gEQPMxnknroDp8lzxn1AKgjw=="], - "oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.2.4", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-z4Zlvt1a1PSQVprbgx6bLOeNuILX4d9p80GrTWuuYzqY+OEgbb74LVVUFCsvt8UgnhRTnHuhmphSpIL7UznzZg=="], + "oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.3.0", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-297iEfuK+05g+q64crPW78Zbgm/j5PGjDDweSPkZ6rI6SEfHMvOIkGxMvN8gugM3zcH8FOCQXoY2nC8b6x3pwQ=="], - "oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.2.4", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-pCCPM8rsuwMR3a7XIDyYyr/D1HkMPffOYGXeOY8vBaLL8NKFl8d0H5twA3HIiEqcDINHV3kw9zteL2paW+mHSQ=="], + "oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.3.0", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-oVxP0+yn66HQYfrl9QT6I7TumRzciuPB4z24+PwKEVcDjPbWXQqLY1gwOGHZAQBPLf0vwewv9ybEDVD42RRH4g=="], - "oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.2.4", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-vU9l4rS1oRpCgyXalBiUOOFPddIwSmuWoGY1PgO4dr6Db+gtEpmaDpLcEi5j4jFUDRLH6btQvNAp/eAydVgOJQ=="], + "oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.3.0", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-k9LoLkisLJwJNR1J0Bh1bjGtGBkl5D9WzFPSdZCAlyiT6TgG9w5erPTlXqtl2Lt0We5tYUVYlkEIHRMK/ugNsQ=="], - "oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.2.4", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-OZ+yRl7tOXoWTHh7zQ8WsTasKqZaIaVO3QeUQhDIS5JXFjbgjMgFeC/XBegsCgfqglWTOlMatmCO1S3nx2vy2w=="], + "oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.3.0", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-7asXCeae7wBxJrzoZ7J6Yo1oaOxwUN3bTO7jWurCTMs5TDHO+pEHysgv/nuF1jvj1T+r1vg1H5ZmopuKy1qvXg=="], - "oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.2.4", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-W6TX8OiPCOmu7UZgZESh5DSWat0zH/6WPC3tdvjzwYnik9ZvRiyJGHh9B4uAG3DdqTC+pZJrpuTq1NctqMJiDA=="], + "oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.3.0", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-ABvwfaXb2xdrpbivzlPPJzIm5vXp+QlVakkaHEQf3TU6Mi/+fehH6Qhq/KMh66FDO2gq3xmxbH7nktHRQp9kNA=="], "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], diff --git a/src/hooks/ralph-loop/index.test.ts b/src/hooks/ralph-loop/index.test.ts index de8acabb..9c7ce4f1 100644 --- a/src/hooks/ralph-loop/index.test.ts +++ b/src/hooks/ralph-loop/index.test.ts @@ -24,6 +24,13 @@ describe("ralph-loop", () => { }) return {} }, + promptAsync: async (opts: { path: { id: string }; body: { parts: Array<{ type: string; text: string }> } }) => { + promptCalls.push({ + sessionID: opts.path.id, + text: opts.body.parts[0].text, + }) + return {} + }, messages: async (opts: { path: { id: string } }) => { messagesCalls.push({ sessionID: opts.path.id }) return { data: mockSessionMessages }