From 27f956ecec4f02fd2d48ff141aa212a0cb91cd01 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 27 May 2026 14:22:14 +0900 Subject: [PATCH] fix(delegate-task): start sync prompts asynchronously --- src/shared/model-suggestion-retry.ts | 2 + src/shared/prompt-timeout-context.ts | 2 + src/tools/delegate-task/sync-continuation.ts | 4 +- .../delegate-task/sync-prompt-route.test.ts | 24 ++-- .../delegate-task/sync-prompt-sender.test.ts | 114 +++++++++++++++--- src/tools/delegate-task/sync-prompt-sender.ts | 12 +- src/tools/delegate-task/tools.test.ts | 8 +- 7 files changed, 125 insertions(+), 41 deletions(-) diff --git a/src/shared/model-suggestion-retry.ts b/src/shared/model-suggestion-retry.ts index a8842ca47..ea96e85d7 100644 --- a/src/shared/model-suggestion-retry.ts +++ b/src/shared/model-suggestion-retry.ts @@ -74,6 +74,8 @@ export async function promptWithModelSuggestionRetry( source: "model-suggestion-retry", settleMs: 0, ...(options.queueBehavior ? { queueBehavior: options.queueBehavior } : {}), + ...(options.checkStatus !== undefined ? { checkStatus: options.checkStatus } : {}), + ...(options.checkToolState !== undefined ? { checkToolState: options.checkToolState } : {}), }) if (promptResult.status === "failed") { if (timeoutContext.wasTimedOut()) { diff --git a/src/shared/prompt-timeout-context.ts b/src/shared/prompt-timeout-context.ts index 8c843625d..23ddfa1ae 100644 --- a/src/shared/prompt-timeout-context.ts +++ b/src/shared/prompt-timeout-context.ts @@ -5,6 +5,8 @@ export interface PromptTimeoutArgs { export interface PromptRetryOptions { timeoutMs?: number queueBehavior?: "enqueue" | "defer" + checkStatus?: boolean + checkToolState?: boolean } export const PROMPT_TIMEOUT_MS = 120000 diff --git a/src/tools/delegate-task/sync-continuation.ts b/src/tools/delegate-task/sync-continuation.ts index b5281c814..ce6bf0e99 100644 --- a/src/tools/delegate-task/sync-continuation.ts +++ b/src/tools/delegate-task/sync-continuation.ts @@ -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 { promptSyncWithModelSuggestionRetry } from "../../shared/model-suggestion-retry" +import { promptWithModelSuggestionRetry } 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 promptSyncWithModelSuggestionRetry(client, { + await promptWithModelSuggestionRetry(client, { path: { id: continuationID }, body: { ...(resumeAgent !== undefined ? { agent: resumeAgent } : {}), diff --git a/src/tools/delegate-task/sync-prompt-route.test.ts b/src/tools/delegate-task/sync-prompt-route.test.ts index 02d79d95e..96bc01f8d 100644 --- a/src/tools/delegate-task/sync-prompt-route.test.ts +++ b/src/tools/delegate-task/sync-prompt-route.test.ts @@ -4,17 +4,17 @@ import { unsafeTestValue } from "../../../test-support/unsafe-test-value" import type { OpencodeClient } from "./types" import { sendSyncPrompt } from "./sync-prompt-sender" import { - promptSyncWithModelSuggestionRetry, + promptWithModelSuggestionRetry, } from "../../shared/model-suggestion-retry" -type PromptSyncRetryClient = Parameters[0] -type PromptSyncRetryArgs = Parameters[1] +type PromptRetryClient = Parameters[0] +type PromptRetryArgs = Parameters[1] describe("sendSyncPrompt session routing", () => { test("#given a sync child session directory #when sending the prompt #then prompt uses that OpenCode directory route", async () => { // given - const promptCalls: PromptSyncRetryArgs[] = [] - const promptSyncWithRetry = mock(async (_client: PromptSyncRetryClient, input: PromptSyncRetryArgs) => { + const promptCalls: PromptRetryArgs[] = [] + const promptWithRetry = mock(async (_client: PromptRetryClient, input: PromptRetryArgs) => { promptCalls.push(input) }) @@ -37,7 +37,7 @@ describe("sendSyncPrompt session routing", () => { 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 () => { // given - const promptSyncCalls: PromptSyncRetryArgs[] = [] - const promptSyncWithRetry = mock(async (_client: PromptSyncRetryClient, input: PromptSyncRetryArgs) => { - promptSyncCalls.push(input) + const promptCalls: PromptRetryArgs[] = [] + const promptWithRetry = mock(async (_client: PromptRetryClient, input: PromptRetryArgs) => { + promptCalls.push(input) throw new Error("JSON Parse error: Unexpected EOF") }) @@ -73,13 +73,13 @@ describe("sendSyncPrompt session routing", () => { taskId: undefined, }, { - promptSyncWithModelSuggestionRetry: promptSyncWithRetry, + promptWithModelSuggestionRetry: promptWithRetry, }, ) // then expect(result).toBeNull() - expect(promptSyncCalls).toHaveLength(1) - expect(promptSyncCalls[0]?.query).toEqual({ directory: "/parent/project" }) + expect(promptCalls).toHaveLength(1) + expect(promptCalls[0]?.query).toEqual({ directory: "/parent/project" }) }) }) diff --git a/src/tools/delegate-task/sync-prompt-sender.test.ts b/src/tools/delegate-task/sync-prompt-sender.test.ts index 150f48fe7..86fe571b7 100644 --- a/src/tools/delegate-task/sync-prompt-sender.test.ts +++ b/src/tools/delegate-task/sync-prompt-sender.test.ts @@ -16,6 +16,45 @@ bunDescribe("sendSyncPrompt", () => { 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 () => { //#given const { sendSyncPrompt } = require("./sync-prompt-sender") @@ -234,7 +273,7 @@ bunDescribe("sendSyncPrompt", () => { const { sendSyncPrompt } = require("./sync-prompt-sender") let promptArgs: any - const promptSyncWithModelSuggestionRetry = bunMock(async (_client: any, input: any) => { + const promptWithModelSuggestionRetry = bunMock(async (_client: any, input: any) => { promptArgs = input }) @@ -267,12 +306,12 @@ bunDescribe("sendSyncPrompt", () => { { session: { prompt: bunMock(async () => ({ data: {} })) } }, input, { - promptSyncWithModelSuggestionRetry, + promptWithModelSuggestionRetry, }, ) //#then - bunExpect(promptSyncWithModelSuggestionRetry).toHaveBeenCalledTimes(1) + bunExpect(promptWithModelSuggestionRetry).toHaveBeenCalledTimes(1) bunExpect(promptArgs.body.model).toEqual({ providerID: "openai", modelID: "gpt-5.4", @@ -299,7 +338,7 @@ bunDescribe("sendSyncPrompt", () => { const { sendSyncPrompt } = require("./sync-prompt-sender") let promptArgs: any - const promptSyncWithModelSuggestionRetry = bunMock(async (_client: any, input: any) => { + const promptWithModelSuggestionRetry = bunMock(async (_client: any, input: any) => { promptArgs = input }) @@ -328,19 +367,19 @@ bunDescribe("sendSyncPrompt", () => { { session: { prompt: bunMock(async () => ({ data: {} })) } }, input, { - promptSyncWithModelSuggestionRetry, + promptWithModelSuggestionRetry, }, ) //#then - bunExpect(promptSyncWithModelSuggestionRetry).toHaveBeenCalledTimes(1) + bunExpect(promptWithModelSuggestionRetry).toHaveBeenCalledTimes(1) 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 const { sendSyncPrompt } = require("./sync-prompt-sender") - const promptSyncWithModelSuggestionRetry = bunMock(async () => { + const promptWithModelSuggestionRetry = bunMock(async () => { throw new Error("JSON Parse error: Unexpected EOF") }) @@ -364,20 +403,20 @@ bunDescribe("sendSyncPrompt", () => { { session: { promptAsync: bunMock(async () => ({ data: {} })) } }, input, { - promptSyncWithModelSuggestionRetry, + promptWithModelSuggestionRetry, }, ) //#then 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 const { sendSyncPrompt } = require("./sync-prompt-sender") - const promptSyncWithModelSuggestionRetry = bunMock(async () => { + const promptWithModelSuggestionRetry = bunMock(async () => { throw new Error("JSON Parse error: Unexpected EOF") }) @@ -401,20 +440,59 @@ bunDescribe("sendSyncPrompt", () => { { session: { promptAsync: bunMock(async () => ({ data: {} })) } }, input, { - promptSyncWithModelSuggestionRetry, + promptWithModelSuggestionRetry, }, ) //#then 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 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") }) @@ -438,12 +516,12 @@ bunDescribe("sendSyncPrompt", () => { { session: { promptAsync: bunMock(async () => ({ data: {} })) } }, input, { - promptSyncWithModelSuggestionRetry, + promptWithModelSuggestionRetry, }, ) //#then bunExpect(result).toContain("prompt skipped by gate") - bunExpect(promptSyncWithModelSuggestionRetry).toHaveBeenCalledTimes(1) + bunExpect(promptWithModelSuggestionRetry).toHaveBeenCalledTimes(1) }) }) diff --git a/src/tools/delegate-task/sync-prompt-sender.ts b/src/tools/delegate-task/sync-prompt-sender.ts index 5a5ed28d3..42e36871b 100644 --- a/src/tools/delegate-task/sync-prompt-sender.ts +++ b/src/tools/delegate-task/sync-prompt-sender.ts @@ -3,10 +3,10 @@ import { stripInvisibleAgentCharacters } from "../../shared/agent-display-names" 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 { routePromptSyncRetry } from "../../shared/session-route" +import { routePromptRetry } from "../../shared/session-route" import { setSessionTools } from "../../shared/session-tools-store" import { isPlanFamily } from "./constants" import { formatDetailedError } from "./error-formatting" @@ -14,11 +14,11 @@ import { buildTaskPrompt } from "./prompt-builder" import type { DelegatedModelConfig, DelegateTaskArgs, OpencodeClient } from "./types" type SendSyncPromptDeps = { - promptSyncWithModelSuggestionRetry: typeof promptSyncWithModelSuggestionRetry + promptWithModelSuggestionRetry: typeof promptWithModelSuggestionRetry } const sendSyncPromptDeps: SendSyncPromptDeps = { - promptSyncWithModelSuggestionRetry, + promptWithModelSuggestionRetry, } function buildPromptGenerationParams(model: DelegatedModelConfig | undefined): Record { @@ -101,8 +101,10 @@ export async function sendSyncPrompt( } try { - await deps.promptSyncWithModelSuggestionRetry(client, routePromptSyncRetry(promptArgs, input.directory), { + await deps.promptWithModelSuggestionRetry(client, routePromptRetry(promptArgs, input.directory), { queueBehavior: "defer", + checkStatus: false, + checkToolState: false, }) } catch (promptError) { if (isOracleAgent(input.agentToUse) && isUnexpectedEofError(promptError)) { diff --git a/src/tools/delegate-task/tools.test.ts b/src/tools/delegate-task/tools.test.ts index 42165e1e3..393523a02 100644 --- a/src/tools/delegate-task/tools.test.ts +++ b/src/tools/delegate-task/tools.test.ts @@ -1117,7 +1117,7 @@ describe("sisyphus-task", () => { }) }, { 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 const { createDelegateTask } = require("./tools") let promptBody: any @@ -2617,7 +2617,7 @@ describe("sisyphus-task", () => { // then - should run sync, NOT forced to background 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") }, { timeout: 20000 }) @@ -3296,6 +3296,7 @@ describe("sisyphus-task", () => { prompt: async () => { return { data: {} } }, + promptAsync: async () => ({ data: {} }), messages: async () => ({ 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 const { createDelegateTask } = require("./tools") let promptBody: any @@ -4083,7 +4084,6 @@ describe("sisyphus-task", () => { toolContext ) - // then - matched agent's model should be passed to session.prompt expect(promptBody.model).toEqual({ providerID: "anthropic", modelID: "claude-opus-4-7",