fix(prompt): treat post-dispatch failures as accepted

This commit is contained in:
YeonGyu-Kim
2026-05-20 11:42:32 +09:00
parent f540249838
commit d3e218f912
31 changed files with 503 additions and 76 deletions
+20 -4
View File
@@ -402,7 +402,7 @@ describe("promptWithModelSuggestionRetry", () => {
expect(promptMock).toHaveBeenCalledTimes(1)
})
it("#given promptAsync throws after dispatch was attempted #when caller observes the error #then the post-dispatch hold remains reserved", async () => {
it("#given promptAsync throws after dispatch was attempted #when caller observes ambiguous EOF #then it treats the prompt as accepted and keeps the hold", async () => {
// given
const promptMock = mock().mockRejectedValueOnce(new Error("JSON Parse error: Unexpected EOF"))
const client = { session: { promptAsync: promptMock } }
@@ -415,9 +415,7 @@ describe("promptWithModelSuggestionRetry", () => {
}
// when
await expect(
promptWithModelSuggestionRetry(unsafeTestValue(client), args)
).rejects.toThrow("Unexpected EOF")
await promptWithModelSuggestionRetry(unsafeTestValue(client), args)
const second = await dispatchInternalPrompt({
mode: "async",
client,
@@ -623,6 +621,24 @@ describe("promptSyncWithModelSuggestionRetry", () => {
expect(receivedSignal?.aborted).toBe(true)
})
it("#given sync prompt throws after dispatch was attempted #when caller observes ambiguous EOF #then it treats the prompt as accepted", async () => {
// given
const promptMock = mock().mockRejectedValueOnce(new Error("JSON Parse error: Unexpected EOF"))
const client = { session: { prompt: promptMock } }
// when
await promptSyncWithModelSuggestionRetry(unsafeTestValue(client), {
path: { id: "session-sync-ambiguous-eof" },
body: {
parts: [{ type: "text", text: "hello" }],
model: { providerID: "anthropic", modelID: "claude-sonnet-4" },
},
})
// then
expect(promptMock).toHaveBeenCalledTimes(1)
})
it("should retry with suggested model on ProviderModelNotFoundError", async () => {
// given a client that fails first with model-not-found, then succeeds
const promptMock = mock()
+19
View File
@@ -10,6 +10,7 @@ import {
isInternalPromptDispatchAccepted,
releasePromptAsyncReservation,
} from "./prompt-async-gate"
import { isAmbiguousPostDispatchPromptFailure } from "./prompt-failure-classifier"
type Client = ReturnType<typeof createOpencodeClient>
@@ -122,6 +123,12 @@ export async function promptWithModelSuggestionRetry(
...(options.queueBehavior ? { queueBehavior: options.queueBehavior } : {}),
})
if (promptResult.status === "failed") {
if (timeoutContext.wasTimedOut()) {
throw new Error(`promptAsync timed out after ${timeoutMs}ms`)
}
if (isAmbiguousPostDispatchPromptFailure(promptResult)) {
return
}
throw promptResult.error
}
if (!isInternalPromptDispatchAccepted(promptResult)) {
@@ -168,6 +175,12 @@ export async function promptSyncWithModelSuggestionRetry(
...(options.queueBehavior ? { queueBehavior: options.queueBehavior } : {}),
})
if (promptResult.status === "failed") {
if (timeoutContext.wasTimedOut()) {
throw new Error(`prompt timed out after ${timeoutMs}ms`)
}
if (isAmbiguousPostDispatchPromptFailure(promptResult)) {
return
}
throw promptResult.error
}
if (!isInternalPromptDispatchAccepted(promptResult)) {
@@ -228,6 +241,12 @@ export async function promptSyncWithModelSuggestionRetry(
...(options.queueBehavior ? { queueBehavior: options.queueBehavior } : {}),
})
if (promptResult.status === "failed") {
if (timeoutContext.wasTimedOut()) {
throw new Error(`prompt timed out after ${timeoutMs}ms`)
}
if (isAmbiguousPostDispatchPromptFailure(promptResult)) {
return
}
throw promptResult.error
}
if (!isInternalPromptDispatchAccepted(promptResult)) {
+2 -2
View File
@@ -86,7 +86,7 @@ export type InternalPromptDispatchResult =
| { status: "active" }
| { status: "reserved"; reservedBy: string }
| { status: "unavailable" }
| { status: "failed"; error: unknown }
| { status: "failed"; error: unknown; dispatchAttempted: boolean }
export type PromptAsyncGateResult = InternalPromptDispatchResult
@@ -579,7 +579,7 @@ async function dispatchAfterSessionIdle<TInput>(args: {
return { status: "dispatched", response }
} catch (error) {
log(`[prompt-async-gate] ${sessionName} failed`, { sessionID, source, error: String(error) })
return { status: "failed", error }
return { status: "failed", error, dispatchAttempted }
} finally {
const current = promptAsyncReservations.get(sessionID)
if (current?.token === reservation.token) {
+34 -1
View File
@@ -1,6 +1,9 @@
import { describe, expect, test } from "bun:test"
import { isAmbiguousPromptDispatchFailure } from "./prompt-failure-classifier"
import {
isAmbiguousPostDispatchPromptFailure,
isAmbiguousPromptDispatchFailure,
} from "./prompt-failure-classifier"
describe("prompt failure classifier", () => {
test("#given prompt dispatch reports a generic JSON parse error #when classifying ambiguity #then it treats the dispatch as possibly accepted", () => {
@@ -24,4 +27,34 @@ describe("prompt failure classifier", () => {
// then
expect(ambiguous).toBe(true)
})
test("#given ambiguous failure before dispatch #when classifying post-dispatch acceptance #then it is not treated as accepted", () => {
// given
const result = {
status: "failed" as const,
dispatchAttempted: false,
error: new Error("JSON Parse error: Unexpected EOF"),
}
// when
const ambiguous = isAmbiguousPostDispatchPromptFailure(result)
// then
expect(ambiguous).toBe(false)
})
test("#given ambiguous failure after dispatch #when classifying post-dispatch acceptance #then it is treated as accepted", () => {
// given
const result = {
status: "failed" as const,
dispatchAttempted: true,
error: new Error("JSON Parse error: Unexpected EOF"),
}
// when
const ambiguous = isAmbiguousPostDispatchPromptFailure(result)
// then
expect(ambiguous).toBe(true)
})
})
+10
View File
@@ -22,3 +22,13 @@ export function isAmbiguousPromptDispatchFailure(error: unknown): boolean {
|| message.includes("timed out")
)
}
type PromptDispatchFailureResultLike = {
status: "failed"
error: unknown
dispatchAttempted?: boolean
}
export function isAmbiguousPostDispatchPromptFailure(result: PromptDispatchFailureResultLike): boolean {
return result.dispatchAttempted === true && isAmbiguousPromptDispatchFailure(result.error)
}
+27
View File
@@ -63,6 +63,33 @@ describe("promptAsyncInDirectory", () => {
expect(promptAsync).toHaveBeenCalledTimes(1)
expect(promptAsync.mock.calls[0]?.[0].query).toEqual({ directory: "/workspace/project" })
})
test("#given routed promptAsync reports ambiguous EOF after dispatch #when the route handles it #then it treats the prompt as accepted", async () => {
// given
const promptAsync = mock(async () => {
throw new Error("JSON Parse error: Unexpected EOF")
})
const client = {
session: {
promptAsync,
},
}
const args = {
path: { id: "ses_route_ambiguous_eof" },
body: { parts: [{ type: "text", text: "continue" }] },
}
// when
const result = await promptAsyncInDirectory(
unsafeTestValue(client),
unsafeTestValue(args),
"/workspace/project",
)
// then
expect(result).toBeUndefined()
expect(promptAsync).toHaveBeenCalledTimes(1)
})
})
describe("promptWithRetryInDirectory", () => {
+4
View File
@@ -4,6 +4,7 @@ import {
promptWithModelSuggestionRetry,
} from "./model-suggestion-retry"
import { dispatchInternalPrompt, isInternalPromptDispatchAccepted } from "./prompt-async-gate"
import { isAmbiguousPostDispatchPromptFailure } from "./prompt-failure-classifier"
type OpencodeClient = PluginInput["client"]
@@ -69,6 +70,9 @@ export function promptAsyncInDirectory(
queueBehavior: "defer",
}).then((result) => {
if (result.status === "failed") {
if (isAmbiguousPostDispatchPromptFailure(result)) {
return undefined
}
throw result.error
}
if (!isInternalPromptDispatchAccepted(result)) {