Merge pull request #4074 from code-yeongyu/fix/delegate-task-spawn

fix(delegate-task): start child prompts reliably
This commit is contained in:
YeonGyu-Kim
2026-05-17 01:00:03 +09:00
committed by GitHub
22 changed files with 1385 additions and 216 deletions
+7 -2
View File
@@ -6,8 +6,9 @@ import { getSessionAgent } from "../../features/claude-code-session-state"
import { getFallbackModelsForSession } from "./fallback-models"
import { prepareFallback } from "./fallback-state"
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
import { clearDelegatedChildSessionBootstrap } from "../../shared/delegated-child-session-bootstrap"
import { buildRetryModelPayload } from "./retry-model-payload"
import { getLastUserRetryParts } from "./last-user-retry-parts"
import { getLastUserRetryPayload } from "./last-user-retry-parts"
import { extractSessionMessages } from "./session-messages"
import { resolveRegisteredAgentName } from "../../features/claude-code-session-state"
import {
@@ -143,7 +144,8 @@ export function createAutoRetryHelpers(deps: HookDeps) {
path: { id: sessionID },
query: { directory: ctx.directory },
})
const retryParts = getLastUserRetryParts(messagesResp)
const retryPayload = getLastUserRetryPayload(messagesResp, sessionID)
const retryParts = retryPayload.retryParts
if (retryParts.length > 0) {
log(`[${HOOK_NAME}] Auto-retrying with fallback model (${source})`, {
sessionID,
@@ -165,6 +167,8 @@ export function createAutoRetryHelpers(deps: HookDeps) {
body: {
...(launchAgent ? { agent: launchAgent } : {}),
...retryModelPayload,
...(retryPayload.system ? { system: retryPayload.system } : {}),
...(retryPayload.tools ? { tools: retryPayload.tools } : {}),
parts: retryParts,
},
query: { directory: ctx.directory },
@@ -239,6 +243,7 @@ export function createAutoRetryHelpers(deps: HookDeps) {
sessionRetryInFlight.delete(sessionID)
sessionAwaitingFallbackResult.delete(sessionID)
clearSessionFallbackTimeout(sessionID)
clearDelegatedChildSessionBootstrap(sessionID)
SessionCategoryRegistry.remove(sessionID)
sessionStatusRetryKeys.delete(sessionID)
cleanedCount++
+129 -5
View File
@@ -1,8 +1,14 @@
import { describe, expect, test, beforeEach, afterEach, mock } from "bun:test"
import type { RuntimeFallbackConfig, OhMyOpenCodeConfig } from "../../config"
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
import type { OhMyOpenCodeConfig, RuntimeFallbackConfig } from "../../config"
import {
clearAllDelegatedChildSessionBootstrap,
getDelegatedChildSessionBootstrap,
registerDelegatedChildSessionBootstrap,
} from "../../shared/delegated-child-session-bootstrap"
import * as loggerModule from "../../shared/logger"
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
import type { RuntimeFallbackPluginInput } from "./types"
type RuntimeFallbackModule = typeof import("./hook")
@@ -16,6 +22,7 @@ describe("runtime-fallback", () => {
logCalls = []
toastCalls = []
SessionCategoryRegistry.clear()
clearAllDelegatedChildSessionBootstrap()
const cacheBuster = `${Date.now()}-${Math.random()}`
@@ -32,6 +39,7 @@ describe("runtime-fallback", () => {
afterEach(() => {
SessionCategoryRegistry.clear()
clearAllDelegatedChildSessionBootstrap()
mock.restore()
})
@@ -42,8 +50,8 @@ describe("runtime-fallback", () => {
abort?: (args: unknown) => Promise<unknown>
status?: () => Promise<unknown>
}
}) {
return unsafeTestValue({
}): RuntimeFallbackPluginInput {
return unsafeTestValue<RuntimeFallbackPluginInput>({
client: {
tui: {
showToast: async (opts: { body: { title: string; message: string; variant: string; duration: number } }) => {
@@ -489,6 +497,122 @@ describe("runtime-fallback", () => {
})
})
test("should retry delegated child session from bootstrap when history has no user prompt", async () => {
const promptCalls: Array<Record<string, unknown>> = []
const hook = createRuntimeFallbackHook(
createMockPluginInput({
session: {
messages: async () => ({ data: [] }),
promptAsync: async (args) => {
promptCalls.push(args as Record<string, unknown>)
return {}
},
},
}),
{
config: createMockConfig({ notify_on_fallback: false }),
pluginConfig: createMockPluginConfigWithCategoryModel(
"quick",
"anthropic/claude-haiku-4-5",
["openai/gpt-5.4(high)"],
),
},
)
const sessionID = "test-delegated-empty-history-bootstrap"
registerDelegatedChildSessionBootstrap({
sessionID,
promptText: "inspect src/tools/delegate-task and report the issue",
category: "quick",
system: "delegated child system prompt",
tools: { call_omo_agent: true, question: false, task: false },
})
await hook.event({
event: {
type: "session.error",
properties: {
sessionID,
error: { statusCode: 429, message: "Rate limit exceeded before history persisted" },
},
},
})
expect(promptCalls).toHaveLength(1)
const promptBody = promptCalls[0]?.body as {
model?: { providerID?: string; modelID?: string }
parts?: Array<{ type?: string; text?: string }>
system?: string
tools?: Record<string, boolean>
variant?: string
} | undefined
expect(promptBody?.model).toEqual({ providerID: "openai", modelID: "gpt-5.4" })
expect(promptBody?.variant).toBe("high")
expect(promptBody?.system).toBe("delegated child system prompt")
expect(promptBody?.tools?.question).toBe(false)
expect(promptBody?.tools?.call_omo_agent).toBe(true)
expect(promptBody?.parts?.[0]?.text).toContain("inspect src/tools/delegate-task")
})
test("should use persisted user prompt while preserving delegated bootstrap launch context", async () => {
const promptCalls: Array<Record<string, unknown>> = []
const sessionID = "test-delegated-history-prefers-persisted-user"
const hook = createRuntimeFallbackHook(
createMockPluginInput({
session: {
messages: async () => ({
data: [
{
info: { role: "user" },
parts: [{ type: "text", text: "persisted child task prompt" }],
},
],
}),
promptAsync: async (args) => {
promptCalls.push(args as Record<string, unknown>)
return {}
},
},
}),
{
config: createMockConfig({ notify_on_fallback: false }),
pluginConfig: createMockPluginConfigWithCategoryModel(
"test",
"anthropic/claude-haiku-4-5",
["openai/gpt-5.4"],
),
},
)
registerDelegatedChildSessionBootstrap({
sessionID,
promptText: "bootstrap copy should not be reused",
system: "persisted delegated child system prompt",
tools: { call_omo_agent: true, question: false, task: false },
})
SessionCategoryRegistry.register(sessionID, "test")
await hook.event({
event: {
type: "session.error",
properties: {
sessionID,
error: { statusCode: 429, message: "Rate limit after prompt persisted" },
},
},
})
expect(promptCalls).toHaveLength(1)
const promptBody = promptCalls[0]?.body as {
parts?: Array<{ type?: string; text?: string }>
system?: string
tools?: Record<string, boolean>
} | undefined
expect(promptBody?.parts?.[0]?.text).toBe("persisted child task prompt")
expect(promptBody?.system).toBe("persisted delegated child system prompt")
expect(promptBody?.tools?.question).toBe(false)
expect(promptBody?.tools?.call_omo_agent).toBe(true)
expect(getDelegatedChildSessionBootstrap(sessionID)).toBeUndefined()
})
test("should trigger fallback on Copilot auto-retry signal in message.updated", async () => {
const hook = createRuntimeFallbackHook(createMockPluginInput(), {
config: createMockConfig({ notify_on_fallback: false }),
@@ -1,15 +1,36 @@
import { extractSessionMessages } from "./session-messages"
import {
clearDelegatedChildSessionBootstrap,
getDelegatedChildSessionBootstrap,
} from "../../shared/delegated-child-session-bootstrap"
type RetryPart = { type: "text"; text: string }
export type LastUserRetryPayload = {
retryParts: RetryPart[]
system?: string
tools?: Record<string, boolean>
}
export function getLastUserRetryParts(
messagesResponse: unknown,
): Array<{ type: "text"; text: string }> {
sessionID?: string,
): RetryPart[] {
return getLastUserRetryPayload(messagesResponse, sessionID).retryParts
}
export function getLastUserRetryPayload(
messagesResponse: unknown,
sessionID?: string,
): LastUserRetryPayload {
const bootstrap = sessionID ? getDelegatedChildSessionBootstrap(sessionID) : undefined
const messages = extractSessionMessages(messagesResponse)
const lastUserMessage = messages?.filter((message) => message.info?.role === "user").pop()
const lastUserParts =
lastUserMessage?.parts
?? (lastUserMessage?.info?.parts as Array<{ type?: string; text?: string }> | undefined)
return (lastUserParts ?? [])
const retryParts = (lastUserParts ?? [])
.filter(
(part): part is { type: "text"; text: string } =>
part.type === "text"
@@ -17,4 +38,25 @@ export function getLastUserRetryParts(
&& part.text.length > 0,
)
.map((part) => ({ type: "text" as const, text: part.text }))
if (retryParts.length > 0) {
if (sessionID) {
clearDelegatedChildSessionBootstrap(sessionID)
}
return {
retryParts,
...(bootstrap?.system ? { system: bootstrap.system } : {}),
...(bootstrap?.tools ? { tools: bootstrap.tools } : {}),
}
}
if (!sessionID) {
return { retryParts }
}
return {
retryParts: bootstrap?.retryParts ?? [],
...(bootstrap?.system ? { system: bootstrap.system } : {}),
...(bootstrap?.tools ? { tools: bootstrap.tools } : {}),
}
}
+2
View File
@@ -16,6 +16,8 @@ export interface RuntimeFallbackPluginInput {
body: {
agent?: string
model: { providerID: string; modelID: string }
system?: string
tools?: Record<string, boolean>
parts: Array<{ type: "text"; text: string }>
}
query: { directory: string }
@@ -91,6 +91,36 @@ describe("promptAsyncAfterSessionIdle", () => {
expect(promptCalls).toBe(1)
})
test("#given SDK promptAsync depends on its session receiver #when the gate dispatches #then method binding is preserved", async () => {
// given
const session = {
_client: { accepted: true },
async promptAsync(
this: { _client: { accepted: boolean } },
input: { path: { id: string }, body: { parts: unknown[] } },
) {
return { accepted: this._client.accepted, sessionID: input.path.id }
},
}
const client = { session }
// when
const result = await promptAsyncAfterSessionIdle({
client,
sessionID: "ses_bound_prompt_async",
input: { path: { id: "ses_bound_prompt_async" }, body: { parts: [] } },
source: "test:bound-prompt-async",
settleMs: 0,
postDispatchHoldMs: 0,
})
// then
expect(result).toEqual({
status: "dispatched",
response: { accepted: true, sessionID: "ses_bound_prompt_async" },
})
})
test("#given session.status reports busy #when an internal promptAsync is requested #then no prompt is sent", async () => {
// given
let promptCalls = 0
@@ -445,4 +475,34 @@ describe("promptAsyncAfterSessionIdle", () => {
expect(second.status).toBe("reserved")
expect(promptCalls).toBe(1)
})
test("#given SDK prompt depends on its session receiver #when the gate dispatches #then method binding is preserved", async () => {
// given
const session = {
_client: { accepted: true },
async prompt(
this: { _client: { accepted: boolean } },
input: { path: { id: string }, body: { parts: unknown[] } },
) {
return { accepted: this._client.accepted, sessionID: input.path.id }
},
}
const client = { session }
// when
const result = await promptAfterSessionIdle({
client,
sessionID: "ses_bound_prompt",
input: { path: { id: "ses_bound_prompt" }, body: { parts: [] } },
source: "test:bound-prompt",
settleMs: 0,
postDispatchHoldMs: 0,
})
// then
expect(result).toEqual({
status: "dispatched",
response: { accepted: true, sessionID: "ses_bound_prompt" },
})
})
})