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
View File
@@ -73,6 +73,7 @@ export * from "./record-type-guard"
export * from "./session-directory-resolver"
export * from "./session-route"
export * from "./prompt-tools"
export * from "./prompt-failure-classifier"
export * from "./compaction-marker"
export * from "./internal-initiator-marker"
export * from "./plugin-command-discovery"
+10 -14
View File
@@ -236,7 +236,7 @@ describe("promptWithModelSuggestionRetry", () => {
expect(promptMock).toHaveBeenCalledTimes(1)
})
it("should reject concurrent promptAsync retries for the same session after one dispatch is reserved", async () => {
it("should coalesce concurrent promptAsync retries for the same session after one dispatch is reserved", async () => {
// given two callers racing to send into one session
let releasePrompt: (() => void) | undefined
const promptGate = new Promise<void>((resolve) => {
@@ -269,10 +269,10 @@ describe("promptWithModelSuggestionRetry", () => {
// then only the reserved dispatch is sent to OpenCode
expect(promptMock).toHaveBeenCalledTimes(1)
expect(results[0]?.status).toBe("fulfilled")
expect(results[1]?.status).toBe("rejected")
expect(results[1]?.status).toBe("fulfilled")
})
it("#given promptAsync retry just dispatched #when the same session is prompted again immediately #then the second caller is rejected by the gate", async () => {
it("#given promptAsync retry just dispatched #when the same session is prompted again immediately #then the second caller is coalesced by the queue", async () => {
// given
const promptMock = mock(async () => undefined)
const client = {
@@ -290,14 +290,13 @@ describe("promptWithModelSuggestionRetry", () => {
// when
await promptWithModelSuggestionRetry(unsafeTestValue(client), args)
const second = promptWithModelSuggestionRetry(unsafeTestValue(client), args)
await promptWithModelSuggestionRetry(unsafeTestValue(client), args)
// then
await expect(second).rejects.toThrow("promptAsync skipped by gate: reserved")
expect(promptMock).toHaveBeenCalledTimes(1)
})
it("#given same-source retry observes a peer reservation #when it rejects #then the peer hold remains reserved", async () => {
it("#given same-source retry observes a peer reservation #when it coalesces #then a different prompt remains queued behind the hold", async () => {
// given
const promptMock = mock(async () => undefined)
const client = {
@@ -315,9 +314,7 @@ describe("promptWithModelSuggestionRetry", () => {
// when
await promptWithModelSuggestionRetry(unsafeTestValue(client), args)
await expect(
promptWithModelSuggestionRetry(unsafeTestValue(client), args)
).rejects.toThrow("promptAsync skipped by gate: reserved")
await promptWithModelSuggestionRetry(unsafeTestValue(client), args)
const third = await dispatchInternalPrompt({
mode: "async",
client,
@@ -329,7 +326,7 @@ describe("promptWithModelSuggestionRetry", () => {
})
// then
expect(third).toEqual({ status: "reserved", reservedBy: "model-suggestion-retry" })
expect(third).toEqual({ status: "queued", queuedBy: "model-suggestion-retry", position: 1 })
expect(promptMock).toHaveBeenCalledTimes(1)
})
@@ -432,7 +429,7 @@ describe("promptWithModelSuggestionRetry", () => {
})
// then
expect(second).toEqual({ status: "reserved", reservedBy: "model-suggestion-retry" })
expect(second).toEqual({ status: "queued", queuedBy: "model-suggestion-retry", position: 1 })
expect(promptMock).toHaveBeenCalledTimes(1)
})
@@ -564,7 +561,7 @@ describe("promptSyncWithModelSuggestionRetry", () => {
expect(promptAsyncMock).toHaveBeenCalledTimes(0)
})
it("#given sync prompt retry just dispatched #when the same session is prompted again immediately #then the second caller is rejected by the gate", async () => {
it("#given sync prompt retry just dispatched #when the same session is prompted again immediately #then the second caller is coalesced by the queue", async () => {
// given
const promptMock = mock(async () => undefined)
const client = {
@@ -582,10 +579,9 @@ describe("promptSyncWithModelSuggestionRetry", () => {
// when
await promptSyncWithModelSuggestionRetry(unsafeTestValue(client), args)
const second = promptSyncWithModelSuggestionRetry(unsafeTestValue(client), args)
await promptSyncWithModelSuggestionRetry(unsafeTestValue(client), args)
// then
await expect(second).rejects.toThrow("prompt skipped by gate: reserved")
expect(promptMock).toHaveBeenCalledTimes(1)
})
+7 -3
View File
@@ -7,6 +7,7 @@ import {
} from "./prompt-timeout-context"
import {
dispatchInternalPrompt,
isInternalPromptDispatchAccepted,
releasePromptAsyncReservation,
} from "./prompt-async-gate"
@@ -118,11 +119,12 @@ export async function promptWithModelSuggestionRetry(
} as Parameters<typeof client.session.promptAsync>[0],
source: "model-suggestion-retry",
settleMs: 0,
...(options.queueBehavior ? { queueBehavior: options.queueBehavior } : {}),
})
if (promptResult.status === "failed") {
throw promptResult.error
}
if (promptResult.status !== "dispatched") {
if (!isInternalPromptDispatchAccepted(promptResult)) {
throw new Error(`promptAsync skipped by gate: ${promptResult.status}`)
}
if (timeoutContext.wasTimedOut()) {
@@ -162,11 +164,12 @@ export async function promptSyncWithModelSuggestionRetry(
source: "model-suggestion-retry:sync",
settleMs: 0,
checkStatus: false,
...(options.queueBehavior ? { queueBehavior: options.queueBehavior } : {}),
})
if (promptResult.status === "failed") {
throw promptResult.error
}
if (promptResult.status !== "dispatched") {
if (!isInternalPromptDispatchAccepted(promptResult)) {
throw new Error(`prompt skipped by gate: ${promptResult.status}`)
}
if (timeoutContext.wasTimedOut()) {
@@ -220,11 +223,12 @@ export async function promptSyncWithModelSuggestionRetry(
source: "model-suggestion-retry:sync-retry",
settleMs: 0,
checkStatus: false,
...(options.queueBehavior ? { queueBehavior: options.queueBehavior } : {}),
})
if (promptResult.status === "failed") {
throw promptResult.error
}
if (promptResult.status !== "dispatched") {
if (!isInternalPromptDispatchAccepted(promptResult)) {
throw new Error(`prompt skipped by gate: ${promptResult.status}`)
}
if (timeoutContext.wasTimedOut()) {
+319
View File
@@ -13,6 +13,7 @@ import {
export const DEFAULT_PROMPT_ASYNC_POST_DISPATCH_HOLD_MS = 250
export const DEFAULT_PROMPT_DISPATCH_TIMEOUT_MS = 30_000
export const DEFAULT_PROMPT_GATE_MESSAGES_FETCH_TIMEOUT_MS = 5_000
export const DEFAULT_PROMPT_QUEUE_RETRY_MS = 250
type PromptAsyncInput = {
path?: { id?: string }
@@ -44,11 +45,16 @@ type PromptClient<TInput> = {
}
export type InternalPromptDispatchMode = "async" | "sync"
export type InternalPromptQueueBehavior = "enqueue" | "defer"
type InternalPromptDispatchCommonArgs<TInput> = {
sessionID: string
input: TInput
source: string
dedupeKey?: string
queueBehavior?: InternalPromptQueueBehavior
queue?: boolean
queueRetryMs?: number
settleMs?: number
postDispatchHoldMs?: number
dispatchTimeoutMs?: number
@@ -63,6 +69,7 @@ export type InternalPromptDispatchArgs<TInput = PromptAsyncInput> = InternalProm
type PromptAsyncReservation = {
source: string
dedupeKey: string
reservedAt: number
token: symbol
expiresAt?: number
@@ -75,6 +82,7 @@ let promptGateMessagesFetchTimeoutMsForTesting: number | undefined
export type InternalPromptDispatchResult =
| { status: "dispatched"; response: unknown }
| { status: "queued"; queuedBy: string; position: number }
| { status: "active" }
| { status: "reserved"; reservedBy: string }
| { status: "unavailable" }
@@ -88,6 +96,35 @@ type PromptAsyncReservationReleaseOptions = {
}
const promptAsyncReservations = new Map<string, PromptAsyncReservation>()
const promptQueues = new Map<string, QueuedInternalPrompt[]>()
const promptQueueDraining = new Set<string>()
const promptQueueInFlight = new Map<string, QueuedInternalPrompt>()
const promptQueueTimers = new Map<string, unknown>()
let promptQueueSequence = 0
type PromptDispatchClient = {
session?: {
status?: () => Promise<unknown>
messages?: (input: { path: { id: string }; query: PromptMessagesQuery }) => Promise<unknown>
}
}
type QueuedInternalPrompt = {
id: number
sessionID: string
sessionName: "promptAsync" | "prompt"
client: PromptDispatchClient
input: unknown
source: string
dedupeKey: string
settleMs: number
postDispatchHoldMs: number
dispatchTimeoutMs: number
queueRetryMs: number
checkStatus: boolean
checkToolState: boolean
dispatch: (input: unknown) => Promise<unknown>
}
export function _setPromptGateMessagesFetchTimeoutMsForTesting(value: number | undefined): void {
promptGateMessagesFetchTimeoutMsForTesting = value
@@ -98,15 +135,20 @@ function getPromptGateMessagesFetchTimeoutMs(): number {
}
function pruneExpiredReservations(now = Date.now()): void {
const expiredSessionIDs: string[] = []
for (const [sessionID, reservation] of promptAsyncReservations) {
if (typeof reservation.expiresAt === "number" && reservation.expiresAt <= now) {
promptAsyncReservations.delete(sessionID)
expiredSessionIDs.push(sessionID)
log("[prompt-async-gate] expired reservation released", {
sessionID,
source: reservation.source,
})
}
}
for (const sessionID of expiredSessionIDs) {
schedulePromptQueueDrain(sessionID, 0)
}
}
function getActiveReservation(sessionID: string): PromptAsyncReservation | undefined {
@@ -114,6 +156,102 @@ function getActiveReservation(sessionID: string): PromptAsyncReservation | undef
return promptAsyncReservations.get(sessionID)
}
function getPromptQueue(sessionID: string): QueuedInternalPrompt[] {
const existing = promptQueues.get(sessionID)
if (existing) {
return existing
}
const queue: QueuedInternalPrompt[] = []
promptQueues.set(sessionID, queue)
return queue
}
function setPromptQueue(sessionID: string, queue: QueuedInternalPrompt[]): void {
if (queue.length === 0) {
promptQueues.delete(sessionID)
return
}
promptQueues.set(sessionID, queue)
}
function stringifyPromptInputForDedupe(input: unknown): string {
try {
const serialized = JSON.stringify(input, (key: string, value: unknown): unknown => {
if (key === "signal") {
return "[AbortSignal]"
}
if (typeof value === "function") {
return `[Function:${value.name}]`
}
return value
})
return serialized ?? String(input)
} catch {
return String(input)
}
}
function createDefaultDedupeKey(source: string, input: unknown): string {
const fingerprint = stringifyPromptInputForDedupe(input)
return `${source}:${fingerprint.length}:${fingerprint.slice(0, 8192)}`
}
function queuedResult(entry: QueuedInternalPrompt, position: number, queuedBy = entry.source): InternalPromptDispatchResult {
return {
status: "queued",
queuedBy,
position,
}
}
function clearPromptQueueTimer(sessionID: string): void {
const timer = promptQueueTimers.get(sessionID)
if (timer !== undefined) {
clearTimeout(timer)
promptQueueTimers.delete(sessionID)
}
}
function schedulePromptQueueDrain(sessionID: string, delayMs: number): void {
const queue = promptQueues.get(sessionID)
if (!queue || queue.length === 0) {
clearPromptQueueTimer(sessionID)
return
}
clearPromptQueueTimer(sessionID)
const timer = setTimeout(() => {
promptQueueTimers.delete(sessionID)
void drainPromptQueue(sessionID).catch((error: unknown) => {
log("[prompt-async-gate] queued prompt drain failed", {
sessionID,
error: String(error),
})
})
}, Math.max(0, delayMs))
promptQueueTimers.set(sessionID, timer)
}
function removePromptQueueEntry(sessionID: string, entry: QueuedInternalPrompt): void {
const queue = promptQueues.get(sessionID)
if (!queue) {
return
}
const nextQueue = queue.filter((queued) => queued.id !== entry.id)
setPromptQueue(sessionID, nextQueue)
}
function getQueuedPromptBlocker(sessionID: string): string | undefined {
const inFlight = promptQueueInFlight.get(sessionID)
if (inFlight) {
return inFlight.source
}
const queue = promptQueues.get(sessionID)
return queue?.[0]?.source
}
function reservationSourceMatches(
reservationSource: string,
expectedSource: string | readonly string[],
@@ -341,6 +479,7 @@ async function dispatchAfterSessionIdle<TInput>(args: {
sessionID: string
input: TInput
source: string
dedupeKey: string
settleMs: number
postDispatchHoldMs: number
dispatchTimeoutMs: number
@@ -354,6 +493,7 @@ async function dispatchAfterSessionIdle<TInput>(args: {
sessionID,
input,
source,
dedupeKey,
settleMs,
postDispatchHoldMs,
dispatchTimeoutMs,
@@ -375,6 +515,7 @@ async function dispatchAfterSessionIdle<TInput>(args: {
const reservation: PromptAsyncReservation = {
source,
dedupeKey,
reservedAt: Date.now(),
token: Symbol(source),
}
@@ -447,6 +588,117 @@ async function dispatchAfterSessionIdle<TInput>(args: {
}
}
async function drainPromptQueue(sessionID: string, awaitedEntry?: QueuedInternalPrompt): Promise<InternalPromptDispatchResult | undefined> {
if (promptQueueDraining.has(sessionID)) {
return awaitedEntry ? queuedResult(awaitedEntry, 1) : undefined
}
promptQueueDraining.add(sessionID)
clearPromptQueueTimer(sessionID)
let awaitedResult: InternalPromptDispatchResult | undefined
try {
while (true) {
const queue = promptQueues.get(sessionID)
const entry = queue?.[0]
if (!entry) {
break
}
promptQueueInFlight.set(sessionID, entry)
const result = await dispatchAfterSessionIdle({
sessionName: entry.sessionName,
client: entry.client,
sessionID: entry.sessionID,
input: entry.input,
source: entry.source,
dedupeKey: entry.dedupeKey,
settleMs: entry.settleMs,
postDispatchHoldMs: entry.postDispatchHoldMs,
dispatchTimeoutMs: entry.dispatchTimeoutMs,
checkStatus: entry.checkStatus,
checkToolState: entry.checkToolState,
dispatch: entry.dispatch,
})
if (promptQueueInFlight.get(sessionID)?.id === entry.id) {
promptQueueInFlight.delete(sessionID)
}
if (result.status === "active" || result.status === "reserved") {
const queued = queuedResult(
entry,
1,
result.status === "reserved" ? result.reservedBy : entry.source,
)
if (awaitedEntry?.id === entry.id) {
awaitedResult = queued
}
schedulePromptQueueDrain(sessionID, entry.queueRetryMs)
break
}
removePromptQueueEntry(sessionID, entry)
if (awaitedEntry?.id === entry.id) {
awaitedResult = result
}
const remainingQueue = promptQueues.get(sessionID)
if (!remainingQueue || remainingQueue.length === 0) {
break
}
schedulePromptQueueDrain(sessionID, entry.postDispatchHoldMs)
break
}
} finally {
promptQueueDraining.delete(sessionID)
}
return awaitedResult
}
async function enqueueInternalPrompt(entry: QueuedInternalPrompt): Promise<InternalPromptDispatchResult> {
const activeReservation = getActiveReservation(entry.sessionID)
if (activeReservation?.dedupeKey === entry.dedupeKey) {
log("[prompt-async-gate] queued prompt coalesced with recent dispatch", {
sessionID: entry.sessionID,
source: entry.source,
queuedBy: activeReservation.source,
})
return queuedResult(entry, 0, activeReservation.source)
}
const queue = getPromptQueue(entry.sessionID)
const existingIndex = queue.findIndex((queued) => queued.dedupeKey === entry.dedupeKey)
if (existingIndex >= 0) {
const existing = queue[existingIndex]
if (existing) {
log("[prompt-async-gate] queued prompt coalesced with pending dispatch", {
sessionID: entry.sessionID,
source: entry.source,
queuedBy: existing.source,
position: existingIndex + 1,
})
return queuedResult(existing, existingIndex + 1)
}
}
queue.push(entry)
log("[prompt-async-gate] queued prompt accepted", {
sessionID: entry.sessionID,
source: entry.source,
position: queue.length,
})
if (queue.length > 1 || promptQueueDraining.has(entry.sessionID)) {
schedulePromptQueueDrain(entry.sessionID, 0)
return queuedResult(entry, queue.length)
}
const result = await drainPromptQueue(entry.sessionID, entry)
return result ?? queuedResult(entry, 1)
}
export async function dispatchInternalPrompt<TInput = PromptAsyncInput>(
args: InternalPromptDispatchArgs<TInput>,
): Promise<InternalPromptDispatchResult> {
@@ -457,6 +709,8 @@ export async function dispatchInternalPrompt<TInput = PromptAsyncInput>(
source,
settleMs = DEFAULT_SESSION_IDLE_SETTLE_MS,
} = args
const dedupeKey = args.dedupeKey ?? createDefaultDedupeKey(source, input)
const queueRetryMs = args.queueRetryMs ?? DEFAULT_PROMPT_QUEUE_RETRY_MS
const postDispatchHoldMs = args.postDispatchHoldMs ?? DEFAULT_PROMPT_ASYNC_POST_DISPATCH_HOLD_MS
const dispatchTimeoutMs = args.dispatchTimeoutMs ?? DEFAULT_PROMPT_DISPATCH_TIMEOUT_MS
const sessionName = args.mode === "async" ? "promptAsync" : "prompt"
@@ -483,12 +737,59 @@ export async function dispatchInternalPrompt<TInput = PromptAsyncInput>(
return { status: "unavailable" }
}
if (args.queueBehavior === "defer") {
const activeReservation = getActiveReservation(sessionID)
if (activeReservation) {
return { status: "reserved", reservedBy: activeReservation.source }
}
const queuedBy = getQueuedPromptBlocker(sessionID)
if (queuedBy !== undefined || promptQueueDraining.has(sessionID)) {
return { status: "reserved", reservedBy: queuedBy ?? source }
}
return dispatchAfterSessionIdle({
sessionName,
client,
sessionID,
input,
source,
dedupeKey,
settleMs,
postDispatchHoldMs,
dispatchTimeoutMs,
checkStatus: args.checkStatus !== false,
checkToolState: args.checkToolState !== false,
dispatch,
})
}
if (args.queue !== false) {
return enqueueInternalPrompt({
id: promptQueueSequence += 1,
sessionID,
sessionName,
client,
input,
source,
dedupeKey,
settleMs,
postDispatchHoldMs,
dispatchTimeoutMs,
queueRetryMs,
checkStatus: args.checkStatus !== false,
checkToolState: args.checkToolState !== false,
dispatch: dispatch as (dispatchInput: unknown) => Promise<unknown>,
})
}
return dispatchAfterSessionIdle({
sessionName,
client,
sessionID,
input,
source,
dedupeKey,
settleMs,
postDispatchHoldMs,
dispatchTimeoutMs,
@@ -500,9 +801,20 @@ export async function dispatchInternalPrompt<TInput = PromptAsyncInput>(
export function releaseAllPromptAsyncReservationsForTesting(): void {
promptAsyncReservations.clear()
promptQueues.clear()
promptQueueDraining.clear()
promptQueueInFlight.clear()
for (const timer of promptQueueTimers.values()) {
clearTimeout(timer)
}
promptQueueTimers.clear()
promptGateMessagesFetchTimeoutMsForTesting = undefined
}
export function isInternalPromptDispatchAccepted(result: InternalPromptDispatchResult): boolean {
return result.status === "dispatched" || result.status === "queued"
}
export function releasePromptAsyncReservation(
sessionID: string,
source: string,
@@ -524,6 +836,13 @@ export function releasePromptAsyncReservation(
}
promptAsyncReservations.delete(sessionID)
const inFlight = promptQueueInFlight.get(sessionID)
if (inFlight?.dedupeKey === existing.dedupeKey) {
removePromptQueueEntry(sessionID, inFlight)
promptQueueInFlight.delete(sessionID)
promptQueueDraining.delete(sessionID)
}
schedulePromptQueueDrain(sessionID, 0)
log("[prompt-async-gate] promptAsync reservation released", {
sessionID,
source,
+18 -1
View File
@@ -16,7 +16,7 @@ const RAW_PROMPT_ALLOWLIST = new Map<string, string>([
],
[
path.join(SOURCE_ROOT, "plugin", "unstable-agent-babysitter.ts"),
"binds SDK Session.prompt/.promptAsync into a narrow facade consumed only by gate-routed unstable-agent-babysitter dispatch; performs no direct dispatch itself",
"binds SDK Session.promptAsync into a narrow facade consumed only by gate-routed unstable-agent-babysitter dispatch; performs no direct dispatch itself",
],
[
path.join(SOURCE_ROOT, "hooks", "session-recovery", "recover-unavailable-tool.ts"),
@@ -287,4 +287,21 @@ describe("production prompt injection routes", () => {
// then
expect(offenders).toEqual([])
})
test("#given production TypeScript sources #when prompt gate callers are audited #then callers cannot bypass the central prompt queue", async () => {
// given
const files = await listSourceFiles(SOURCE_ROOT)
const offenders: string[] = []
// when
for (const filePath of files) {
const contents = await readFile(filePath, "utf8")
if (/queue\s*:\s*false\b/.test(contents)) {
offenders.push(relativeSourcePath(filePath))
}
}
// then
expect(offenders).toEqual([])
})
})
@@ -0,0 +1,27 @@
import { describe, expect, test } from "bun:test"
import { 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", () => {
// given
const error = new Error("JSON Parse error: Unexpected end of JSON input")
// when
const ambiguous = isAmbiguousPromptDispatchFailure(error)
// then
expect(ambiguous).toBe(true)
})
test("#given prompt dispatch timeout casing varies #when classifying ambiguity #then it treats the dispatch as possibly accepted", () => {
// given
const error = "PromptAsync Timed Out after 30000ms"
// when
const ambiguous = isAmbiguousPromptDispatchFailure(error)
// then
expect(ambiguous).toBe(true)
})
})
+24
View File
@@ -0,0 +1,24 @@
export function extractPromptFailureMessage(error: unknown): string {
if (typeof error === "string") return error
if (error instanceof Error) return error.message
if (typeof error === "object" && error !== null) {
const record = error as Record<string, unknown>
if (typeof record.message === "string") return record.message
try {
return JSON.stringify(error)
} catch {
return ""
}
}
return String(error)
}
export function isAmbiguousPromptDispatchFailure(error: unknown): boolean {
const message = extractPromptFailureMessage(error).toLowerCase()
return (
message.includes("unexpected eof")
|| message.includes("json parse error")
|| message.includes("unexpected end of json input")
|| message.includes("timed out")
)
}
+1
View File
@@ -4,6 +4,7 @@ export interface PromptTimeoutArgs {
export interface PromptRetryOptions {
timeoutMs?: number
queueBehavior?: "enqueue" | "defer"
}
export const PROMPT_TIMEOUT_MS = 120000
+3 -3
View File
@@ -27,7 +27,7 @@ describe("promptAsyncInDirectory", () => {
expect(promptAsync).toHaveBeenCalledTimes(0)
})
test("#given a routed prompt just dispatched #when the same session is prompted again immediately #then the route keeps the session reserved", async () => {
test("#given a routed prompt just dispatched #when the same session is prompted again immediately #then the route coalesces the duplicate", async () => {
// given
const promptAsync = mock(async () => ({ data: "sent" }))
const client = {
@@ -46,7 +46,7 @@ describe("promptAsyncInDirectory", () => {
unsafeTestValue(args),
"/workspace/project",
)
const second = promptAsyncInDirectory(
const second = await promptAsyncInDirectory(
unsafeTestValue(client),
unsafeTestValue(args),
"/workspace/project",
@@ -54,7 +54,7 @@ describe("promptAsyncInDirectory", () => {
// then
expect(first).toEqual({ data: "sent" })
await expect(second).rejects.toThrow("promptAsync skipped by gate: reserved")
expect(second).toBeUndefined()
expect(promptAsync).toHaveBeenCalledTimes(1)
expect(promptAsync.mock.calls[0]?.[0].query).toEqual({ directory: "/workspace/project" })
})
+3 -3
View File
@@ -3,7 +3,7 @@ import {
promptSyncWithModelSuggestionRetry,
promptWithModelSuggestionRetry,
} from "./model-suggestion-retry"
import { dispatchInternalPrompt } from "./prompt-async-gate"
import { dispatchInternalPrompt, isInternalPromptDispatchAccepted } from "./prompt-async-gate"
type OpencodeClient = PluginInput["client"]
@@ -70,10 +70,10 @@ export function promptAsyncInDirectory(
if (result.status === "failed") {
throw result.error
}
if (result.status !== "dispatched") {
if (!isInternalPromptDispatchAccepted(result)) {
throw new Error(`promptAsync skipped by gate: ${result.status}`)
}
return result.response
return result.status === "dispatched" ? result.response : undefined
})
}