Merge pull request #4074 from code-yeongyu/fix/delegate-task-spawn
fix(delegate-task): start child prompts reliably
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
|
||||
const { describe, test, expect, mock } = require("bun:test")
|
||||
import { describe, test, expect, mock } from "bun:test"
|
||||
|
||||
type ExecuteSync = typeof import("./sync-executor").executeSync
|
||||
|
||||
@@ -13,6 +13,7 @@ type PromptAsyncInput = {
|
||||
variant?: string
|
||||
temperature?: number
|
||||
topP?: number
|
||||
maxOutputTokens?: number
|
||||
options?: Record<string, unknown>
|
||||
}
|
||||
}
|
||||
@@ -342,6 +343,64 @@ describe("executeSync", () => {
|
||||
expect(deps.setSessionFallbackChain).toHaveBeenCalledWith("ses-fallback", fallbackChain)
|
||||
})
|
||||
|
||||
test("registers child-session bootstrap and tracked prompt state before sync prompt dispatch", async () => {
|
||||
//#given
|
||||
const executeSync = await importExecuteSync()
|
||||
const { _resetForTesting, getSessionAgent } = require("../../features/claude-code-session-state")
|
||||
const { clearAllDelegatedChildSessionBootstrap, getDelegatedChildSessionBootstrap } = require("../../shared/delegated-child-session-bootstrap")
|
||||
const { clearSessionTools, getSessionTools } = require("../../shared/session-tools-store")
|
||||
const deps = createDependencies({
|
||||
createOrGetSession: mock(async () => ({ sessionID: "ses-call-bootstrap", isNew: true })),
|
||||
})
|
||||
const toolContext = createToolContext()
|
||||
const observed: Array<{
|
||||
agent: string | undefined
|
||||
tools: Record<string, boolean> | undefined
|
||||
bootstrap: ReturnType<typeof getDelegatedChildSessionBootstrap>
|
||||
}> = []
|
||||
const recorder = createPromptAsyncRecorder(async () => {
|
||||
observed.push({
|
||||
agent: getSessionAgent("ses-call-bootstrap"),
|
||||
tools: getSessionTools("ses-call-bootstrap"),
|
||||
bootstrap: getDelegatedChildSessionBootstrap("ses-call-bootstrap"),
|
||||
})
|
||||
return { data: {} }
|
||||
})
|
||||
const args = {
|
||||
subagent_type: "explore",
|
||||
description: "bootstrap state",
|
||||
prompt: "collect bootstrap evidence",
|
||||
run_in_background: false,
|
||||
}
|
||||
const fallbackChain = [
|
||||
{ providers: ["openai"], model: "gpt-5.4", variant: "high" },
|
||||
]
|
||||
|
||||
try {
|
||||
//#when
|
||||
await executeSync(
|
||||
args,
|
||||
toolContext,
|
||||
createContext(recorder.promptAsync) as never,
|
||||
deps,
|
||||
fallbackChain
|
||||
)
|
||||
|
||||
//#then
|
||||
expect(observed[0]?.agent).toBe("explore")
|
||||
expect(observed[0]?.tools?.question).toBe(false)
|
||||
expect(observed[0]?.tools?.task).toBe(false)
|
||||
expect(observed[0]?.bootstrap?.retryParts[0]?.text).toContain("collect bootstrap evidence")
|
||||
expect(observed[0]?.bootstrap?.tools?.question).toBe(false)
|
||||
expect(observed[0]?.bootstrap?.fallbackChain?.[0]?.model).toBe("gpt-5.4")
|
||||
expect(getDelegatedChildSessionBootstrap("ses-call-bootstrap")).toBeUndefined()
|
||||
} finally {
|
||||
clearAllDelegatedChildSessionBootstrap()
|
||||
clearSessionTools()
|
||||
_resetForTesting()
|
||||
}
|
||||
})
|
||||
|
||||
test("returns dedicated agent-not-found error with task metadata", async () => {
|
||||
//#given
|
||||
const executeSync = await importExecuteSync()
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import type { CallOmoAgentArgs } from "./types"
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { subagentSessions, syncSubagentSessions } from "../../features/claude-code-session-state"
|
||||
import { getAgentToolRestrictions, log } from "../../shared"
|
||||
import { applySessionPromptParams } from "../../shared/session-prompt-params-helpers"
|
||||
import type { DelegatedModelConfig } from "../../shared/model-resolution-types"
|
||||
import type { FallbackEntry } from "../../shared/model-requirements"
|
||||
import { getAgentDisplayName, stripAgentListSortPrefix } from "../../shared/agent-display-names"
|
||||
import { setSessionAgent, subagentSessions, syncSubagentSessions } from "../../features/claude-code-session-state"
|
||||
import { promptAsyncAfterSessionIdle } from "../../hooks/shared/prompt-async-gate"
|
||||
import { getAgentToolRestrictions, log } from "../../shared"
|
||||
import { getAgentDisplayName, stripAgentListSortPrefix } from "../../shared/agent-display-names"
|
||||
import {
|
||||
clearDelegatedChildSessionBootstrap,
|
||||
registerDelegatedChildSessionBootstrap,
|
||||
} from "../../shared/delegated-child-session-bootstrap"
|
||||
import type { FallbackEntry } from "../../shared/model-requirements"
|
||||
import type { DelegatedModelConfig } from "../../shared/model-resolution-types"
|
||||
import { applySessionPromptParams } from "../../shared/session-prompt-params-helpers"
|
||||
import { deleteSessionTools, setSessionTools } from "../../shared/session-tools-store"
|
||||
import { waitForCompletion } from "./completion-poller"
|
||||
import { processMessages } from "./message-processor"
|
||||
import { createOrGetSession } from "./session-creator"
|
||||
import type { CallOmoAgentArgs } from "./types"
|
||||
|
||||
type SessionWithPromptAsync = {
|
||||
promptAsync: (opts: { path: { id: string }; body: Record<string, unknown> }) => Promise<unknown>
|
||||
@@ -58,6 +63,14 @@ function buildPromptGenerationParams(model: DelegatedModelConfig | undefined): R
|
||||
}
|
||||
}
|
||||
|
||||
function buildSyncPromptTools(agent: string): Record<string, boolean> {
|
||||
return {
|
||||
...getAgentToolRestrictions(agent),
|
||||
task: false,
|
||||
question: false,
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeSync(
|
||||
args: CallOmoAgentArgs,
|
||||
toolContext: {
|
||||
@@ -105,6 +118,16 @@ export async function executeSync(
|
||||
log(`[call_omo_agent] Sending prompt to session ${sessionID}`)
|
||||
log(`[call_omo_agent] Prompt text:`, args.prompt.substring(0, 100))
|
||||
const normalizedSubagentType = stripAgentListSortPrefix(args.subagent_type)
|
||||
const promptAgent = getAgentDisplayName(normalizedSubagentType)
|
||||
const promptTools = buildSyncPromptTools(normalizedSubagentType)
|
||||
setSessionAgent(sessionID, promptAgent)
|
||||
setSessionTools(sessionID, promptTools)
|
||||
registerDelegatedChildSessionBootstrap({
|
||||
sessionID,
|
||||
promptText: args.prompt,
|
||||
fallbackChain,
|
||||
tools: promptTools,
|
||||
})
|
||||
|
||||
try {
|
||||
if (!hasPromptAsync(ctx.client.session)) {
|
||||
@@ -119,12 +142,8 @@ export async function executeSync(
|
||||
input: {
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
agent: getAgentDisplayName(normalizedSubagentType),
|
||||
tools: {
|
||||
...getAgentToolRestrictions(normalizedSubagentType),
|
||||
task: false,
|
||||
question: false,
|
||||
},
|
||||
agent: promptAgent,
|
||||
tools: promptTools,
|
||||
parts: [{ type: "text", text: args.prompt }],
|
||||
...(model ? { model: { providerID: model.providerID, modelID: model.modelID } } : {}),
|
||||
...(model?.variant ? { variant: model.variant } : {}),
|
||||
@@ -160,9 +179,14 @@ export async function executeSync(
|
||||
deps.clearSessionFallbackChain(sessionID)
|
||||
}
|
||||
|
||||
if (sessionID) {
|
||||
clearDelegatedChildSessionBootstrap(sessionID)
|
||||
}
|
||||
|
||||
if (sessionID && createdSessionForExecution) {
|
||||
subagentSessions.delete(sessionID)
|
||||
syncSubagentSessions.delete(sessionID)
|
||||
deleteSessionTools(sessionID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import type { DelegateTaskArgs, OpencodeClient, DelegatedModelConfig } from "./types"
|
||||
import type { SisyphusAgentConfig } from "../../config/schema"
|
||||
import { isPlanFamily } from "./constants"
|
||||
import { buildTaskPrompt } from "./prompt-builder"
|
||||
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 { routePromptRetry, routePromptSyncRetry } from "../../shared/session-route"
|
||||
import { formatDetailedError } from "./error-formatting"
|
||||
import { getAgentToolRestrictions } from "../../shared/agent-tool-restrictions"
|
||||
import { stripInvisibleAgentCharacters } from "../../shared/agent-display-names"
|
||||
import { applySessionPromptParams } from "../../shared/session-prompt-params-helpers"
|
||||
import { routePromptRetry, routePromptSyncRetry } from "../../shared/session-route"
|
||||
import { setSessionTools } from "../../shared/session-tools-store"
|
||||
import { createInternalAgentTextPart } from "../../shared/internal-initiator-marker"
|
||||
import { isPlanFamily } from "./constants"
|
||||
import { formatDetailedError } from "./error-formatting"
|
||||
import { buildTaskPrompt } from "./prompt-builder"
|
||||
import type { DelegatedModelConfig, DelegateTaskArgs, OpencodeClient } from "./types"
|
||||
|
||||
type SendSyncPromptDeps = {
|
||||
promptWithModelSuggestionRetry: typeof promptWithModelSuggestionRetry
|
||||
@@ -52,6 +52,15 @@ function isUnexpectedEofError(error: unknown): boolean {
|
||||
return lowered.includes("unexpected eof") || lowered.includes("json parse error")
|
||||
}
|
||||
|
||||
export function buildSyncPromptTools(agentToUse: string): Record<string, boolean> {
|
||||
return {
|
||||
task: isPlanFamily(agentToUse),
|
||||
call_omo_agent: true,
|
||||
question: false,
|
||||
...getAgentToolRestrictions(agentToUse),
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendSyncPrompt(
|
||||
client: OpencodeClient,
|
||||
input: {
|
||||
@@ -67,15 +76,9 @@ export async function sendSyncPrompt(
|
||||
},
|
||||
deps: SendSyncPromptDeps = sendSyncPromptDeps
|
||||
): Promise<string | null> {
|
||||
const allowTask = isPlanFamily(input.agentToUse)
|
||||
const tddEnabled = input.sisyphusAgentConfig?.tdd
|
||||
const effectivePrompt = buildTaskPrompt(input.args.prompt, input.agentToUse, tddEnabled)
|
||||
const tools = {
|
||||
task: allowTask,
|
||||
call_omo_agent: true,
|
||||
question: false,
|
||||
...getAgentToolRestrictions(input.agentToUse),
|
||||
}
|
||||
const tools = buildSyncPromptTools(input.agentToUse)
|
||||
setSessionTools(input.sessionID, tools)
|
||||
|
||||
applySessionPromptParams(input.sessionID, input.categoryModel)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const { describe, test, expect, beforeEach, afterEach, mock, spyOn } = require("bun:test")
|
||||
import { describe, test, expect, beforeEach, afterEach, mock, spyOn } from "bun:test"
|
||||
|
||||
function clearRequireCache(modulePath: string): void {
|
||||
const resolvedPath = require.resolve(modulePath)
|
||||
@@ -27,6 +27,8 @@ describe("executeSyncTask - cleanup on error paths", () => {
|
||||
addTaskCalls = []
|
||||
deleteCalls = []
|
||||
addCalls = []
|
||||
const { clearAllDelegatedChildSessionBootstrap } = require("../../shared/delegated-child-session-bootstrap")
|
||||
clearAllDelegatedChildSessionBootstrap()
|
||||
|
||||
clearRequireCache("./sync-task")
|
||||
|
||||
@@ -62,6 +64,8 @@ describe("executeSyncTask - cleanup on error paths", () => {
|
||||
mock.restore()
|
||||
resetToastManager?.()
|
||||
resetToastManager = null
|
||||
const { clearAllDelegatedChildSessionBootstrap } = require("../../shared/delegated-child-session-bootstrap")
|
||||
clearAllDelegatedChildSessionBootstrap()
|
||||
})
|
||||
|
||||
test("cleans up toast and subagentSessions when fetchSyncResult returns ok: false", async () => {
|
||||
@@ -664,6 +668,69 @@ describe("executeSyncTask - cleanup on error paths", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("registers child-session bootstrap before sync prompt and clears it after completion", async () => {
|
||||
const mockClient = {
|
||||
session: {
|
||||
create: async () => ({ data: { id: "ignored" } }),
|
||||
},
|
||||
}
|
||||
|
||||
const { executeSyncTask } = require("./sync-task")
|
||||
const { getDelegatedChildSessionBootstrap } = require("../../shared/delegated-child-session-bootstrap")
|
||||
const observedBootstrapPrompts: string[] = []
|
||||
const observedBootstrapSystems: Array<string | undefined> = []
|
||||
const observedBootstrapTools: Array<Record<string, boolean> | undefined> = []
|
||||
|
||||
const deps = {
|
||||
createSyncSession: async () => ({ ok: true as const, sessionID: "ses_bootstrap_sync" }),
|
||||
sendSyncPrompt: async (_client: unknown, input: { sessionID: string }) => {
|
||||
const bootstrap = getDelegatedChildSessionBootstrap(input.sessionID)
|
||||
observedBootstrapPrompts.push(bootstrap?.retryParts[0]?.text ?? "")
|
||||
observedBootstrapSystems.push(bootstrap?.system)
|
||||
observedBootstrapTools.push(bootstrap?.tools)
|
||||
return null
|
||||
},
|
||||
pollSyncSession: async () => null,
|
||||
fetchSyncResult: async () => ({ ok: true as const, textContent: "sync result" }),
|
||||
}
|
||||
|
||||
const mockCtx = {
|
||||
sessionID: "parent-session",
|
||||
callID: "call-123",
|
||||
metadata: () => {},
|
||||
}
|
||||
|
||||
const mockExecutorCtx = {
|
||||
client: mockClient,
|
||||
directory: "/tmp",
|
||||
onSyncSessionCreated: null,
|
||||
modelFallbackControllerAccessor: {
|
||||
setSessionFallbackChain: () => {},
|
||||
clearSessionFallbackChain: () => {},
|
||||
},
|
||||
}
|
||||
|
||||
const args = {
|
||||
prompt: "sync bootstrap prompt",
|
||||
description: "sync bootstrap task",
|
||||
category: "quick",
|
||||
load_skills: [],
|
||||
run_in_background: false,
|
||||
command: null,
|
||||
}
|
||||
|
||||
const result = await executeSyncTask(args, mockCtx, mockExecutorCtx, {
|
||||
sessionID: "parent-session",
|
||||
}, "sisyphus-junior", undefined, "sync delegated skill system", undefined, undefined, deps)
|
||||
|
||||
expect(result).toContain("sync result")
|
||||
expect(observedBootstrapPrompts[0]).toContain("sync bootstrap prompt")
|
||||
expect(observedBootstrapSystems[0]).toBe("sync delegated skill system")
|
||||
expect(observedBootstrapTools[0]?.question).toBe(false)
|
||||
expect(observedBootstrapTools[0]?.call_omo_agent).toBe(true)
|
||||
expect(getDelegatedChildSessionBootstrap("ses_bootstrap_sync")).toBeUndefined()
|
||||
})
|
||||
|
||||
test("replays sync session side effects for retry-created sessions", async () => {
|
||||
const mockClient = {
|
||||
session: {
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
import type { ModelFallbackInfo } from "../../features/task-toast-manager/types"
|
||||
import type { DelegateTaskArgs, ToolContextWithMetadata, DelegatedModelConfig } from "./types"
|
||||
import type { ExecutorContext, ParentContext } from "./executor-types"
|
||||
import { setSessionAgent, subagentSessions, syncSubagentSessions } from "../../features/claude-code-session-state"
|
||||
import { getTaskToastManager } from "../../features/task-toast-manager"
|
||||
import type { ModelFallbackInfo } from "../../features/task-toast-manager/types"
|
||||
import { publishToolMetadata } from "../../features/tool-metadata-store"
|
||||
import { subagentSessions, syncSubagentSessions, setSessionAgent } from "../../features/claude-code-session-state"
|
||||
import { log } from "../../shared/logger"
|
||||
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
|
||||
import { formatDuration } from "./time-formatter"
|
||||
import { formatDetailedError } from "./error-formatting"
|
||||
import { syncTaskDeps, type SyncTaskDeps } from "./sync-task-deps"
|
||||
import { getNextSyncFallbackModel, retrySyncPromptWithFallbacks } from "./sync-task-fallback"
|
||||
import { buildTaskMetadataBlock } from "../../features/tool-metadata-store/task-metadata-contract"
|
||||
import { resolveMetadataModel } from "./resolve-metadata-model"
|
||||
import { shouldRetryError } from "../../shared/model-error-classifier"
|
||||
import type { ModelFallbackState } from "../../hooks/model-fallback/hook"
|
||||
import {
|
||||
clearDelegatedChildSessionBootstrap,
|
||||
registerDelegatedChildSessionBootstrap,
|
||||
} from "../../shared/delegated-child-session-bootstrap"
|
||||
import { log } from "../../shared/logger"
|
||||
import { shouldRetryError } from "../../shared/model-error-classifier"
|
||||
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
|
||||
import { formatDetailedError } from "./error-formatting"
|
||||
import type { ExecutorContext, ParentContext } from "./executor-types"
|
||||
import { buildTaskPrompt } from "./prompt-builder"
|
||||
import { resolveMetadataModel } from "./resolve-metadata-model"
|
||||
import { buildSyncPromptTools } from "./sync-prompt-sender"
|
||||
import { type SyncTaskDeps, syncTaskDeps } from "./sync-task-deps"
|
||||
import { getNextSyncFallbackModel, retrySyncPromptWithFallbacks } from "./sync-task-fallback"
|
||||
import { formatDuration } from "./time-formatter"
|
||||
import type { DelegatedModelConfig, DelegateTaskArgs, ToolContextWithMetadata } from "./types"
|
||||
|
||||
function shouldAttemptPollErrorRecovery(pollError: string): boolean {
|
||||
const trimmed = pollError.trim()
|
||||
@@ -107,11 +113,15 @@ export async function executeSyncTask(
|
||||
subagentSessions.add(newSessionID)
|
||||
syncSubagentSessions.add(newSessionID)
|
||||
setSessionAgent(newSessionID, agentToUse)
|
||||
executorCtx.modelFallbackControllerAccessor?.setSessionFallbackChain(newSessionID, fallbackChain)
|
||||
|
||||
if (args.category) {
|
||||
SessionCategoryRegistry.register(newSessionID, args.category)
|
||||
}
|
||||
registerDelegatedChildSessionBootstrap({
|
||||
sessionID: newSessionID,
|
||||
promptText: buildTaskPrompt(args.prompt, agentToUse, executorCtx.sisyphusAgentConfig?.tdd),
|
||||
fallbackChain,
|
||||
category: args.category,
|
||||
system: systemContent,
|
||||
tools: buildSyncPromptTools(agentToUse),
|
||||
modelFallbackControllerAccessor: executorCtx.modelFallbackControllerAccessor,
|
||||
})
|
||||
|
||||
if (onSyncSessionCreated) {
|
||||
log("[task] Invoking onSyncSessionCreated callback", { sessionID: newSessionID, parentID: parentContext.sessionID })
|
||||
@@ -131,7 +141,6 @@ export async function executeSyncTask(
|
||||
const publishSyncMetadata = async (
|
||||
currentSessionID: string,
|
||||
currentModel: DelegatedModelConfig | undefined,
|
||||
currentTaskId: string,
|
||||
spawnDepth: number,
|
||||
): Promise<void> => {
|
||||
await publishToolMetadata(ctx, {
|
||||
@@ -171,7 +180,7 @@ export async function executeSyncTask(
|
||||
modelInfo,
|
||||
})
|
||||
}
|
||||
await publishSyncMetadata(sessionID, categoryModel, taskId, spawnContext.childDepth)
|
||||
await publishSyncMetadata(sessionID, categoryModel, spawnContext.childDepth)
|
||||
|
||||
const syncPromptInput = {
|
||||
sessionID,
|
||||
@@ -199,6 +208,7 @@ export async function executeSyncTask(
|
||||
const cleanupRetrySession = (currentSessionID: string): void => {
|
||||
subagentSessions.delete(currentSessionID)
|
||||
syncSubagentSessions.delete(currentSessionID)
|
||||
clearDelegatedChildSessionBootstrap(currentSessionID)
|
||||
executorCtx.modelFallbackControllerAccessor?.clearSessionFallbackChain(currentSessionID)
|
||||
SessionCategoryRegistry.remove(currentSessionID)
|
||||
}
|
||||
@@ -304,7 +314,7 @@ export async function executeSyncTask(
|
||||
})
|
||||
}
|
||||
if (taskId) {
|
||||
await publishSyncMetadata(activeSessionID, effectiveCategoryModel, taskId, spawnContext.childDepth)
|
||||
await publishSyncMetadata(activeSessionID, effectiveCategoryModel, spawnContext.childDepth)
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -329,7 +339,7 @@ export async function executeSyncTask(
|
||||
modelRoutingNote = `\nModel: ${actualModelStr}${args.category ? ` (category: ${args.category})` : ""}`
|
||||
}
|
||||
|
||||
await publishSyncMetadata(activeSessionID, effectiveCategoryModel, taskId!, spawnContext.childDepth)
|
||||
await publishSyncMetadata(activeSessionID, effectiveCategoryModel, spawnContext.childDepth)
|
||||
|
||||
return `Task completed in ${duration}.
|
||||
|
||||
@@ -364,6 +374,7 @@ ${buildTaskMetadataBlock({
|
||||
if (syncSessionID) {
|
||||
subagentSessions.delete(syncSessionID)
|
||||
syncSubagentSessions.delete(syncSessionID)
|
||||
clearDelegatedChildSessionBootstrap(syncSessionID)
|
||||
executorCtx.modelFallbackControllerAccessor?.clearSessionFallbackChain(syncSessionID)
|
||||
SessionCategoryRegistry.remove(syncSessionID)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user