fix(prompt-gate): harden internal prompt dispatch
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user