fix(prompt-gate): harden internal prompt dispatch

This commit is contained in:
YeonGyu-Kim
2026-05-19 16:02:18 +09:00
parent 6c63372ef9
commit 1492bffd20
49 changed files with 1488 additions and 141 deletions
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
import { releaseAllPromptAsyncReservationsForTesting } from "../shared/prompt-async-gate"
import type { MessageData } from "./types"
let sqliteBackend = false
@@ -34,11 +35,14 @@ interface PromptAsyncInput {
}
}
function createMockClient(messages: MessageData[] = []) {
function createMockClient(
messages: MessageData[] = [],
promptAsyncImpl?: (input: PromptAsyncInput) => Promise<unknown>,
) {
const promptAsyncCalls: PromptAsyncInput[] = []
const promptAsync = mock((input: PromptAsyncInput) => {
promptAsyncCalls.push(input)
return Promise.resolve({})
return promptAsyncImpl ? promptAsyncImpl(input) : Promise.resolve({})
})
return {
@@ -69,6 +73,7 @@ describe("recoverToolResultMissing", () => {
afterEach(() => {
mock.restore()
releaseAllPromptAsyncReservationsForTesting()
})
it("returns false for sqlite fallback when tool part has no valid callID", async () => {
@@ -286,6 +291,27 @@ describe("recoverToolResultMissing", () => {
expect(call.body).not.toHaveProperty("model")
expect(call.body).not.toHaveProperty("variant")
})
it("#given recovered tool result may have been accepted before EOF #when promptAsync fails ambiguously #then recovery is treated as started", async () => {
// given
storedParts = [{
type: "tool",
id: "prt_stored_eof_call",
callID: "toolu_eof",
tool: "bash",
state: { input: {} },
}]
const { client, promptAsync } = createMockClient([], async () => {
throw new Error("JSON Parse error: Unexpected EOF")
})
// when
const result = await recoverToolResultMissing(client, "ses_eof_recovery", failedAssistantMsg)
// then
expect(result).toBe(true)
expect(promptAsync).toHaveBeenCalledTimes(1)
})
})
export {}
@@ -2,8 +2,8 @@ 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 { normalizeSDKResponse } from "../../shared"
import { dispatchInternalPrompt } from "../shared/prompt-async-gate"
import { isAmbiguousPromptDispatchFailure, normalizeSDKResponse } from "../../shared"
import { dispatchInternalPrompt, isInternalPromptDispatchAccepted } from "../shared/prompt-async-gate"
type Client = ReturnType<typeof createOpencodeClient>
type ToolResultContent = { type: "text"; text: string }
@@ -176,9 +176,13 @@ export async function recoverToolResultMissing(
source: options?.source ?? "session-recovery-tool-result-missing",
input: promptInput,
checkToolState: false,
queueBehavior: "defer",
})
return promptResult.status === "dispatched"
if (promptResult.status === "failed" && isAmbiguousPromptDispatchFailure(promptResult.error)) {
return true
}
return isInternalPromptDispatchAccepted(promptResult)
} catch {
return false
}
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
import { releaseAllPromptAsyncReservationsForTesting } from "../shared/prompt-async-gate"
import type { MessageData } from "./types"
let sqliteBackend = false
@@ -24,8 +25,8 @@ const failedAssistantMsg: MessageData = {
parts: [],
}
function createMockClient(messages: MessageData[] = []) {
const promptAsync = mock(() => Promise.resolve({}))
function createMockClient(messages: MessageData[] = [], promptAsyncImpl?: () => Promise<unknown>) {
const promptAsync = mock(() => promptAsyncImpl ? promptAsyncImpl() : Promise.resolve({}))
return {
client: {
@@ -46,6 +47,7 @@ describe("recoverUnavailableTool", () => {
afterEach(() => {
mock.restore()
releaseAllPromptAsyncReservationsForTesting()
})
it("sends a schema-compatible recovered tool result for sqlite fallback", async () => {
@@ -109,4 +111,22 @@ describe("recoverUnavailableTool", () => {
},
})
})
it("#given unavailable-tool recovery may have been accepted before EOF #when promptAsync fails ambiguously #then recovery is treated as started", async () => {
//#given
const failedAssistantWithToolUse: MessageData = {
info: { id: "msg_failed_eof", role: "assistant", error: "No such tool: bash" },
parts: [{ type: "tool_use", id: "toolu_eof", name: "bash" }],
}
const { client, promptAsync } = createMockClient([], async () => {
throw new Error("JSON Parse error: Unexpected EOF")
})
//#when
const result = await recoverUnavailableTool(client, "ses_unavailable_eof", failedAssistantWithToolUse)
//#then
expect(result).toBe(true)
expect(promptAsync).toHaveBeenCalledTimes(1)
})
})
@@ -2,9 +2,9 @@ import type { createOpencodeClient } from "@opencode-ai/sdk"
import { extractUnavailableToolName } from "./detect-error-type"
import { readParts } from "./storage"
import type { MessageData } from "./types"
import { normalizeSDKResponse } from "../../shared"
import { isAmbiguousPromptDispatchFailure, normalizeSDKResponse } from "../../shared"
import { isSqliteBackend } from "../../shared/opencode-storage-detection"
import { dispatchInternalPrompt } from "../shared/prompt-async-gate"
import { dispatchInternalPrompt, isInternalPromptDispatchAccepted } from "../shared/prompt-async-gate"
type Client = ReturnType<typeof createOpencodeClient>
@@ -126,9 +126,13 @@ export async function recoverUnavailableTool(
client,
sessionID,
source: "session-recovery-unavailable-tool",
queueBehavior: "defer",
input: promptInput,
})
return promptResult.status === "dispatched"
if (promptResult.status === "failed" && isAmbiguousPromptDispatchFailure(promptResult.error)) {
return true
}
return isInternalPromptDispatchAccepted(promptResult)
} catch {
return false
}
+29 -1
View File
@@ -1,11 +1,16 @@
declare const require: (name: string) => any
const { describe, expect, test } = require("bun:test")
const { afterEach, describe, expect, test } = require("bun:test")
import { OMO_INTERNAL_INITIATOR_MARKER } from "../../shared/internal-initiator-marker"
import { releaseAllPromptAsyncReservationsForTesting } from "../shared/prompt-async-gate"
import { extractResumeConfig, findLastUserMessage, resumeSession } from "./resume"
import type { MessageData } from "./types"
describe("session-recovery resume", () => {
afterEach(() => {
releaseAllPromptAsyncReservationsForTesting()
})
test("findLastUserMessage skips synthetic and internally marked user messages", () => {
// given
const realUserMessage: MessageData = {
@@ -123,4 +128,27 @@ describe("session-recovery resume", () => {
expect(firstPart?.metadata?.compaction_continue).toBe(true)
expect(promptBody?.noReply).toBeUndefined()
})
test("#given recovery resume may have been accepted before EOF #when promptAsync fails ambiguously #then resume is treated as started", async () => {
// given
let promptCalls = 0
const client = {
session: {
promptAsync: async () => {
promptCalls += 1
throw new Error("JSON Parse error: Unexpected EOF")
},
},
}
// when
const ok = await resumeSession(client as never, {
sessionID: "ses_resume_eof",
agent: "Hephaestus",
})
// then
expect(ok).toBe(true)
expect(promptCalls).toBe(1)
})
})
+7 -2
View File
@@ -1,10 +1,11 @@
import type { createOpencodeClient } from "@opencode-ai/sdk"
import {
createInternalAgentContinuationTextPart,
isAmbiguousPromptDispatchFailure,
isRealUserMessage,
resolveInheritedPromptTools,
} from "../../shared"
import { dispatchInternalPrompt } from "../shared/prompt-async-gate"
import { dispatchInternalPrompt, isInternalPromptDispatchAccepted } from "../shared/prompt-async-gate"
import type { MessageData, ResumeConfig } from "./types"
const RECOVERY_RESUME_TEXT = "[session recovered - continuing previous task]"
@@ -43,6 +44,7 @@ export async function resumeSession(client: Client, config: ResumeConfig): Promi
client,
sessionID: config.sessionID,
source: "session-recovery",
queueBehavior: "defer",
input: {
path: { id: config.sessionID },
body: {
@@ -54,7 +56,10 @@ export async function resumeSession(client: Client, config: ResumeConfig): Promi
},
},
})
return promptResult.status === "dispatched"
if (promptResult.status === "failed" && isAmbiguousPromptDispatchFailure(promptResult.error)) {
return true
}
return isInternalPromptDispatchAccepted(promptResult)
} catch {
return false
}