fix(runtime-fallback): carry delegated system and tools through bootstrap retry

When the first prompt fails before any durable user message persists,
runtime fallback retry was rebuilding the request from parts alone and
losing the delegated agent system prompt and tool gates. Now it threads
bootstrap.system and bootstrap.tools into the retry body alongside the
captured retry parts, so the retried prompt keeps the same scope as the
initial delegate launch.
This commit is contained in:
YeonGyu-Kim
2026-05-17 00:08:30 +09:00
parent 761f682add
commit ba648685d4
4 changed files with 53 additions and 9 deletions
+5 -2
View File
@@ -8,7 +8,7 @@ import { prepareFallback } from "./fallback-state"
import { SessionCategoryRegistry } from "../../shared/session-category-registry" import { SessionCategoryRegistry } from "../../shared/session-category-registry"
import { clearDelegatedChildSessionBootstrap } from "../../shared/delegated-child-session-bootstrap" import { clearDelegatedChildSessionBootstrap } from "../../shared/delegated-child-session-bootstrap"
import { buildRetryModelPayload } from "./retry-model-payload" 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 { extractSessionMessages } from "./session-messages"
import { resolveRegisteredAgentName } from "../../features/claude-code-session-state" import { resolveRegisteredAgentName } from "../../features/claude-code-session-state"
import { import {
@@ -144,7 +144,8 @@ export function createAutoRetryHelpers(deps: HookDeps) {
path: { id: sessionID }, path: { id: sessionID },
query: { directory: ctx.directory }, query: { directory: ctx.directory },
}) })
const retryParts = getLastUserRetryParts(messagesResp, sessionID) const retryPayload = getLastUserRetryPayload(messagesResp, sessionID)
const retryParts = retryPayload.retryParts
if (retryParts.length > 0) { if (retryParts.length > 0) {
log(`[${HOOK_NAME}] Auto-retrying with fallback model (${source})`, { log(`[${HOOK_NAME}] Auto-retrying with fallback model (${source})`, {
sessionID, sessionID,
@@ -166,6 +167,8 @@ export function createAutoRetryHelpers(deps: HookDeps) {
body: { body: {
...(launchAgent ? { agent: launchAgent } : {}), ...(launchAgent ? { agent: launchAgent } : {}),
...retryModelPayload, ...retryModelPayload,
...(retryPayload.system ? { system: retryPayload.system } : {}),
...(retryPayload.tools ? { tools: retryPayload.tools } : {}),
parts: retryParts, parts: retryParts,
}, },
query: { directory: ctx.directory }, query: { directory: ctx.directory },
+18 -3
View File
@@ -8,6 +8,7 @@ import {
} from "../../shared/delegated-child-session-bootstrap" } from "../../shared/delegated-child-session-bootstrap"
import * as loggerModule from "../../shared/logger" import * as loggerModule from "../../shared/logger"
import { SessionCategoryRegistry } from "../../shared/session-category-registry" import { SessionCategoryRegistry } from "../../shared/session-category-registry"
import type { RuntimeFallbackPluginInput } from "./types"
type RuntimeFallbackModule = typeof import("./hook") type RuntimeFallbackModule = typeof import("./hook")
@@ -49,8 +50,8 @@ describe("runtime-fallback", () => {
abort?: (args: unknown) => Promise<unknown> abort?: (args: unknown) => Promise<unknown>
status?: () => Promise<unknown> status?: () => Promise<unknown>
} }
}) { }): RuntimeFallbackPluginInput {
return unsafeTestValue({ return unsafeTestValue<RuntimeFallbackPluginInput>({
client: { client: {
tui: { tui: {
showToast: async (opts: { body: { title: string; message: string; variant: string; duration: number } }) => { showToast: async (opts: { body: { title: string; message: string; variant: string; duration: number } }) => {
@@ -522,6 +523,8 @@ describe("runtime-fallback", () => {
sessionID, sessionID,
promptText: "inspect src/tools/delegate-task and report the issue", promptText: "inspect src/tools/delegate-task and report the issue",
category: "quick", category: "quick",
system: "delegated child system prompt",
tools: { call_omo_agent: true, question: false, task: false },
}) })
await hook.event({ await hook.event({
@@ -538,14 +541,19 @@ describe("runtime-fallback", () => {
const promptBody = promptCalls[0]?.body as { const promptBody = promptCalls[0]?.body as {
model?: { providerID?: string; modelID?: string } model?: { providerID?: string; modelID?: string }
parts?: Array<{ type?: string; text?: string }> parts?: Array<{ type?: string; text?: string }>
system?: string
tools?: Record<string, boolean>
variant?: string variant?: string
} | undefined } | undefined
expect(promptBody?.model).toEqual({ providerID: "openai", modelID: "gpt-5.4" }) expect(promptBody?.model).toEqual({ providerID: "openai", modelID: "gpt-5.4" })
expect(promptBody?.variant).toBe("high") 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") expect(promptBody?.parts?.[0]?.text).toContain("inspect src/tools/delegate-task")
}) })
test("should discard delegated bootstrap once persisted user prompt exists", async () => { test("should use persisted user prompt while preserving delegated bootstrap launch context", async () => {
const promptCalls: Array<Record<string, unknown>> = [] const promptCalls: Array<Record<string, unknown>> = []
const sessionID = "test-delegated-history-prefers-persisted-user" const sessionID = "test-delegated-history-prefers-persisted-user"
const hook = createRuntimeFallbackHook( const hook = createRuntimeFallbackHook(
@@ -577,6 +585,8 @@ describe("runtime-fallback", () => {
registerDelegatedChildSessionBootstrap({ registerDelegatedChildSessionBootstrap({
sessionID, sessionID,
promptText: "bootstrap copy should not be reused", 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") SessionCategoryRegistry.register(sessionID, "test")
@@ -593,8 +603,13 @@ describe("runtime-fallback", () => {
expect(promptCalls).toHaveLength(1) expect(promptCalls).toHaveLength(1)
const promptBody = promptCalls[0]?.body as { const promptBody = promptCalls[0]?.body as {
parts?: Array<{ type?: string; text?: string }> parts?: Array<{ type?: string; text?: string }>
system?: string
tools?: Record<string, boolean>
} | undefined } | undefined
expect(promptBody?.parts?.[0]?.text).toBe("persisted child task prompt") 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() expect(getDelegatedChildSessionBootstrap(sessionID)).toBeUndefined()
}) })
@@ -4,10 +4,26 @@ import {
getDelegatedChildSessionBootstrap, getDelegatedChildSessionBootstrap,
} from "../../shared/delegated-child-session-bootstrap" } 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( export function getLastUserRetryParts(
messagesResponse: unknown, messagesResponse: unknown,
sessionID?: string, sessionID?: string,
): Array<{ type: "text"; text: 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 messages = extractSessionMessages(messagesResponse)
const lastUserMessage = messages?.filter((message) => message.info?.role === "user").pop() const lastUserMessage = messages?.filter((message) => message.info?.role === "user").pop()
const lastUserParts = const lastUserParts =
@@ -27,12 +43,20 @@ export function getLastUserRetryParts(
if (sessionID) { if (sessionID) {
clearDelegatedChildSessionBootstrap(sessionID) clearDelegatedChildSessionBootstrap(sessionID)
} }
return retryParts return {
retryParts,
...(bootstrap?.system ? { system: bootstrap.system } : {}),
...(bootstrap?.tools ? { tools: bootstrap.tools } : {}),
}
} }
if (!sessionID) { if (!sessionID) {
return retryParts return { retryParts }
} }
return getDelegatedChildSessionBootstrap(sessionID)?.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: { body: {
agent?: string agent?: string
model: { providerID: string; modelID: string } model: { providerID: string; modelID: string }
system?: string
tools?: Record<string, boolean>
parts: Array<{ type: "text"; text: string }> parts: Array<{ type: "text"; text: string }>
} }
query: { directory: string } query: { directory: string }