Merge pull request #4554 from code-yeongyu/code-yeongyu/fix-opencode-session-stall

fix(delegate-task): start sync prompts asynchronously
This commit is contained in:
YeonGyu-Kim
2026-05-27 14:26:23 +09:00
committed by GitHub
7 changed files with 125 additions and 41 deletions
+2
View File
@@ -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()) {
+2
View File
@@ -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
+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 { 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 } : {}),
@@ -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<typeof promptSyncWithModelSuggestionRetry>[0]
type PromptSyncRetryArgs = Parameters<typeof promptSyncWithModelSuggestionRetry>[1]
type PromptRetryClient = Parameters<typeof promptWithModelSuggestionRetry>[0]
type PromptRetryArgs = Parameters<typeof promptWithModelSuggestionRetry>[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" })
})
})
@@ -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)
})
})
@@ -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<string, unknown> {
@@ -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)) {
+4 -4
View File
@@ -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",