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
+11 -2
View File
@@ -14,6 +14,7 @@ import { suppressRunInput } from "./stdin-suppression"
import { createTimestampedStdoutController } from "./timestamp-output"
import { createCliPostHog, getPostHogDistinctId } from "../../shared/posthog"
import { dispatchInternalPrompt, isInternalPromptDispatchAccepted } from "../../shared/prompt-async-gate"
import { isAmbiguousPostDispatchPromptFailure } from "../../shared/prompt-failure-classifier"
export { resolveRunAgent }
@@ -130,10 +131,18 @@ export async function run(options: RunOptions): Promise<number> {
query: { directory },
},
})
const promptMayHaveBeenAccepted = promptResult.status === "failed"
&& isAmbiguousPostDispatchPromptFailure(promptResult)
if (promptResult.status === "failed") {
throw promptResult.error
if (promptMayHaveBeenAccepted) {
if (options.verbose) {
console.error(pc.dim("promptAsync returned an ambiguous error after dispatch; continuing to poll session"))
}
} else {
throw promptResult.error
}
}
if (!isInternalPromptDispatchAccepted(promptResult)) {
if (!promptMayHaveBeenAccepted && !isInternalPromptDispatchAccepted(promptResult)) {
throw new Error(`Session ${sessionID} is not idle; promptAsync skipped by gate: ${promptResult.status}`)
}
const exitCode = await pollForCompletion(ctx, eventState, abortController)
@@ -630,6 +630,63 @@ describe("BackgroundManager prompt rejection fallback routing", () => {
expect(storedTask?.status).toBe("pending")
})
test("keeps launch running when promptAsync returns ambiguous EOF after dispatch", async () => {
//#given
let abortCalls = 0
const client = {
session: {
get: async () => ({ data: { directory: tmpdir() } }),
create: async () => ({ data: { id: "ses_launch_ambiguous" } }),
promptAsync: async () => {
throw new Error("JSON Parse error: Unexpected EOF")
},
abort: async () => {
abortCalls += 1
return {}
},
},
}
const manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
stubNotifyParentSession(manager)
;(cast<{
reserveSubagentSpawn: () => Promise<{
spawnContext: { rootSessionID: string; parentDepth: number; childDepth: number }
descendantCount: number
commit: () => number
rollback: () => void
}>
}>(manager)).reserveSubagentSpawn = async () => ({
spawnContext: { rootSessionID: "parent-session", parentDepth: 0, childDepth: 1 },
descendantCount: 1,
commit: () => 1,
rollback: () => {},
})
const retried: string[] = []
;(cast<{
tryFallbackRetry: (task: BackgroundTask, errorInfo: { name?: string; message?: string }, source: string) => Promise<boolean>
}>(manager)).tryFallbackRetry = async (_task, _errorInfo, source) => {
retried.push(source)
return true
}
//#when
const launchedTask = await manager.launch({
description: "ambiguous launch",
prompt: "say hi",
agent: "sisyphus-junior",
parentSessionId: "parent-session",
parentMessageId: "parent-message",
fallbackChain: [{ model: "claude-haiku-4-5", providers: ["anthropic"] }],
})
await flushBackgroundNotifications()
//#then
const storedTask = getTaskMap(manager).get(launchedTask.id)
expect(retried).toEqual([])
expect(abortCalls).toBe(0)
expect(storedTask?.status).toBe("running")
})
test("routes resume-time prompt rejections into tryFallbackRetry before marking interrupt", async () => {
//#given
const promptError = {
@@ -691,6 +748,61 @@ describe("BackgroundManager prompt rejection fallback routing", () => {
})
expect(storedTask?.status).toBe("pending")
})
test("keeps resumed task running when promptAsync returns ambiguous EOF after dispatch", async () => {
//#given
let abortCalls = 0
const client = {
session: {
promptAsync: async () => {
throw new Error("JSON Parse error: Unexpected EOF")
},
abort: async () => {
abortCalls += 1
return {}
},
},
}
const manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
stubNotifyParentSession(manager)
const task: BackgroundTask = {
id: "bg_resume_ambiguous",
sessionId: "ses_resume_ambiguous",
parentSessionId: "parent-session",
parentMessageId: "parent-message",
description: "resume ambiguous test",
prompt: "say hi",
agent: "sisyphus-junior",
status: "completed",
startedAt: new Date(),
completedAt: new Date(),
fallbackChain: [{ model: "claude-haiku-4-5", providers: ["anthropic"] }],
concurrencyGroup: "anthropic/claude-haiku-4-5",
}
getTaskMap(manager).set(task.id, task)
const retried: string[] = []
;(cast<{
tryFallbackRetry: (task: BackgroundTask, errorInfo: { name?: string; message?: string }, source: string) => Promise<boolean>
}>(manager)).tryFallbackRetry = async (_retryTask, _errorInfo, source) => {
retried.push(source)
return true
}
//#when
await manager.resume({
sessionId: "ses_resume_ambiguous",
prompt: "continue",
parentSessionId: "parent-session",
parentMessageId: "parent-message-2",
})
await flushBackgroundNotifications()
//#then
expect(retried).toEqual([])
expect(abortCalls).toBe(0)
expect(task.status).toBe("running")
expect(task.completedAt).toBeUndefined()
})
})
describe("BackgroundManager retry observability", () => {
+9
View File
@@ -11,6 +11,7 @@ import { isSessionActive as isOpenCodeSessionActive } from "../../hooks/shared/s
import {
createInternalAgentTextPart,
getAgentToolRestrictions,
isAmbiguousPostDispatchPromptFailure,
log,
messagesInDirectory,
normalizePromptTools,
@@ -1334,6 +1335,14 @@ The fallback retry session is now created and can be inspected directly.
},
}).then((promptResult) => {
if (promptResult.status === "failed") {
if (isAmbiguousPostDispatchPromptFailure(promptResult)) {
log("[background-agent] resume prompt may have been accepted before ambiguous failure; continuing to poll", {
taskId: existingTask.id,
sessionID: existingTask.sessionId,
error: promptResult.error instanceof Error ? promptResult.error.message : String(promptResult.error),
})
return
}
throw promptResult.error
}
if (promptResult.status === "queued") {
@@ -1,7 +1,7 @@
import { resolveRegisteredAgentName } from "../claude-code-session-state"
import {
createInternalAgentTextPart,
isAmbiguousPromptDispatchFailure,
isAmbiguousPostDispatchPromptFailure,
isSyntheticOrInternalUserMessage,
log,
messagesInDirectory,
@@ -209,6 +209,18 @@ export class ParentWakeNotifier {
},
})
if (promptResult.status === "failed") {
if (isAmbiguousPostDispatchPromptFailure(promptResult)) {
const dispatchedWake = this.cloneParentWake(latestWake)
dispatchedWake.dispatchedAt = dispatchStartedAt
if (await this.hasAcceptedMessageAfterDispatchedParentWake(sessionID, dispatchedWake)) {
this.trackDispatchedParentWake(sessionID, latestWake, dispatchStartedAt)
log("[background-agent] Treated failed parent wake prompt as accepted after observing session history:", {
sessionID,
error: promptResult.error,
})
return
}
}
throw promptResult.error
}
if (promptResult.status === "reserved" && promptResult.reservedBy === "background-agent-parent-wake") {
@@ -229,18 +241,6 @@ export class ParentWakeNotifier {
log("[background-agent] Sent deferred parent wake:", { sessionID })
this.trackDispatchedParentWake(sessionID, latestWake, dispatchStartedAt)
} catch (error) {
if (isAmbiguousPromptDispatchFailure(error)) {
const dispatchedWake = this.cloneParentWake(latestWake)
dispatchedWake.dispatchedAt = dispatchStartedAt
if (await this.hasAcceptedMessageAfterDispatchedParentWake(sessionID, dispatchedWake)) {
this.trackDispatchedParentWake(sessionID, latestWake, dispatchStartedAt)
log("[background-agent] Treated failed parent wake prompt as accepted after observing session history:", {
sessionID,
error,
})
return
}
}
this.requeueWake(sessionID, latestWake)
this.schedulePendingParentWakeFlush(sessionID)
log("[background-agent] Failed to send deferred parent wake:", { sessionID, error })
@@ -41,6 +41,33 @@ describe("background-agent session routing", () => {
expect(promptAsync.mock.calls[0]?.[0].query).toEqual({ directory: "/workspace/project" })
})
test("#given routed promptAsync reports ambiguous EOF after dispatch #when the background 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_background_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)
})
test("#given a background retry prompt just dispatched #when the same child session is prompted again immediately #then retry routing defers instead of enqueueing", async () => {
// given
const promptAsync = mock(async () => undefined)
@@ -1,6 +1,6 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { promptWithModelSuggestionRetry } from "../../shared"
import { isAmbiguousPostDispatchPromptFailure, promptWithModelSuggestionRetry } from "../../shared"
import { dispatchInternalPrompt, isInternalPromptDispatchAccepted } from "../../shared/prompt-async-gate"
type OpencodeClient = PluginInput["client"]
@@ -45,6 +45,9 @@ export function promptAsyncInDirectory(
queueBehavior: "defer",
}).then((result) => {
if (result.status === "failed") {
if (isAmbiguousPostDispatchPromptFailure(result)) {
return undefined
}
throw result.error
}
if (!isInternalPromptDispatchAccepted(result)) {
@@ -18,6 +18,7 @@ import {
findNearestMessageWithFieldsFromSDK,
} from "../../features/hook-message-injector"
import { dispatchInternalPrompt, isInternalPromptDispatchAccepted } from "../shared/prompt-async-gate"
import { isAmbiguousPostDispatchPromptFailure } from "../../shared/prompt-failure-classifier"
export async function runAggressiveTruncationStrategy(params: {
sessionID: string
@@ -108,6 +109,13 @@ export async function runAggressiveTruncationStrategy(params: {
} as never,
})
if (!isInternalPromptDispatchAccepted(promptResult)) {
if (promptResult.status === "failed" && isAmbiguousPostDispatchPromptFailure(promptResult)) {
log("[auto-compact] delayed auto prompt may have been accepted before ambiguous failure", {
sessionID: params.sessionID,
error: String(promptResult.error),
})
return
}
log("[auto-compact] delayed auto prompt skipped by promptAsync gate", {
sessionID: params.sessionID,
status: promptResult.status,
@@ -6,7 +6,7 @@ import {
import { stripAgentListSortPrefix } from "../../shared/agent-display-names"
import { log } from "../../shared/logger"
import { createInternalAgentContinuationTextPart, resolveInheritedPromptTools } from "../../shared"
import { isAmbiguousPromptDispatchFailure } from "../../shared/prompt-failure-classifier"
import { isAmbiguousPostDispatchPromptFailure } from "../../shared/prompt-failure-classifier"
import { dispatchInternalPrompt, isInternalPromptDispatchAccepted } from "../shared/prompt-async-gate"
import { HOOK_NAME } from "./hook-name"
import { BOULDER_CONTINUATION_PROMPT } from "./system-reminder-templates"
@@ -114,6 +114,15 @@ export async function injectBoulderContinuation(input: {
},
})
if (promptResult.status === "failed") {
if (isAmbiguousPostDispatchPromptFailure(promptResult)) {
sessionState.promptFailureCount = 0
markContinuationInjectedAwaitingToolProgress(sessionState)
log(`[${HOOK_NAME}] Boulder continuation prompt failed after dispatch may have been accepted`, {
sessionID,
error: String(promptResult.error),
})
return "injected"
}
throw promptResult.error
}
if (!isInternalPromptDispatchAccepted(promptResult)) {
@@ -129,15 +138,6 @@ export async function injectBoulderContinuation(input: {
log(`[${HOOK_NAME}] Boulder continuation injected`, { sessionID })
return "injected"
} catch (err) {
if (isAmbiguousPromptDispatchFailure(err)) {
sessionState.promptFailureCount = 0
markContinuationInjectedAwaitingToolProgress(sessionState)
log(`[${HOOK_NAME}] Boulder continuation prompt failed after dispatch may have been accepted`, {
sessionID,
error: String(err),
})
return "injected"
}
sessionState.promptFailureCount += 1
sessionState.lastFailureAt = Date.now()
log(`[${HOOK_NAME}] Boulder continuation failed`, {
+2 -2
View File
@@ -19,7 +19,7 @@ import { isSessionInBoulderLineage } from "./boulder-session-lineage"
import { createInternalAgentContinuationTextPart } from "../../shared"
import { getAgentConfigKey } from "../../shared/agent-display-names"
import { log } from "../../shared/logger"
import { isAmbiguousPromptDispatchFailure } from "../../shared/prompt-failure-classifier"
import { isAmbiguousPostDispatchPromptFailure } from "../../shared/prompt-failure-classifier"
import { shouldPromptAfterSessionIdle } from "../shared/session-idle-settle"
import { dispatchInternalPrompt, isInternalPromptDispatchAccepted } from "../shared/prompt-async-gate"
import { injectBoulderContinuation } from "./boulder-continuation-injector"
@@ -316,7 +316,7 @@ export async function handleAtlasSessionIdle(input: {
},
})
if (!isInternalPromptDispatchAccepted(promptResult)) {
if (promptResult.status === "failed" && isAmbiguousPromptDispatchFailure(promptResult.error)) {
if (promptResult.status === "failed" && isAmbiguousPostDispatchPromptFailure(promptResult)) {
sessionState.boulderCompletionNudgedAt = {
...(sessionState.boulderCompletionNudgedAt ?? {}),
[work.work_id]: Date.now(),
@@ -8,6 +8,7 @@ import { clearToolInputCache, stopToolInputCacheCleanup } from "../tool-input-ca
import type { PluginConfig } from "../types"
import { createInternalAgentTextPart, isHookDisabled, log } from "../../../shared"
import { resolveSessionEventID } from "../../../shared/event-session-id"
import { isAmbiguousPostDispatchPromptFailure } from "../../../shared/prompt-failure-classifier"
import { dispatchInternalPrompt, isInternalPromptDispatchAccepted } from "../../../shared/prompt-async-gate"
import {
clearAllSessionHookState,
@@ -124,7 +125,14 @@ export function createSessionEventHandler(
},
})
if (promptResult.status === "failed") {
log("Failed to inject prompt from Stop hook", { error: String(promptResult.error) })
if (isAmbiguousPostDispatchPromptFailure(promptResult)) {
log("Prompt injected from Stop hook may have been accepted before ambiguous failure", {
sessionID,
error: String(promptResult.error),
})
} else {
log("Failed to inject prompt from Stop hook", { error: String(promptResult.error) })
}
} else if (!isInternalPromptDispatchAccepted(promptResult)) {
log("Skipped prompt injection from Stop hook", { sessionID, status: promptResult.status })
}
@@ -7,7 +7,7 @@ import {
} from "../../shared/compaction-agent-config-checkpoint"
import { createInternalAgentContinuationTextPart } from "../../shared/internal-initiator-marker"
import { log } from "../../shared/logger"
import { isAmbiguousPromptDispatchFailure } from "../../shared/prompt-failure-classifier"
import { isAmbiguousPostDispatchPromptFailure } from "../../shared/prompt-failure-classifier"
import { setSessionModel } from "../../shared/session-model-state"
import { setSessionTools } from "../../shared/session-tools-store"
import {
@@ -102,7 +102,7 @@ export function createRecoveryLogic(
},
})
if (!isInternalPromptDispatchAccepted(promptResult)) {
if (promptResult.status === "failed" && isAmbiguousPromptDispatchFailure(promptResult.error)) {
if (promptResult.status === "failed" && isAmbiguousPostDispatchPromptFailure(promptResult)) {
tailState.lastRecoveryAt = now
}
log(`[compaction-context-injector] Recovery skipped by promptAsync gate`, {
@@ -5,7 +5,7 @@ import { getMessageDir } from "./message-storage-directory"
import { withTimeout } from "./with-timeout"
import {
createInternalAgentContinuationTextPart,
isAmbiguousPromptDispatchFailure,
isAmbiguousPostDispatchPromptFailure,
isRecord,
normalizeSDKResponse,
resolveInheritedPromptTools,
@@ -160,7 +160,7 @@ export async function injectContinuationPrompt(
},
})
if (promptResult.status === "failed") {
if (isAmbiguousPromptDispatchFailure(promptResult.error)) {
if (isAmbiguousPostDispatchPromptFailure(promptResult)) {
return { status: "dispatched" }
}
throw promptResult.error
@@ -179,9 +179,6 @@ export async function injectContinuationPrompt(
}
response = promptResult.response
} catch (error) {
if (isAmbiguousPromptDispatchFailure(error)) {
return { status: "dispatched" }
}
const promptError = error instanceof Error
? error
: createPromptAsyncError("promptAsync rejected", error)
@@ -2,7 +2,7 @@ import type { createOpencodeClient } from "@opencode-ai/sdk"
import type { MessageData, ResumeConfig } from "./types"
import { readParts } from "./storage/parts-reader"
import { isSqliteBackend } from "../../shared/opencode-storage-detection"
import { isAmbiguousPromptDispatchFailure, normalizeSDKResponse } from "../../shared"
import { isAmbiguousPostDispatchPromptFailure, normalizeSDKResponse } from "../../shared"
import { dispatchInternalPrompt, isInternalPromptDispatchAccepted } from "../shared/prompt-async-gate"
type Client = ReturnType<typeof createOpencodeClient>
@@ -179,7 +179,7 @@ export async function recoverToolResultMissing(
queueBehavior: "defer",
})
if (promptResult.status === "failed" && isAmbiguousPromptDispatchFailure(promptResult.error)) {
if (promptResult.status === "failed" && isAmbiguousPostDispatchPromptFailure(promptResult)) {
return true
}
return isInternalPromptDispatchAccepted(promptResult)
@@ -2,7 +2,7 @@ import type { createOpencodeClient } from "@opencode-ai/sdk"
import { extractUnavailableToolName } from "./detect-error-type"
import { readParts } from "./storage"
import type { MessageData } from "./types"
import { isAmbiguousPromptDispatchFailure, normalizeSDKResponse } from "../../shared"
import { isAmbiguousPostDispatchPromptFailure, normalizeSDKResponse } from "../../shared"
import { isSqliteBackend } from "../../shared/opencode-storage-detection"
import { dispatchInternalPrompt, isInternalPromptDispatchAccepted } from "../shared/prompt-async-gate"
@@ -130,7 +130,7 @@ export async function recoverUnavailableTool(
checkToolState: false,
input: promptInput,
})
if (promptResult.status === "failed" && isAmbiguousPromptDispatchFailure(promptResult.error)) {
if (promptResult.status === "failed" && isAmbiguousPostDispatchPromptFailure(promptResult)) {
return true
}
return isInternalPromptDispatchAccepted(promptResult)
+2 -2
View File
@@ -1,7 +1,7 @@
import type { createOpencodeClient } from "@opencode-ai/sdk"
import {
createInternalAgentContinuationTextPart,
isAmbiguousPromptDispatchFailure,
isAmbiguousPostDispatchPromptFailure,
isRealUserMessage,
resolveInheritedPromptTools,
} from "../../shared"
@@ -56,7 +56,7 @@ export async function resumeSession(client: Client, config: ResumeConfig): Promi
},
},
})
if (promptResult.status === "failed" && isAmbiguousPromptDispatchFailure(promptResult.error)) {
if (promptResult.status === "failed" && isAmbiguousPostDispatchPromptFailure(promptResult)) {
return true
}
return isInternalPromptDispatchAccepted(promptResult)
@@ -1126,7 +1126,9 @@ describe("dispatchInternalPrompt shared gate behavior", () => {
// then
expect(first.status).toBe("failed")
expect(first).toMatchObject({ dispatchAttempted: true })
expect(second.status).toBe("failed")
expect(second).toMatchObject({ dispatchAttempted: true })
expect(promptCalls).toBe(2)
})
@@ -1162,6 +1164,7 @@ describe("dispatchInternalPrompt shared gate behavior", () => {
// then
expect(first.status).toBe("failed")
expect(first).toMatchObject({ dispatchAttempted: true })
expect(second).toEqual({ status: "queued", queuedBy: "test:reject:first", position: 1 })
expect(promptCalls).toBe(1)
})
@@ -7,7 +7,7 @@ import {
} from "../../features/claude-code-session-state"
import {
createInternalAgentContinuationTextPart,
isAmbiguousPromptDispatchFailure,
isAmbiguousPostDispatchPromptFailure,
normalizeSDKResponse,
resolveInheritedPromptTools,
} from "../../shared"
@@ -208,6 +208,15 @@ ${todoList}`
},
})
if (promptResult.status === "failed") {
if (isAmbiguousPostDispatchPromptFailure(promptResult)) {
if (injectionState) {
injectionState.inFlight = false
injectionState.lastInjectedAt = Date.now()
injectionState.awaitingPostInjectionProgressCheck = true
injectionState.consecutiveFailures = 0
}
return
}
throw promptResult.error
}
if (!isInternalPromptDispatchAccepted(promptResult)) {
@@ -230,11 +239,6 @@ ${todoList}`
if (injectionState) {
injectionState.inFlight = false
injectionState.lastInjectedAt = Date.now()
if (isAmbiguousPromptDispatchFailure(error)) {
injectionState.awaitingPostInjectionProgressCheck = true
injectionState.consecutiveFailures = 0
return
}
injectionState.consecutiveFailures = (injectionState.consecutiveFailures ?? 0) + 1
const errorObj = error instanceof Error
@@ -1,7 +1,7 @@
import type { BackgroundManager } from "../../features/background-agent"
import { getMainSessionID, getSessionAgent } from "../../features/claude-code-session-state"
import { log } from "../../shared/logger"
import { createInternalAgentTextPart, isAmbiguousPromptDispatchFailure, resolveInheritedPromptTools } from "../../shared"
import { createInternalAgentTextPart, isAmbiguousPostDispatchPromptFailure, resolveInheritedPromptTools } from "../../shared"
import { resolveMessageEventSessionID, resolveSessionEventID } from "../../shared/event-session-id"
import { isAbortError } from "../../shared/is-abort-error"
import {
@@ -261,7 +261,7 @@ export function createUnstableAgentBabysitterHook(ctx: BabysitterContext, option
},
})
if (!isInternalPromptDispatchAccepted(promptResult)) {
if (promptResult.status === "failed" && isAmbiguousPromptDispatchFailure(promptResult.error)) {
if (promptResult.status === "failed" && isAmbiguousPostDispatchPromptFailure(promptResult)) {
reminderCooldowns.set(task.id, now)
}
log(`[${HOOK_NAME}] Reminder skipped by promptAsync gate`, {
+12 -1
View File
@@ -30,6 +30,7 @@ import { getAgentConfigKey } from "../shared/agent-display-names";
import { readConnectedProvidersCache } from "../shared/connected-providers-cache";
import { invalidateContextWindowUsageCache } from "../shared/dynamic-truncator";
import { log } from "../shared/logger";
import { isAmbiguousPostDispatchPromptFailure } from "../shared/prompt-failure-classifier";
import { shouldRetryError } from "../shared/model-error-classifier";
import { buildFallbackChainFromModels } from "../shared/fallback-chain-from-models";
import { extractRetryAttempt, normalizeRetryStatusMessage } from "../shared/retry-status-utils";
@@ -527,6 +528,9 @@ export function createEventHandler(args: {
if (isInternalPromptDispatchAccepted(promptResult)) {
dispatched = true;
} else if (promptResult.status === "failed") {
if (isAmbiguousPostDispatchPromptFailure(promptResult)) {
dispatched = true;
}
const error = promptResult.error;
log("[event] model-fallback promptAsync failed", { sessionID, source, error });
} else {
@@ -546,6 +550,9 @@ export function createEventHandler(args: {
if (isInternalPromptDispatchAccepted(promptResult)) {
dispatched = true;
} else if (promptResult.status === "failed") {
if (isAmbiguousPostDispatchPromptFailure(promptResult)) {
dispatched = true;
}
log("[event] model-fallback prompt failed", { sessionID, source, error: promptResult.error });
} else {
log("[event] model-fallback prompt skipped by gate", { sessionID, source, status: promptResult.status });
@@ -965,7 +972,11 @@ export function createEventHandler(args: {
},
});
if (promptResult.status === "failed") {
log("[event] recovery continue prompt failed", { sessionID, error: promptResult.error });
if (isAmbiguousPostDispatchPromptFailure(promptResult)) {
log("[event] recovery continue prompt may have been accepted before ambiguous failure", { sessionID, error: promptResult.error });
} else {
log("[event] recovery continue prompt failed", { sessionID, error: promptResult.error });
}
} else if (!isInternalPromptDispatchAccepted(promptResult)) {
log("[event] recovery continue prompt skipped by gate", { sessionID, status: promptResult.status });
}
+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)) {
@@ -479,6 +479,39 @@ describe("executeSync", () => {
expect(deps.processMessages).not.toHaveBeenCalled()
})
test("#given sync prompt returns ambiguous EOF after dispatch #when executeSync runs #then it waits for the existing session result", async () => {
//#given
const executeSync = await importExecuteSync()
const deps = createDependencies({
createOrGetSession: mock(async () => ({ sessionID: "ses-ambiguous-prompt", isNew: true })),
waitForCompletion: mock(async () => {}),
processMessages: mock(async () => "accepted response"),
})
const toolContext = createToolContext()
const recorder = createPromptAsyncRecorder(async () => {
throw new Error("JSON Parse error: Unexpected EOF")
})
const args = {
subagent_type: "librarian",
description: "ambiguous prompt",
prompt: "find docs",
run_in_background: false,
}
//#when
const result = await executeSync(args, toolContext, createContext(recorder.promptAsync) as never, deps)
//#then
expect(result).toContain("accepted response")
expect(result).toContain("session_id: ses-ambiguous-prompt")
expect(deps.waitForCompletion).toHaveBeenCalledWith(
"ses-ambiguous-prompt",
toolContext,
expect.objectContaining({ client: expect.anything() }),
)
expect(deps.processMessages).toHaveBeenCalledTimes(1)
})
test("does not send a duplicate sync prompt when a reused session is active", async () => {
//#given
const executeSync = await importExecuteSync()
+12 -3
View File
@@ -1,7 +1,7 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { clearSessionAgent, setSessionAgent, subagentSessions, syncSubagentSessions } from "../../features/claude-code-session-state"
import { dispatchInternalPrompt, isInternalPromptDispatchAccepted } from "../../hooks/shared/prompt-async-gate"
import { getAgentToolRestrictions, log } from "../../shared"
import { getAgentToolRestrictions, isAmbiguousPostDispatchPromptFailure, log } from "../../shared"
import { getAgentDisplayName, stripAgentListSortPrefix } from "../../shared/agent-display-names"
import {
clearDelegatedChildSessionBootstrap,
@@ -153,10 +153,19 @@ export async function executeSync(
},
},
})
const promptMayHaveBeenAccepted = promptResult.status === "failed"
&& isAmbiguousPostDispatchPromptFailure(promptResult)
if (promptResult.status === "failed") {
throw promptResult.error
if (promptMayHaveBeenAccepted) {
log("[call_omo_agent] Prompt returned an ambiguous error after dispatch; waiting for completion", {
sessionID,
error: promptResult.error instanceof Error ? promptResult.error.message : String(promptResult.error),
})
} else {
throw promptResult.error
}
}
if (!isInternalPromptDispatchAccepted(promptResult)) {
if (!promptMayHaveBeenAccepted && !isInternalPromptDispatchAccepted(promptResult)) {
throw new Error(`prompt skipped by gate: ${promptResult.status}`)
}
} catch (error) {
+73 -2
View File
@@ -2006,7 +2006,7 @@ describe("sisyphus-task", () => {
}
const promptMock = async () => {
throw new Error("JSON Parse error: Unexpected EOF")
throw new Error("Synthetic prompt transport failure")
}
const mockClient = {
@@ -2050,11 +2050,82 @@ describe("sisyphus-task", () => {
// then - should return detailed error message with args and stack trace
expect(result).toContain("Send prompt failed")
expect(result).toContain("JSON Parse error")
expect(result).toContain("Synthetic prompt transport failure")
expect(result).toContain("**Arguments**:")
expect(result).toContain("**Stack Trace**:")
})
test("#given sync prompt returns ambiguous EOF #when sync task runs #then it waits for the accepted session result", async () => {
// given
const { createDelegateTask } = require("./tools")
let promptCalls = 0
const mockManager = {
launch: async () => ({}),
}
const promptMock = async () => {
promptCalls += 1
throw new Error("JSON Parse error: Unexpected EOF")
}
const mockClient = {
session: {
get: async () => ({ data: { directory: "/project" } }),
create: async () => ({ data: { id: "ses_sync_ambiguous_eof" } }),
prompt: promptMock,
promptAsync: promptMock,
messages: async () => ({
data: [
{
info: { id: "msg_001", role: "user", time: { created: Date.now() } },
parts: [{ type: "text", text: "Do something" }],
},
{
info: { id: "msg_002", role: "assistant", time: { created: Date.now() + 1 }, finish: "end_turn" },
parts: [{ type: "text", text: "Accepted despite EOF" }],
},
],
}),
status: async () => ({ data: { ses_sync_ambiguous_eof: { type: "idle" } } }),
abort: async () => ({}),
},
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
app: {
agents: async () => ({ data: [{ name: "ultrabrain", mode: "subagent" }] }),
},
}
const tool = createDelegateTask({
manager: mockManager,
client: mockClient,
})
const toolContext = {
sessionID: "parent-session",
messageID: "parent-message",
agent: "sisyphus",
abort: new AbortController().signal,
}
// when
const result = await tool.execute(
{
description: "Sync accepted EOF test",
prompt: "Do something",
category: "ultrabrain",
run_in_background: false,
load_skills: ["git-master"],
},
toolContext
)
// then
expect(result).toContain("Accepted despite EOF")
expect(result).toContain("Task completed")
expect(promptCalls).toBe(1)
}, { timeout: 20000 })
test("sync mode success returns task result with content", async () => {
// given
const { createDelegateTask } = require("./tools")
@@ -6,6 +6,7 @@ import { MULTIMODAL_LOOKER_AGENT } from "./constants"
import { READ_ENABLED, buildLookAtPrompt } from "./look-at-prompt"
import type { LookAtFilePart } from "./look-at-input-preparer"
import { resolveMultimodalLookerAgentMetadata } from "./multimodal-agent-metadata"
import { pollSessionUntilIdle } from "./session-poller"
interface RunLookAtSessionInput {
ctx: PluginInput
@@ -85,6 +86,10 @@ Original error: ${createResult.error}`
log("[look_at] Prompt error (ignored, will still fetch messages):", promptError)
}
if (typeof ctx.client.session.status === "function") {
await pollSessionUntilIdle(ctx.client, sessionID)
}
log(`[look_at] Fetching messages from session ${sessionID}...`)
const messagesResult = await ctx.client.session.messages({
path: { id: sessionID },
+22 -13
View File
@@ -371,28 +371,36 @@ describe("look-at tool", () => {
expect(result).toBe("result")
expect(syncPrompt).toHaveBeenCalledTimes(1)
expect(asyncPrompt).not.toHaveBeenCalled()
expect(statusFn).not.toHaveBeenCalled()
expect(statusFn).toHaveBeenCalledTimes(1)
})
// given sync prompt throws (JSON parse error even on success)
// when tool is executed
// then catches error gracefully and still fetches messages
test("catches sync prompt errors and still fetches messages", async () => {
test("#given sync prompt returns ambiguous EOF #when look_at runs #then it waits for idle before reading messages", async () => {
// given
const callOrder: string[] = []
const mockClient = {
app: {
agents: async () => ({ data: [] }),
},
session: {
get: async () => ({ data: { directory: "/project" } }),
create: async () => ({ data: { id: "ses_sync_error" } }),
prompt: async () => { throw new Error("JSON parse error") },
create: async () => ({ data: { id: "ses_sync_ambiguous" } }),
prompt: async () => {
callOrder.push("prompt")
throw new Error("JSON Parse error: Unexpected EOF")
},
promptAsync: async () => ({}),
status: async () => ({ data: {} }),
messages: async () => ({
data: [
{ info: { role: "assistant", time: { created: 1 } }, parts: [{ type: "text", text: "result despite error" }] },
],
}),
status: async () => {
callOrder.push("status")
return { data: {} }
},
messages: async () => {
callOrder.push("messages")
return {
data: [
{ info: { role: "assistant", time: { created: 1 } }, parts: [{ type: "text", text: "result despite error" }] },
],
}
},
},
}
@@ -418,6 +426,7 @@ describe("look-at tool", () => {
)
expect(result).toBe("result despite error")
expect(callOrder).toEqual(["prompt", "status", "messages"])
})
// given sync prompt throws and no messages available