fix(delegate-task): start child prompts reliably

Preserve delegated child prompt/bootstrap metadata for early runtime fallback before OpenCode has persisted the first user turn. Bind prompt gate calls to the SDK session receiver and keep completed background task lookup visible across plugin manager instances.
This commit is contained in:
YeonGyu-Kim
2026-05-16 16:21:48 +09:00
parent 76e573a920
commit 982fa81367
11 changed files with 742 additions and 88 deletions
+3 -1
View File
@@ -6,6 +6,7 @@ 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 { extractSessionMessages } from "./session-messages"
@@ -143,7 +144,7 @@ export function createAutoRetryHelpers(deps: HookDeps) {
path: { id: sessionID },
query: { directory: ctx.directory },
})
const retryParts = getLastUserRetryParts(messagesResp)
const retryParts = getLastUserRetryParts(messagesResp, sessionID)
if (retryParts.length > 0) {
log(`[${HOOK_NAME}] Auto-retrying with fallback model (${source})`, {
sessionID,
@@ -239,6 +240,7 @@ export function createAutoRetryHelpers(deps: HookDeps) {
sessionRetryInFlight.delete(sessionID)
sessionAwaitingFallbackResult.delete(sessionID)
clearSessionFallbackTimeout(sessionID)
clearDelegatedChildSessionBootstrap(sessionID)
SessionCategoryRegistry.remove(sessionID)
sessionStatusRetryKeys.delete(sessionID)
cleanedCount++
+112 -3
View File
@@ -1,8 +1,13 @@
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"
type RuntimeFallbackModule = typeof import("./hook")
@@ -16,6 +21,7 @@ describe("runtime-fallback", () => {
logCalls = []
toastCalls = []
SessionCategoryRegistry.clear()
clearAllDelegatedChildSessionBootstrap()
const cacheBuster = `${Date.now()}-${Math.random()}`
@@ -32,6 +38,7 @@ describe("runtime-fallback", () => {
afterEach(() => {
SessionCategoryRegistry.clear()
clearAllDelegatedChildSessionBootstrap()
mock.restore()
})
@@ -489,6 +496,108 @@ 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",
})
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 }>
variant?: string
} | undefined
expect(promptBody?.model).toEqual({ providerID: "openai", modelID: "gpt-5.4" })
expect(promptBody?.variant).toBe("high")
expect(promptBody?.parts?.[0]?.text).toContain("inspect src/tools/delegate-task")
})
test("should discard delegated bootstrap once persisted user prompt exists", 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",
})
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 }>
} | undefined
expect(promptBody?.parts?.[0]?.text).toBe("persisted child task prompt")
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,7 +1,12 @@
import { extractSessionMessages } from "./session-messages"
import {
clearDelegatedChildSessionBootstrap,
getDelegatedChildSessionBootstrap,
} from "../../shared/delegated-child-session-bootstrap"
export function getLastUserRetryParts(
messagesResponse: unknown,
sessionID?: string,
): Array<{ type: "text"; text: string }> {
const messages = extractSessionMessages(messagesResponse)
const lastUserMessage = messages?.filter((message) => message.info?.role === "user").pop()
@@ -9,7 +14,7 @@ export function getLastUserRetryParts(
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 +22,17 @@ 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
}
if (!sessionID) {
return retryParts
}
return getDelegatedChildSessionBootstrap(sessionID)?.retryParts ?? []
}