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
@@ -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)) {