fix(prompt-retry): preserve async holds without blocking validation fallbacks

This commit is contained in:
YeonGyu-Kim
2026-05-19 13:48:33 +09:00
parent 5f733f471c
commit e57bac3b6b
9 changed files with 168 additions and 6 deletions
+61
View File
@@ -405,6 +405,67 @@ 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 () => {
// given
const promptMock = mock().mockRejectedValueOnce(new Error("JSON Parse error: Unexpected EOF"))
const client = { session: { promptAsync: promptMock } }
const args = {
path: { id: "session-failed-async-hold" },
body: {
parts: [{ type: "text", text: "hello" }],
model: { providerID: "anthropic", modelID: "claude-sonnet-4" },
},
}
// when
await expect(
promptWithModelSuggestionRetry(unsafeTestValue(client), args)
).rejects.toThrow("Unexpected EOF")
const second = await dispatchInternalPrompt({
mode: "async",
client,
sessionID: "session-failed-async-hold",
input: args,
source: "test:after-failed-async",
settleMs: 0,
postDispatchHoldMs: 0,
})
// then
expect(second).toEqual({ status: "reserved", reservedBy: "model-suggestion-retry" })
expect(promptMock).toHaveBeenCalledTimes(1)
})
it("#given promptAsync rejects before acceptance with an agent lookup error #when retried immediately #then the reservation is released", async () => {
// given
const promptMock = mock()
.mockRejectedValueOnce(new Error("Agent not found: missing-agent"))
.mockResolvedValueOnce(undefined)
const client = { session: { promptAsync: promptMock } }
const args = {
path: { id: "session-agent-preaccept-failure" },
body: {
agent: "missing-agent",
parts: [{ type: "text", text: "hello" }],
},
}
// when
await expect(
promptWithModelSuggestionRetry(unsafeTestValue(client), args)
).rejects.toThrow("Agent not found")
await promptWithModelSuggestionRetry(unsafeTestValue(client), {
...args,
body: {
...args.body,
agent: "general",
},
})
// then
expect(promptMock).toHaveBeenCalledTimes(2)
})
it("should pass all body fields through to promptAsync", async () => {
// given a client where promptAsync succeeds
const promptMock = mock().mockResolvedValueOnce(undefined)
+11 -4
View File
@@ -8,7 +8,6 @@ import {
import {
dispatchInternalPrompt,
releasePromptAsyncReservation,
type InternalPromptDispatchResult,
} from "./prompt-async-gate"
type Client = ReturnType<typeof createOpencodeClient>
@@ -34,6 +33,15 @@ function extractMessage(error: unknown): string {
return String(error)
}
function isAgentResolutionError(error: unknown): boolean {
const message = extractMessage(error)
return message.includes("Agent not found") || message.includes("agent.name")
}
function shouldReleaseReservationAfterFailedAsyncPrompt(error: unknown): boolean {
return parseModelSuggestion(error) !== null || isAgentResolutionError(error)
}
export function parseModelSuggestion(error: unknown): ModelSuggestionInfo | null {
if (!error) return null
@@ -98,10 +106,9 @@ export async function promptWithModelSuggestionRetry(
): Promise<void> {
const timeoutMs = options.timeoutMs ?? PROMPT_TIMEOUT_MS
const timeoutContext = createPromptTimeoutContext(args, timeoutMs)
let promptResult: InternalPromptDispatchResult | undefined
try {
promptResult = await dispatchInternalPrompt({
const promptResult = await dispatchInternalPrompt({
mode: "async",
client,
sessionID: args.path.id,
@@ -125,7 +132,7 @@ export async function promptWithModelSuggestionRetry(
if (timeoutContext.wasTimedOut()) {
throw new Error(`promptAsync timed out after ${timeoutMs}ms`)
}
if (promptResult?.status === "failed") {
if (shouldReleaseReservationAfterFailedAsyncPrompt(error)) {
releasePromptAsyncReservation(args.path.id, "model-suggestion-retry")
}
throw error