fix(prompt-gate): harden sync and team prompt dispatch

This commit is contained in:
YeonGyu-Kim
2026-05-19 17:43:37 +09:00
parent 1492bffd20
commit bcea4a9d28
44 changed files with 688 additions and 140 deletions
@@ -69,6 +69,7 @@ describe("delegate-task Oracle gap closure", () => {
client: {
session: {
messages: async () => ({ data: [{ info: { agent: "explore", model: MODEL, variant: "max" } }] }),
prompt: async () => ({}),
promptAsync: async () => ({}),
},
},
@@ -99,6 +100,7 @@ describe("delegate-task Oracle gap closure", () => {
client: {
session: {
messages: async () => ({ data: [{ info: { agent: "explore", model: MODEL } }] }),
prompt: async () => ({}),
promptAsync: async () => ({}),
},
},
@@ -190,6 +192,10 @@ describe("delegate-task Oracle gap closure", () => {
client: {
session: {
messages: async () => ({ data: [{ info: { agent: "explore", model: MODEL } }] }),
prompt: async (input: { body?: { system?: string } }) => {
promptCalls.push(input)
return {}
},
promptAsync: async (input: { body?: { system?: string } }) => {
promptCalls.push(input)
return {}
@@ -74,6 +74,7 @@ describe("executeSyncContinuation - toast cleanup error paths", () => {
},
],
}),
prompt: async () => ({}),
promptAsync: async () => ({}),
status: async () => ({
data: { ses_test: { type: "idle" } },
@@ -136,6 +137,7 @@ describe("executeSyncContinuation - toast cleanup error paths", () => {
},
],
}),
prompt: async () => ({}),
promptAsync: async () => ({}),
status: async () => ({
data: { ses_test: { type: "idle" } },
@@ -198,6 +200,7 @@ describe("executeSyncContinuation - toast cleanup error paths", () => {
},
],
}),
prompt: async () => ({}),
promptAsync: async () => ({}),
status: async () => ({
data: { ses_test: { type: "idle" } },
@@ -256,6 +259,7 @@ describe("executeSyncContinuation - toast cleanup error paths", () => {
},
],
}),
prompt: async () => ({}),
promptAsync: async () => ({}),
status: async () => ({
data: { ses_test: { type: "idle" } },
@@ -312,6 +316,7 @@ describe("executeSyncContinuation - toast cleanup error paths", () => {
},
],
}),
prompt: async () => ({}),
promptAsync: async () => ({}),
status: async () => ({
data: { ses_test: { type: "idle" } },
@@ -363,6 +368,7 @@ describe("executeSyncContinuation - toast cleanup error paths", () => {
messages: async () => {
throw new Error("messages unavailable")
},
prompt: async () => ({}),
promptAsync: async () => ({}),
status: async () => ({
data: { ses_test: { type: "idle" } },
@@ -429,6 +435,7 @@ describe("executeSyncContinuation - toast cleanup error paths", () => {
},
],
}),
prompt: async () => ({}),
promptAsync: async () => ({}),
status: async () => ({
data: { ses_test: { type: "idle" } },
@@ -487,6 +494,7 @@ describe("executeSyncContinuation - toast cleanup error paths", () => {
},
],
}),
prompt: async () => ({}),
promptAsync: async () => ({}),
status: async () => ({
data: { ses_test: { type: "idle" } },
@@ -550,6 +558,7 @@ describe("executeSyncContinuation - toast cleanup error paths", () => {
},
],
}),
prompt: async () => ({}),
promptAsync: async () => ({}),
status: async () => ({
data: { ses_test: { type: "idle" } },
@@ -610,6 +619,7 @@ describe("executeSyncContinuation - toast cleanup error paths", () => {
},
],
}),
prompt: async () => ({}),
promptAsync: async () => ({}),
status: async () => ({
data: { ses_test: { type: "idle" } },
@@ -664,6 +674,7 @@ describe("executeSyncContinuation - toast cleanup error paths", () => {
},
],
}),
prompt: async () => ({}),
promptAsync: async () => ({}),
status: async () => ({
data: { ses_test: { type: "idle" } },
@@ -725,6 +736,10 @@ describe("executeSyncContinuation - toast cleanup error paths", () => {
},
],
}),
prompt: async (input: { path: { id: string }; body: Record<string, unknown> }) => {
promptAsyncCalls.push(input)
return {}
},
promptAsync: async (input: { path: { id: string }; body: Record<string, unknown> }) => {
promptAsyncCalls.push(input)
return {}
@@ -796,6 +811,10 @@ describe("executeSyncContinuation - toast cleanup error paths", () => {
},
],
}),
prompt: async (input: { path: { id: string }; body: Record<string, unknown> }) => {
promptAsyncCalls.push(input)
return {}
},
promptAsync: async (input: { path: { id: string }; body: Record<string, unknown> }) => {
promptAsyncCalls.push(input)
return {}
@@ -867,6 +886,10 @@ describe("executeSyncContinuation - toast cleanup error paths", () => {
},
],
}),
prompt: async (input: { path: { id: string }; body: Record<string, unknown> }) => {
promptAsyncCalls.push(input)
return {}
},
promptAsync: async (input: { path: { id: string }; body: Record<string, unknown> }) => {
promptAsyncCalls.push(input)
return {}
+2 -2
View File
@@ -5,7 +5,7 @@ import { publishToolMetadata } from "../../features/tool-metadata-store"
import { getTaskToastManager } from "../../features/task-toast-manager"
import { getAgentToolRestrictions } from "../../shared/agent-tool-restrictions"
import { getMessageDir, normalizeSDKResponse } from "../../shared"
import { promptWithModelSuggestionRetry } from "../../shared/model-suggestion-retry"
import { promptSyncWithModelSuggestionRetry } from "../../shared/model-suggestion-retry"
import { resolveMessageContext } from "../../features/hook-message-injector"
import { formatDuration } from "./time-formatter"
import { syncContinuationDeps, type SyncContinuationDeps } from "./sync-continuation-deps"
@@ -160,7 +160,7 @@ export async function executeSyncContinuation(
}
setSessionTools(continuationID, tools)
await promptWithModelSuggestionRetry(client, {
await promptSyncWithModelSuggestionRetry(client, {
path: { id: continuationID },
body: {
...(resumeAgent !== undefined ? { agent: resumeAgent } : {}),
@@ -5,19 +5,16 @@ import type { OpencodeClient } from "./types"
import { sendSyncPrompt } from "./sync-prompt-sender"
import {
promptSyncWithModelSuggestionRetry,
promptWithModelSuggestionRetry,
} from "../../shared/model-suggestion-retry"
type PromptRetryClient = Parameters<typeof promptWithModelSuggestionRetry>[0]
type PromptRetryArgs = Parameters<typeof promptWithModelSuggestionRetry>[1]
type PromptSyncRetryClient = Parameters<typeof promptSyncWithModelSuggestionRetry>[0]
type PromptSyncRetryArgs = Parameters<typeof promptSyncWithModelSuggestionRetry>[1]
describe("sendSyncPrompt session routing", () => {
test("#given a sync child session directory #when sending the prompt #then promptAsync uses that OpenCode directory route", async () => {
test("#given a sync child session directory #when sending the prompt #then prompt uses that OpenCode directory route", async () => {
// given
const promptCalls: PromptRetryArgs[] = []
const promptWithRetry = mock(async (_client: PromptRetryClient, input: PromptRetryArgs) => {
const promptCalls: PromptSyncRetryArgs[] = []
const promptSyncWithRetry = mock(async (_client: PromptSyncRetryClient, input: PromptSyncRetryArgs) => {
promptCalls.push(input)
})
@@ -40,8 +37,7 @@ describe("sendSyncPrompt session routing", () => {
taskId: undefined,
},
{
promptWithModelSuggestionRetry: promptWithRetry,
promptSyncWithModelSuggestionRetry: mock(async () => {}),
promptSyncWithModelSuggestionRetry: promptSyncWithRetry,
},
)
@@ -50,14 +46,12 @@ describe("sendSyncPrompt session routing", () => {
expect(promptCalls[0]?.query).toEqual({ directory: "/parent/project" })
})
test("#given oracle falls back to promptSync #when async prompt returns unexpected EOF #then the sync retry keeps the same directory route", async () => {
test("#given oracle prompt returns unexpected EOF #when sending the prompt #then the sync route keeps the same directory route", async () => {
// given
const promptSyncCalls: PromptSyncRetryArgs[] = []
const promptWithRetry = mock(async () => {
throw new Error("JSON Parse error: Unexpected EOF")
})
const promptSyncWithRetry = mock(async (_client: PromptSyncRetryClient, input: PromptSyncRetryArgs) => {
promptSyncCalls.push(input)
throw new Error("JSON Parse error: Unexpected EOF")
})
// when
@@ -79,7 +73,6 @@ describe("sendSyncPrompt session routing", () => {
taskId: undefined,
},
{
promptWithModelSuggestionRetry: promptWithRetry,
promptSyncWithModelSuggestionRetry: promptSyncWithRetry,
},
)
@@ -28,6 +28,7 @@ bunDescribe("sendSyncPrompt", () => {
const mockClient = {
session: {
prompt: promptAsync,
promptAsync,
},
}
@@ -67,6 +68,7 @@ bunDescribe("sendSyncPrompt", () => {
const mockClient = {
session: {
prompt: promptAsync,
promptAsync,
},
}
@@ -107,6 +109,7 @@ bunDescribe("sendSyncPrompt", () => {
const mockClient = {
session: {
prompt: promptAsync,
promptAsync,
},
}
@@ -147,6 +150,7 @@ bunDescribe("sendSyncPrompt", () => {
const mockClient = {
session: {
prompt: promptAsync,
promptAsync,
},
}
@@ -187,6 +191,7 @@ bunDescribe("sendSyncPrompt", () => {
const mockClient = {
session: {
prompt: promptAsync,
promptAsync,
},
}
@@ -229,7 +234,7 @@ bunDescribe("sendSyncPrompt", () => {
const { sendSyncPrompt } = require("./sync-prompt-sender")
let promptArgs: any
const promptWithModelSuggestionRetry = bunMock(async (_client: any, input: any) => {
const promptSyncWithModelSuggestionRetry = bunMock(async (_client: any, input: any) => {
promptArgs = input
})
@@ -259,16 +264,15 @@ bunDescribe("sendSyncPrompt", () => {
//#when
await sendSyncPrompt(
{ session: { promptAsync: bunMock(async () => ({ data: {} })) } },
{ session: { prompt: bunMock(async () => ({ data: {} })) } },
input,
{
promptWithModelSuggestionRetry,
promptSyncWithModelSuggestionRetry: bunMock(async () => {}),
promptSyncWithModelSuggestionRetry,
},
)
//#then
bunExpect(promptWithModelSuggestionRetry).toHaveBeenCalledTimes(1)
bunExpect(promptSyncWithModelSuggestionRetry).toHaveBeenCalledTimes(1)
bunExpect(promptArgs.body.model).toEqual({
providerID: "openai",
modelID: "gpt-5.4",
@@ -295,7 +299,7 @@ bunDescribe("sendSyncPrompt", () => {
const { sendSyncPrompt } = require("./sync-prompt-sender")
let promptArgs: any
const promptWithModelSuggestionRetry = bunMock(async (_client: any, input: any) => {
const promptSyncWithModelSuggestionRetry = bunMock(async (_client: any, input: any) => {
promptArgs = input
})
@@ -321,26 +325,24 @@ bunDescribe("sendSyncPrompt", () => {
//#when
await sendSyncPrompt(
{ session: { promptAsync: bunMock(async () => ({ data: {} })) } },
{ session: { prompt: bunMock(async () => ({ data: {} })) } },
input,
{
promptWithModelSuggestionRetry,
promptSyncWithModelSuggestionRetry: bunMock(async () => {}),
promptSyncWithModelSuggestionRetry,
},
)
//#then
bunExpect(promptWithModelSuggestionRetry).toHaveBeenCalledTimes(1)
bunExpect(promptSyncWithModelSuggestionRetry).toHaveBeenCalledTimes(1)
bunExpect(promptArgs.body.temperature).toBe(0.25)
})
bunTest("retries with promptSync for oracle when promptAsync fails with unexpected EOF", async () => {
bunTest("#given oracle promptSync returns unexpected EOF #when sending a sync prompt #then the prompt is treated as started without retrying promptAsync", async () => {
//#given
const { sendSyncPrompt } = require("./sync-prompt-sender")
const promptWithModelSuggestionRetry = bunMock(async () => {
const promptSyncWithModelSuggestionRetry = bunMock(async () => {
throw new Error("JSON Parse error: Unexpected EOF")
})
const promptSyncWithModelSuggestionRetry = bunMock(async () => {})
const input = {
sessionID: "test-session",
@@ -362,25 +364,22 @@ bunDescribe("sendSyncPrompt", () => {
{ session: { promptAsync: bunMock(async () => ({ data: {} })) } },
input,
{
promptWithModelSuggestionRetry,
promptSyncWithModelSuggestionRetry,
},
)
//#then
bunExpect(result).toBeNull()
bunExpect(promptWithModelSuggestionRetry).toHaveBeenCalledTimes(1)
bunExpect(promptSyncWithModelSuggestionRetry).toHaveBeenCalledTimes(1)
})
bunTest("does not retry with promptSync for non-oracle on unexpected EOF", async () => {
bunTest("returns non-oracle unexpected EOF without retrying promptAsync", async () => {
//#given
const { sendSyncPrompt } = require("./sync-prompt-sender")
const promptWithModelSuggestionRetry = bunMock(async () => {
const promptSyncWithModelSuggestionRetry = bunMock(async () => {
throw new Error("JSON Parse error: Unexpected EOF")
})
const promptSyncWithModelSuggestionRetry = bunMock(async () => {})
const input = {
sessionID: "test-session",
@@ -402,24 +401,19 @@ bunDescribe("sendSyncPrompt", () => {
{ session: { promptAsync: bunMock(async () => ({ data: {} })) } },
input,
{
promptWithModelSuggestionRetry,
promptSyncWithModelSuggestionRetry,
},
)
//#then
bunExpect(result).toContain("Unexpected EOF")
bunExpect(promptWithModelSuggestionRetry).toHaveBeenCalledTimes(1)
bunExpect(promptSyncWithModelSuggestionRetry).toHaveBeenCalledTimes(0)
bunExpect(promptSyncWithModelSuggestionRetry).toHaveBeenCalledTimes(1)
})
bunTest("#given oracle promptSync fallback is blocked by the prompt gate #when async prompt reports EOF #then the original EOF error is preserved", async () => {
bunTest("#given oracle promptSync is blocked by the prompt gate #when sending a sync prompt #then the gate error is preserved", async () => {
//#given
const { sendSyncPrompt } = require("./sync-prompt-sender")
const promptWithModelSuggestionRetry = bunMock(async () => {
throw new Error("JSON Parse error: Unexpected EOF")
})
const promptSyncWithModelSuggestionRetry = bunMock(async () => {
throw new Error("prompt skipped by gate: reserved")
})
@@ -444,14 +438,12 @@ bunDescribe("sendSyncPrompt", () => {
{ session: { promptAsync: bunMock(async () => ({ data: {} })) } },
input,
{
promptWithModelSuggestionRetry,
promptSyncWithModelSuggestionRetry,
},
)
//#then
bunExpect(result).toContain("JSON Parse error")
bunExpect(result).not.toContain("prompt skipped by gate")
bunExpect(result).toContain("prompt skipped by gate")
bunExpect(promptSyncWithModelSuggestionRetry).toHaveBeenCalledTimes(1)
})
})
+5 -21
View File
@@ -4,10 +4,9 @@ import { getAgentToolRestrictions } from "../../shared/agent-tool-restrictions"
import { createInternalAgentTextPart } from "../../shared/internal-initiator-marker"
import {
promptSyncWithModelSuggestionRetry,
promptWithModelSuggestionRetry,
} from "../../shared/model-suggestion-retry"
import { applySessionPromptParams } from "../../shared/session-prompt-params-helpers"
import { routePromptRetry, routePromptSyncRetry } from "../../shared/session-route"
import { routePromptSyncRetry } from "../../shared/session-route"
import { setSessionTools } from "../../shared/session-tools-store"
import { isPlanFamily } from "./constants"
import { formatDetailedError } from "./error-formatting"
@@ -15,12 +14,10 @@ import { buildTaskPrompt } from "./prompt-builder"
import type { DelegatedModelConfig, DelegateTaskArgs, OpencodeClient } from "./types"
type SendSyncPromptDeps = {
promptWithModelSuggestionRetry: typeof promptWithModelSuggestionRetry
promptSyncWithModelSuggestionRetry: typeof promptSyncWithModelSuggestionRetry
}
const sendSyncPromptDeps: SendSyncPromptDeps = {
promptWithModelSuggestionRetry,
promptSyncWithModelSuggestionRetry,
}
@@ -52,11 +49,6 @@ function isUnexpectedEofError(error: unknown): boolean {
return lowered.includes("unexpected eof") || lowered.includes("json parse error")
}
function isPromptGateReservedError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error)
return message.includes("promptAsync skipped by gate: reserved") || message.includes("prompt skipped by gate: reserved")
}
export function buildSyncPromptTools(agentToUse: string): Record<string, boolean> {
return {
task: isPlanFamily(agentToUse),
@@ -109,20 +101,12 @@ export async function sendSyncPrompt(
}
try {
const routedPromptArgs = routePromptRetry(promptArgs, input.directory)
await deps.promptWithModelSuggestionRetry(client, routedPromptArgs, { queueBehavior: "defer" })
await deps.promptSyncWithModelSuggestionRetry(client, routePromptSyncRetry(promptArgs, input.directory), {
queueBehavior: "defer",
})
} catch (promptError) {
if (isOracleAgent(input.agentToUse) && isUnexpectedEofError(promptError)) {
try {
await deps.promptSyncWithModelSuggestionRetry(client, routePromptSyncRetry(promptArgs, input.directory), {
queueBehavior: "defer",
})
return null
} catch (oracleRetryError) {
if (!isPromptGateReservedError(oracleRetryError)) {
promptError = oracleRetryError
}
}
return null
}
if (input.toastManager && input.taskId !== undefined) {