Merge pull request #4074 from code-yeongyu/fix/delegate-task-spawn
fix(delegate-task): start child prompts reliably
This commit is contained in:
@@ -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++
|
||||
|
||||
@@ -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 } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user