diff --git a/src/features/background-agent/manager.test.ts b/src/features/background-agent/manager.test.ts index 761cf7866..3db7d9c0a 100644 --- a/src/features/background-agent/manager.test.ts +++ b/src/features/background-agent/manager.test.ts @@ -6,7 +6,7 @@ import { clearAllDelegatedChildSessionBootstrap, getDelegatedChildSessionBootstrap, } from "../../shared/delegated-child-session-bootstrap" -import { dispatchInternalPrompt } from "../../shared/prompt-async-gate" +import { dispatchInternalPrompt, releaseAllPromptAsyncReservationsForTesting } from "../../shared/prompt-async-gate" import { clearSessionPromptParams, getSessionPromptParams } from "../../shared/session-prompt-params-state" import { getSessionAgent, @@ -26,6 +26,7 @@ afterAll(() => { mock.restore() }) afterEach(() => { clearBackgroundTaskRegistryForTesting() + releaseAllPromptAsyncReservationsForTesting() }) const TASK_TTL_MS = 30 * 60 * 1000 diff --git a/src/features/background-agent/spawner.test.ts b/src/features/background-agent/spawner.test.ts index 558cbefb7..12cfb55f9 100644 --- a/src/features/background-agent/spawner.test.ts +++ b/src/features/background-agent/spawner.test.ts @@ -3,12 +3,14 @@ import { clearSessionPromptParams, getSessionPromptParams, } from "../../shared/session-prompt-params-state" +import { releaseAllPromptAsyncReservationsForTesting } from "../../shared/prompt-async-gate" import { createTask, startTask } from "./spawner" import type { BackgroundTask } from "./types" describe("background-agent spawner agent-not-found fallback", () => { afterEach(() => { clearSessionPromptParams("session-fallback") + releaseAllPromptAsyncReservationsForTesting() }) test("retries with 'general' agent when promptAsync fails with Agent not found", async () => { diff --git a/src/shared/model-suggestion-retry.test.ts b/src/shared/model-suggestion-retry.test.ts index caf7258fe..5ddc0d8f9 100644 --- a/src/shared/model-suggestion-retry.test.ts +++ b/src/shared/model-suggestion-retry.test.ts @@ -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) diff --git a/src/shared/model-suggestion-retry.ts b/src/shared/model-suggestion-retry.ts index 1f21828d3..48f93a3cb 100644 --- a/src/shared/model-suggestion-retry.ts +++ b/src/shared/model-suggestion-retry.ts @@ -8,7 +8,6 @@ import { import { dispatchInternalPrompt, releasePromptAsyncReservation, - type InternalPromptDispatchResult, } from "./prompt-async-gate" type Client = ReturnType @@ -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 { 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 diff --git a/src/tools/delegate-task/sync-prompt-sender.test.ts b/src/tools/delegate-task/sync-prompt-sender.test.ts index f86e87997..ae1ebfe21 100644 --- a/src/tools/delegate-task/sync-prompt-sender.test.ts +++ b/src/tools/delegate-task/sync-prompt-sender.test.ts @@ -412,4 +412,46 @@ bunDescribe("sendSyncPrompt", () => { bunExpect(promptWithModelSuggestionRetry).toHaveBeenCalledTimes(1) bunExpect(promptSyncWithModelSuggestionRetry).toHaveBeenCalledTimes(0) }) + + bunTest("#given oracle promptSync fallback is blocked by the prompt gate #when async prompt reports EOF #then the original EOF error is preserved", async () => { + //#given + const { sendSyncPrompt } = require("./sync-prompt-sender") + + const promptWithModelSuggestionRetry = bunMock(async () => { + throw new Error("JSON Parse error: Unexpected EOF") + }) + const promptSyncWithModelSuggestionRetry = bunMock(async () => { + throw new Error("prompt skipped by gate: reserved") + }) + + const input = { + sessionID: "test-session", + agentToUse: "oracle", + args: { + description: "test task", + prompt: "test prompt", + run_in_background: false, + load_skills: [], + }, + systemContent: undefined, + categoryModel: undefined, + toastManager: null, + taskId: undefined, + } + + //#when + const result = await sendSyncPrompt( + { session: { promptAsync: bunMock(async () => ({ data: {} })) } }, + input, + { + promptWithModelSuggestionRetry, + promptSyncWithModelSuggestionRetry, + }, + ) + + //#then + bunExpect(result).toContain("JSON Parse error") + bunExpect(result).not.toContain("prompt skipped by gate") + bunExpect(promptSyncWithModelSuggestionRetry).toHaveBeenCalledTimes(1) + }) }) diff --git a/src/tools/delegate-task/sync-prompt-sender.ts b/src/tools/delegate-task/sync-prompt-sender.ts index a67e0db39..2d1af32c3 100644 --- a/src/tools/delegate-task/sync-prompt-sender.ts +++ b/src/tools/delegate-task/sync-prompt-sender.ts @@ -52,6 +52,11 @@ function isUnexpectedEofError(error: unknown): boolean { return lowered.includes("unexpected eof") || lowered.includes("json parse error") } +function isPromptGateReservedError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error) + return message.includes("promptAsync skipped by gate: reserved") || message.includes("prompt skipped by gate: reserved") +} + export function buildSyncPromptTools(agentToUse: string): Record { return { task: isPlanFamily(agentToUse), @@ -112,7 +117,9 @@ export async function sendSyncPrompt( await deps.promptSyncWithModelSuggestionRetry(client, routePromptSyncRetry(promptArgs, input.directory)) return null } catch (oracleRetryError) { - promptError = oracleRetryError + if (!isPromptGateReservedError(oracleRetryError)) { + promptError = oracleRetryError + } } } diff --git a/src/tools/delegate-task/sync-task-fallback.ts b/src/tools/delegate-task/sync-task-fallback.ts index fbbc24316..4f81357d3 100644 --- a/src/tools/delegate-task/sync-task-fallback.ts +++ b/src/tools/delegate-task/sync-task-fallback.ts @@ -16,6 +16,10 @@ function toDelegatedModelConfig(fallback: NonNullable { ]) }) + test("#given sync prompt fallback is blocked by the prompt gate #when retrying prompt fallback #then preserves the original prompt error", async () => { + //#given + const { retrySyncPromptWithFallbacks } = require("./sync-task-fallback") + const sendPrompt = mock(async () => "promptAsync skipped by gate: reserved") + const initialModel = { + providerID: "anthropic", + modelID: "claude-opus-4-7", + variant: "max", + } + + //#when + const result = await retrySyncPromptWithFallbacks({ + sessionID: "ses_gate_reserved", + initialError: "JSON Parse error: Unexpected EOF", + categoryModel: initialModel, + fallbackChain: [ + { providers: ["anthropic"], model: "claude-opus-4-7", variant: "max" }, + { providers: ["openai"], model: "gpt-5.4", variant: "medium" }, + ], + sendPrompt, + }) + + //#then + expect(result.promptError).toBe("JSON Parse error: Unexpected EOF") + expect(result.categoryModel).toEqual(initialModel) + expect(sendPrompt).toHaveBeenCalledTimes(1) + }) + test("cleans up toast and subagentSessions on successful completion", async () => { const mockClient = { session: { diff --git a/src/tools/delegate-task/tools.test.ts b/src/tools/delegate-task/tools.test.ts index 35dde5055..6d1af7c19 100644 --- a/src/tools/delegate-task/tools.test.ts +++ b/src/tools/delegate-task/tools.test.ts @@ -9,6 +9,7 @@ import { clearSkillCache } from "../../features/opencode-skill-loader/skill-cont import { __setTimingConfig, __resetTimingConfig } from "./timing" import * as connectedProvidersCache from "../../shared/connected-providers-cache" import * as executor from "./executor" +import { releaseAllPromptAsyncReservationsForTesting } from "../../shared/prompt-async-gate" const runtimeRequire = require as NodeJS.Require & { cache?: Record } @@ -78,6 +79,7 @@ describe("sisyphus-task", () => { afterEach(() => { __resetTimingConfig() + releaseAllPromptAsyncReservationsForTesting() cacheSpy?.mockRestore() providerModelsSpy?.mockRestore() })