fix(prompt): treat post-dispatch failures as accepted
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,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)) {
|
||||
|
||||
Reference in New Issue
Block a user