Merge pull request #1632 from code-yeongyu/fix/look-at-sync-prompt
fix(look-at): use synchronous prompt to fix race condition (#1620 regression)
This commit is contained in:
commit
56f9de4652
28
bun.lock
28
bun.lock
@ -28,13 +28,13 @@
|
|||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"oh-my-opencode-darwin-arm64": "3.2.4",
|
"oh-my-opencode-darwin-arm64": "3.3.0",
|
||||||
"oh-my-opencode-darwin-x64": "3.2.4",
|
"oh-my-opencode-darwin-x64": "3.3.0",
|
||||||
"oh-my-opencode-linux-arm64": "3.2.4",
|
"oh-my-opencode-linux-arm64": "3.3.0",
|
||||||
"oh-my-opencode-linux-arm64-musl": "3.2.4",
|
"oh-my-opencode-linux-arm64-musl": "3.3.0",
|
||||||
"oh-my-opencode-linux-x64": "3.2.4",
|
"oh-my-opencode-linux-x64": "3.3.0",
|
||||||
"oh-my-opencode-linux-x64-musl": "3.2.4",
|
"oh-my-opencode-linux-x64-musl": "3.3.0",
|
||||||
"oh-my-opencode-windows-x64": "3.2.4",
|
"oh-my-opencode-windows-x64": "3.3.0",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -226,19 +226,19 @@
|
|||||||
|
|
||||||
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
"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=="],
|
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
|
||||||
|
|
||||||
|
|||||||
@ -24,6 +24,13 @@ describe("ralph-loop", () => {
|
|||||||
})
|
})
|
||||||
return {}
|
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 } }) => {
|
messages: async (opts: { path: { id: string } }) => {
|
||||||
messagesCalls.push({ sessionID: opts.path.id })
|
messagesCalls.push({ sessionID: opts.path.id })
|
||||||
return { data: mockSessionMessages }
|
return { data: mockSessionMessages }
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, mock } from "bun:test"
|
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("parseModelSuggestion", () => {
|
||||||
describe("structured NamedError format", () => {
|
describe("structured NamedError format", () => {
|
||||||
@ -377,3 +377,128 @@ describe("promptWithModelSuggestionRetry", () => {
|
|||||||
expect(promptMock).toHaveBeenCalledTimes(1)
|
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")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@ -88,3 +88,42 @@ export async function promptWithModelSuggestionRetry(
|
|||||||
// model errors happen asynchronously server-side and cannot be caught here
|
// model errors happen asynchronously server-side and cannot be caught here
|
||||||
await client.session.promptAsync(args as Parameters<typeof client.session.promptAsync>[0])
|
await client.session.promptAsync(args as Parameters<typeof client.session.promptAsync>[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<void> {
|
||||||
|
try {
|
||||||
|
await client.session.prompt(args as Parameters<typeof client.session.prompt>[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<typeof client.session.prompt>[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { pathToFileURL } from "node:url"
|
|||||||
import { tool, type PluginInput, type ToolDefinition } from "@opencode-ai/plugin"
|
import { tool, type PluginInput, type ToolDefinition } from "@opencode-ai/plugin"
|
||||||
import { LOOK_AT_DESCRIPTION, MULTIMODAL_LOOKER_AGENT } from "./constants"
|
import { LOOK_AT_DESCRIPTION, MULTIMODAL_LOOKER_AGENT } from "./constants"
|
||||||
import type { LookAtArgs } from "./types"
|
import type { LookAtArgs } from "./types"
|
||||||
import { log, promptWithModelSuggestionRetry } from "../../shared"
|
import { log, promptSyncWithModelSuggestionRetry } from "../../shared"
|
||||||
|
|
||||||
interface LookAtArgsWithAlias extends LookAtArgs {
|
interface LookAtArgsWithAlias extends LookAtArgs {
|
||||||
path?: string
|
path?: string
|
||||||
@ -223,7 +223,7 @@ Original error: ${createResult.error}`
|
|||||||
|
|
||||||
log(`[look_at] Sending prompt with ${isBase64Input ? "base64 image" : "file"} to session ${sessionID}`)
|
log(`[look_at] Sending prompt with ${isBase64Input ? "base64 image" : "file"} to session ${sessionID}`)
|
||||||
try {
|
try {
|
||||||
await promptWithModelSuggestionRetry(ctx.client, {
|
await promptSyncWithModelSuggestionRetry(ctx.client, {
|
||||||
path: { id: sessionID },
|
path: { id: sessionID },
|
||||||
body: {
|
body: {
|
||||||
agent: MULTIMODAL_LOOKER_AGENT,
|
agent: MULTIMODAL_LOOKER_AGENT,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user