fix(delegate-task): start sync prompts asynchronously

This commit is contained in:
YeonGyu-Kim
2026-05-27 14:22:14 +09:00
parent 68d84d9eaf
commit 27f956ecec
7 changed files with 125 additions and 41 deletions
+2
View File
@@ -74,6 +74,8 @@ export async function promptWithModelSuggestionRetry(
source: "model-suggestion-retry", source: "model-suggestion-retry",
settleMs: 0, settleMs: 0,
...(options.queueBehavior ? { queueBehavior: options.queueBehavior } : {}), ...(options.queueBehavior ? { queueBehavior: options.queueBehavior } : {}),
...(options.checkStatus !== undefined ? { checkStatus: options.checkStatus } : {}),
...(options.checkToolState !== undefined ? { checkToolState: options.checkToolState } : {}),
}) })
if (promptResult.status === "failed") { if (promptResult.status === "failed") {
if (timeoutContext.wasTimedOut()) { if (timeoutContext.wasTimedOut()) {
+2
View File
@@ -5,6 +5,8 @@ export interface PromptTimeoutArgs {
export interface PromptRetryOptions { export interface PromptRetryOptions {
timeoutMs?: number timeoutMs?: number
queueBehavior?: "enqueue" | "defer" queueBehavior?: "enqueue" | "defer"
checkStatus?: boolean
checkToolState?: boolean
} }
export const PROMPT_TIMEOUT_MS = 120000 export const PROMPT_TIMEOUT_MS = 120000
+2 -2
View File
@@ -5,7 +5,7 @@ import { publishToolMetadata } from "../../features/tool-metadata-store"
import { getTaskToastManager } from "../../features/task-toast-manager" import { getTaskToastManager } from "../../features/task-toast-manager"
import { getAgentToolRestrictions } from "../../shared/agent-tool-restrictions" import { getAgentToolRestrictions } from "../../shared/agent-tool-restrictions"
import { getMessageDir, normalizeSDKResponse } from "../../shared" import { getMessageDir, normalizeSDKResponse } from "../../shared"
import { promptSyncWithModelSuggestionRetry } from "../../shared/model-suggestion-retry" import { promptWithModelSuggestionRetry } from "../../shared/model-suggestion-retry"
import { resolveMessageContext } from "../../features/hook-message-injector" import { resolveMessageContext } from "../../features/hook-message-injector"
import { formatDuration } from "./time-formatter" import { formatDuration } from "./time-formatter"
import { syncContinuationDeps, type SyncContinuationDeps } from "./sync-continuation-deps" import { syncContinuationDeps, type SyncContinuationDeps } from "./sync-continuation-deps"
@@ -160,7 +160,7 @@ export async function executeSyncContinuation(
} }
setSessionTools(continuationID, tools) setSessionTools(continuationID, tools)
await promptSyncWithModelSuggestionRetry(client, { await promptWithModelSuggestionRetry(client, {
path: { id: continuationID }, path: { id: continuationID },
body: { body: {
...(resumeAgent !== undefined ? { agent: resumeAgent } : {}), ...(resumeAgent !== undefined ? { agent: resumeAgent } : {}),
@@ -4,17 +4,17 @@ import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
import type { OpencodeClient } from "./types" import type { OpencodeClient } from "./types"
import { sendSyncPrompt } from "./sync-prompt-sender" import { sendSyncPrompt } from "./sync-prompt-sender"
import { import {
promptSyncWithModelSuggestionRetry, promptWithModelSuggestionRetry,
} from "../../shared/model-suggestion-retry" } from "../../shared/model-suggestion-retry"
type PromptSyncRetryClient = Parameters<typeof promptSyncWithModelSuggestionRetry>[0] type PromptRetryClient = Parameters<typeof promptWithModelSuggestionRetry>[0]
type PromptSyncRetryArgs = Parameters<typeof promptSyncWithModelSuggestionRetry>[1] type PromptRetryArgs = Parameters<typeof promptWithModelSuggestionRetry>[1]
describe("sendSyncPrompt session routing", () => { describe("sendSyncPrompt session routing", () => {
test("#given a sync child session directory #when sending the prompt #then prompt 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 // given
const promptCalls: PromptSyncRetryArgs[] = [] const promptCalls: PromptRetryArgs[] = []
const promptSyncWithRetry = mock(async (_client: PromptSyncRetryClient, input: PromptSyncRetryArgs) => { const promptWithRetry = mock(async (_client: PromptRetryClient, input: PromptRetryArgs) => {
promptCalls.push(input) promptCalls.push(input)
}) })
@@ -37,7 +37,7 @@ describe("sendSyncPrompt session routing", () => {
taskId: undefined, taskId: undefined,
}, },
{ {
promptSyncWithModelSuggestionRetry: promptSyncWithRetry, promptWithModelSuggestionRetry: promptWithRetry,
}, },
) )
@@ -48,9 +48,9 @@ describe("sendSyncPrompt session routing", () => {
test("#given oracle prompt returns unexpected EOF #when sending the prompt #then the sync route 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 // given
const promptSyncCalls: PromptSyncRetryArgs[] = [] const promptCalls: PromptRetryArgs[] = []
const promptSyncWithRetry = mock(async (_client: PromptSyncRetryClient, input: PromptSyncRetryArgs) => { const promptWithRetry = mock(async (_client: PromptRetryClient, input: PromptRetryArgs) => {
promptSyncCalls.push(input) promptCalls.push(input)
throw new Error("JSON Parse error: Unexpected EOF") throw new Error("JSON Parse error: Unexpected EOF")
}) })
@@ -73,13 +73,13 @@ describe("sendSyncPrompt session routing", () => {
taskId: undefined, taskId: undefined,
}, },
{ {
promptSyncWithModelSuggestionRetry: promptSyncWithRetry, promptWithModelSuggestionRetry: promptWithRetry,
}, },
) )
// then // then
expect(result).toBeNull() expect(result).toBeNull()
expect(promptSyncCalls).toHaveLength(1) expect(promptCalls).toHaveLength(1)
expect(promptSyncCalls[0]?.query).toEqual({ directory: "/parent/project" }) expect(promptCalls[0]?.query).toEqual({ directory: "/parent/project" })
}) })
}) })
@@ -16,6 +16,45 @@ bunDescribe("sendSyncPrompt", () => {
clearSessionPromptParams("test-session") clearSessionPromptParams("test-session")
}) })
bunTest("#given sync task result is polled separately #when sending the child prompt #then it starts with promptAsync instead of holding the sync stream", async () => {
//#given
const { sendSyncPrompt } = require("./sync-prompt-sender")
const prompt = bunMock(async () => {
throw new Error("sync prompt stream should not be used")
})
const promptAsync = bunMock(async () => undefined)
const mockClient = {
session: {
prompt,
promptAsync,
},
}
const input = {
sessionID: "test-session",
agentToUse: "sisyphus-junior",
args: {
description: "test task",
prompt: "test prompt",
run_in_background: false,
load_skills: [],
},
systemContent: undefined,
categoryModel: undefined,
toastManager: null,
taskId: undefined,
}
//#when
const result = await sendSyncPrompt(mockClient, input)
//#then
bunExpect(result).toBeNull()
bunExpect(prompt).toHaveBeenCalledTimes(0)
bunExpect(promptAsync).toHaveBeenCalledTimes(1)
})
bunTest("passes question=false via tools parameter", async () => { bunTest("passes question=false via tools parameter", async () => {
//#given //#given
const { sendSyncPrompt } = require("./sync-prompt-sender") const { sendSyncPrompt } = require("./sync-prompt-sender")
@@ -234,7 +273,7 @@ bunDescribe("sendSyncPrompt", () => {
const { sendSyncPrompt } = require("./sync-prompt-sender") const { sendSyncPrompt } = require("./sync-prompt-sender")
let promptArgs: any let promptArgs: any
const promptSyncWithModelSuggestionRetry = bunMock(async (_client: any, input: any) => { const promptWithModelSuggestionRetry = bunMock(async (_client: any, input: any) => {
promptArgs = input promptArgs = input
}) })
@@ -267,12 +306,12 @@ bunDescribe("sendSyncPrompt", () => {
{ session: { prompt: bunMock(async () => ({ data: {} })) } }, { session: { prompt: bunMock(async () => ({ data: {} })) } },
input, input,
{ {
promptSyncWithModelSuggestionRetry, promptWithModelSuggestionRetry,
}, },
) )
//#then //#then
bunExpect(promptSyncWithModelSuggestionRetry).toHaveBeenCalledTimes(1) bunExpect(promptWithModelSuggestionRetry).toHaveBeenCalledTimes(1)
bunExpect(promptArgs.body.model).toEqual({ bunExpect(promptArgs.body.model).toEqual({
providerID: "openai", providerID: "openai",
modelID: "gpt-5.4", modelID: "gpt-5.4",
@@ -299,7 +338,7 @@ bunDescribe("sendSyncPrompt", () => {
const { sendSyncPrompt } = require("./sync-prompt-sender") const { sendSyncPrompt } = require("./sync-prompt-sender")
let promptArgs: any let promptArgs: any
const promptSyncWithModelSuggestionRetry = bunMock(async (_client: any, input: any) => { const promptWithModelSuggestionRetry = bunMock(async (_client: any, input: any) => {
promptArgs = input promptArgs = input
}) })
@@ -328,19 +367,19 @@ bunDescribe("sendSyncPrompt", () => {
{ session: { prompt: bunMock(async () => ({ data: {} })) } }, { session: { prompt: bunMock(async () => ({ data: {} })) } },
input, input,
{ {
promptSyncWithModelSuggestionRetry, promptWithModelSuggestionRetry,
}, },
) )
//#then //#then
bunExpect(promptSyncWithModelSuggestionRetry).toHaveBeenCalledTimes(1) bunExpect(promptWithModelSuggestionRetry).toHaveBeenCalledTimes(1)
bunExpect(promptArgs.body.temperature).toBe(0.25) bunExpect(promptArgs.body.temperature).toBe(0.25)
}) })
bunTest("#given oracle promptSync returns unexpected EOF #when sending a sync prompt #then the prompt is treated as started without retrying promptAsync", async () => { bunTest("#given oracle prompt starter returns unexpected EOF #when sending a sync prompt #then the prompt is treated as started", async () => {
//#given //#given
const { sendSyncPrompt } = require("./sync-prompt-sender") const { sendSyncPrompt } = require("./sync-prompt-sender")
const promptSyncWithModelSuggestionRetry = bunMock(async () => { const promptWithModelSuggestionRetry = bunMock(async () => {
throw new Error("JSON Parse error: Unexpected EOF") throw new Error("JSON Parse error: Unexpected EOF")
}) })
@@ -364,20 +403,20 @@ bunDescribe("sendSyncPrompt", () => {
{ session: { promptAsync: bunMock(async () => ({ data: {} })) } }, { session: { promptAsync: bunMock(async () => ({ data: {} })) } },
input, input,
{ {
promptSyncWithModelSuggestionRetry, promptWithModelSuggestionRetry,
}, },
) )
//#then //#then
bunExpect(result).toBeNull() bunExpect(result).toBeNull()
bunExpect(promptSyncWithModelSuggestionRetry).toHaveBeenCalledTimes(1) bunExpect(promptWithModelSuggestionRetry).toHaveBeenCalledTimes(1)
}) })
bunTest("returns non-oracle unexpected EOF without retrying promptAsync", async () => { bunTest("returns non-oracle unexpected EOF from the prompt starter", async () => {
//#given //#given
const { sendSyncPrompt } = require("./sync-prompt-sender") const { sendSyncPrompt } = require("./sync-prompt-sender")
const promptSyncWithModelSuggestionRetry = bunMock(async () => { const promptWithModelSuggestionRetry = bunMock(async () => {
throw new Error("JSON Parse error: Unexpected EOF") throw new Error("JSON Parse error: Unexpected EOF")
}) })
@@ -401,20 +440,59 @@ bunDescribe("sendSyncPrompt", () => {
{ session: { promptAsync: bunMock(async () => ({ data: {} })) } }, { session: { promptAsync: bunMock(async () => ({ data: {} })) } },
input, input,
{ {
promptSyncWithModelSuggestionRetry, promptWithModelSuggestionRetry,
}, },
) )
//#then //#then
bunExpect(result).toContain("Unexpected EOF") bunExpect(result).toContain("Unexpected EOF")
bunExpect(promptSyncWithModelSuggestionRetry).toHaveBeenCalledTimes(1) bunExpect(promptWithModelSuggestionRetry).toHaveBeenCalledTimes(1)
}) })
bunTest("#given oracle promptSync is blocked by the prompt gate #when sending a sync prompt #then the gate error is preserved", async () => { bunTest("#given prompt starter rejects an invalid payload #when sending a sync prompt #then the task error is surfaced and toast is removed", async () => {
//#given //#given
const { sendSyncPrompt } = require("./sync-prompt-sender") const { sendSyncPrompt } = require("./sync-prompt-sender")
const promptSyncWithModelSuggestionRetry = bunMock(async () => { const promptWithModelSuggestionRetry = bunMock(async () => {
throw new Error("Bad request: parts is required")
})
const removeTask = bunMock(() => undefined)
const input = {
sessionID: "test-session",
agentToUse: "metis",
args: {
description: "test task",
prompt: "test prompt",
run_in_background: false,
load_skills: [],
},
systemContent: undefined,
categoryModel: undefined,
toastManager: { removeTask },
taskId: "task-123",
}
//#when
const result = await sendSyncPrompt(
{ session: { promptAsync: bunMock(async () => ({ data: {} })) } },
input,
{
promptWithModelSuggestionRetry,
},
)
//#then
bunExpect(result).toContain("Bad request: parts is required")
bunExpect(removeTask).toHaveBeenCalledWith("task-123")
bunExpect(promptWithModelSuggestionRetry).toHaveBeenCalledTimes(1)
})
bunTest("#given oracle prompt starter 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("prompt skipped by gate: reserved") throw new Error("prompt skipped by gate: reserved")
}) })
@@ -438,12 +516,12 @@ bunDescribe("sendSyncPrompt", () => {
{ session: { promptAsync: bunMock(async () => ({ data: {} })) } }, { session: { promptAsync: bunMock(async () => ({ data: {} })) } },
input, input,
{ {
promptSyncWithModelSuggestionRetry, promptWithModelSuggestionRetry,
}, },
) )
//#then //#then
bunExpect(result).toContain("prompt skipped by gate") bunExpect(result).toContain("prompt skipped by gate")
bunExpect(promptSyncWithModelSuggestionRetry).toHaveBeenCalledTimes(1) bunExpect(promptWithModelSuggestionRetry).toHaveBeenCalledTimes(1)
}) })
}) })
@@ -3,10 +3,10 @@ import { stripInvisibleAgentCharacters } from "../../shared/agent-display-names"
import { getAgentToolRestrictions } from "../../shared/agent-tool-restrictions" import { getAgentToolRestrictions } from "../../shared/agent-tool-restrictions"
import { createInternalAgentTextPart } from "../../shared/internal-initiator-marker" import { createInternalAgentTextPart } from "../../shared/internal-initiator-marker"
import { import {
promptSyncWithModelSuggestionRetry, promptWithModelSuggestionRetry,
} from "../../shared/model-suggestion-retry" } from "../../shared/model-suggestion-retry"
import { applySessionPromptParams } from "../../shared/session-prompt-params-helpers" import { applySessionPromptParams } from "../../shared/session-prompt-params-helpers"
import { routePromptSyncRetry } from "../../shared/session-route" import { routePromptRetry } from "../../shared/session-route"
import { setSessionTools } from "../../shared/session-tools-store" import { setSessionTools } from "../../shared/session-tools-store"
import { isPlanFamily } from "./constants" import { isPlanFamily } from "./constants"
import { formatDetailedError } from "./error-formatting" import { formatDetailedError } from "./error-formatting"
@@ -14,11 +14,11 @@ import { buildTaskPrompt } from "./prompt-builder"
import type { DelegatedModelConfig, DelegateTaskArgs, OpencodeClient } from "./types" import type { DelegatedModelConfig, DelegateTaskArgs, OpencodeClient } from "./types"
type SendSyncPromptDeps = { type SendSyncPromptDeps = {
promptSyncWithModelSuggestionRetry: typeof promptSyncWithModelSuggestionRetry promptWithModelSuggestionRetry: typeof promptWithModelSuggestionRetry
} }
const sendSyncPromptDeps: SendSyncPromptDeps = { const sendSyncPromptDeps: SendSyncPromptDeps = {
promptSyncWithModelSuggestionRetry, promptWithModelSuggestionRetry,
} }
function buildPromptGenerationParams(model: DelegatedModelConfig | undefined): Record<string, unknown> { function buildPromptGenerationParams(model: DelegatedModelConfig | undefined): Record<string, unknown> {
@@ -101,8 +101,10 @@ export async function sendSyncPrompt(
} }
try { try {
await deps.promptSyncWithModelSuggestionRetry(client, routePromptSyncRetry(promptArgs, input.directory), { await deps.promptWithModelSuggestionRetry(client, routePromptRetry(promptArgs, input.directory), {
queueBehavior: "defer", queueBehavior: "defer",
checkStatus: false,
checkToolState: false,
}) })
} catch (promptError) { } catch (promptError) {
if (isOracleAgent(input.agentToUse) && isUnexpectedEofError(promptError)) { if (isOracleAgent(input.agentToUse) && isUnexpectedEofError(promptError)) {
+4 -4
View File
@@ -1117,7 +1117,7 @@ describe("sisyphus-task", () => {
}) })
}, { timeout: 20000 }) }, { timeout: 20000 })
test("DEFAULT_CATEGORIES explicit high model passes to sync session.prompt WITHOUT userCategories", async () => { test("DEFAULT_CATEGORIES explicit high model passes to sync prompt request WITHOUT userCategories", async () => {
// given - NO userCategories, testing DEFAULT_CATEGORIES for sync mode // given - NO userCategories, testing DEFAULT_CATEGORIES for sync mode
const { createDelegateTask } = require("./tools") const { createDelegateTask } = require("./tools")
let promptBody: any let promptBody: any
@@ -2617,7 +2617,7 @@ describe("sisyphus-task", () => {
// then - should run sync, NOT forced to background // then - should run sync, NOT forced to background
expect(launchCalled).toBe(false) // manager.launch should NOT be called expect(launchCalled).toBe(false) // manager.launch should NOT be called
expect(promptCalled).toBe(true) // sync mode uses session.prompt expect(promptCalled).toBe(true)
expect(result).not.toContain("UNSTABLE AGENT MODE") expect(result).not.toContain("UNSTABLE AGENT MODE")
}, { timeout: 20000 }) }, { timeout: 20000 })
@@ -3296,6 +3296,7 @@ describe("sisyphus-task", () => {
prompt: async () => { prompt: async () => {
return { data: {} } return { data: {} }
}, },
promptAsync: async () => ({ data: {} }),
messages: async () => ({ messages: async () => ({
data: [{ info: { role: "assistant" }, parts: [{ type: "text", text: "Done" }] }] data: [{ info: { role: "assistant" }, parts: [{ type: "text", text: "Done" }] }]
}), }),
@@ -4026,7 +4027,7 @@ describe("sisyphus-task", () => {
}) })
}) })
test("sync mode passes matched agent model to session.prompt", async () => { test("sync mode passes matched agent model to prompt request", async () => {
// given - agent with model registered, using subagent_type with run_in_background=false // given - agent with model registered, using subagent_type with run_in_background=false
const { createDelegateTask } = require("./tools") const { createDelegateTask } = require("./tools")
let promptBody: any let promptBody: any
@@ -4083,7 +4084,6 @@ describe("sisyphus-task", () => {
toolContext toolContext
) )
// then - matched agent's model should be passed to session.prompt
expect(promptBody.model).toEqual({ expect(promptBody.model).toEqual({
providerID: "anthropic", providerID: "anthropic",
modelID: "claude-opus-4-7", modelID: "claude-opus-4-7",