fix(background-agent): keep delegated skill, permission, and child agent across retries
Three coupled gaps surfaced after the initial spawn fix: 1. fallback-retry-handler dropped task.skillContent and task.sessionPermission when rebuilding LaunchInput, so the retried background task lost the delegated system prompt and question-deny permission rule. 2. manager.startTask never bound the child sessionID to the resolved agent via setSessionAgent, leaving runtime fallback and other hooks with no idea which agent owned the new child session. 3. The fallback-to-general path in spawner.ts rebuilt the prompt body without going through buildFallbackBody, so bootstrap state, session tools, and session agent updates drifted apart. Persist skillContent and sessionPermission on BackgroundTask, bind setSessionAgent/updateSessionAgent at session creation and on fallback, and route the FALLBACK_AGENT retry through buildFallbackBody so the prompt body, bootstrap tools, and session registries all agree.
This commit is contained in:
@@ -1,10 +1,12 @@
|
||||
import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test"
|
||||
import { tryFallbackRetry, type FallbackRetryHandlerDeps } from "./fallback-retry-handler"
|
||||
import type { FallbackEntry } from "../../shared/model-requirements"
|
||||
import type { ProviderModelsCache } from "../../shared/connected-providers-cache"
|
||||
import { QUESTION_DENIED_SESSION_PERMISSION } from "../../shared/question-denied-session-permission"
|
||||
|
||||
const sharedLogMock = mock(() => {})
|
||||
const readConnectedProvidersCacheMock = mock(() => null)
|
||||
const readProviderModelsCacheMock = mock((): { connected: string[] } | null => null)
|
||||
const readProviderModelsCacheMock = mock((): ProviderModelsCache | null => null)
|
||||
const shouldRetryErrorMock = mock(() => true)
|
||||
const getNextFallbackMock = mock((chain: FallbackEntry[], attempt: number) => chain[attempt])
|
||||
const hasMoreFallbacksMock = mock((chain: FallbackEntry[], attempt: number) => attempt < chain.length)
|
||||
@@ -258,6 +260,20 @@ describe("tryFallbackRetry", () => {
|
||||
expect(retryInput?.onSessionCreated).toBe(onSessionCreated)
|
||||
})
|
||||
|
||||
test("preserves delegated launch context in retry input", async () => {
|
||||
const args = createDefaultArgs({
|
||||
skillContent: "delegated skill system",
|
||||
sessionPermission: QUESTION_DENIED_SESSION_PERMISSION,
|
||||
})
|
||||
|
||||
await tryFallbackRetry(args)
|
||||
|
||||
const key = `${args.task.model!.providerID}/${args.task.model!.modelID}`
|
||||
const retryInput = args.queuesByKey.get(key)?.[0]?.input
|
||||
expect(retryInput?.skillContent).toBe("delegated skill system")
|
||||
expect(retryInput?.sessionPermission).toEqual(QUESTION_DENIED_SESSION_PERMISSION)
|
||||
})
|
||||
|
||||
test("finalizes the failed attempt, creates a new pending attempt, and enqueues its explicit attemptID", async () => {
|
||||
const args = createDefaultArgs({
|
||||
status: "running",
|
||||
@@ -416,7 +432,11 @@ describe("tryFallbackRetry", () => {
|
||||
|
||||
describe("#given disconnected fallback providers with connected preferred provider", () => {
|
||||
test("keeps fallback entry and selects connected preferred provider", async () => {
|
||||
readProviderModelsCacheMock.mockReturnValueOnce({ connected: ["provider-a"] })
|
||||
readProviderModelsCacheMock.mockReturnValueOnce({
|
||||
connected: ["provider-a"],
|
||||
models: {},
|
||||
updatedAt: new Date("2026-05-16T00:00:00.000Z").toISOString(),
|
||||
})
|
||||
selectFallbackProviderMock.mockImplementationOnce(
|
||||
(_providers: string[], preferredProviderID?: string) => preferredProviderID ?? "provider-b",
|
||||
)
|
||||
|
||||
@@ -197,6 +197,8 @@ export async function tryFallbackRetry(args: {
|
||||
teamRunId: task.teamRunId,
|
||||
model: nextModel,
|
||||
fallbackChain: task.fallbackChain,
|
||||
skillContent: task.skillContent,
|
||||
sessionPermission: task.sessionPermission,
|
||||
category: task.category,
|
||||
isUnstableAgent: task.isUnstableAgent,
|
||||
onSessionCreated: task.onSessionCreated,
|
||||
|
||||
@@ -1,25 +1,28 @@
|
||||
declare const require: (name: string) => any
|
||||
const { describe, test, expect, beforeEach, afterEach, afterAll, spyOn, mock } = require("bun:test")
|
||||
|
||||
afterAll(() => { mock.restore() })
|
||||
|
||||
import { getSessionPromptParams, clearSessionPromptParams } from "../../shared/session-prompt-params-state"
|
||||
import { tmpdir } from "node:os"
|
||||
import { describe, test, expect, beforeEach, afterEach, afterAll, spyOn, mock } from "bun:test"
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import * as sharedModule from "../../shared"
|
||||
import { _resetForTesting as resetClaudeCodeSessionState, registerAgentName, subagentSessions } from "../claude-code-session-state"
|
||||
import type { BackgroundTask, ResumeInput } from "./types"
|
||||
import { MIN_IDLE_TIME_MS } from "./constants"
|
||||
import { BackgroundManager } from "./manager"
|
||||
import { ConcurrencyManager } from "./concurrency"
|
||||
import { promptAsyncAfterSessionIdle } from "../../shared/prompt-async-gate"
|
||||
import { initTaskToastManager, _resetTaskToastManagerForTesting } from "../task-toast-manager/manager"
|
||||
import { _resetForTesting as resetProcessCleanupState } from "./process-cleanup"
|
||||
import { clearBackgroundTaskRegistryForTesting } from "./task-registry"
|
||||
import {
|
||||
clearAllDelegatedChildSessionBootstrap,
|
||||
getDelegatedChildSessionBootstrap,
|
||||
} from "../../shared/delegated-child-session-bootstrap"
|
||||
import { promptAsyncAfterSessionIdle } from "../../shared/prompt-async-gate"
|
||||
import { clearSessionPromptParams, getSessionPromptParams } from "../../shared/session-prompt-params-state"
|
||||
import {
|
||||
getSessionAgent,
|
||||
registerAgentName,
|
||||
_resetForTesting as resetClaudeCodeSessionState,
|
||||
subagentSessions,
|
||||
} from "../claude-code-session-state"
|
||||
import { _resetTaskToastManagerForTesting, initTaskToastManager } from "../task-toast-manager/manager"
|
||||
import type { ConcurrencyManager } from "./concurrency"
|
||||
import { MIN_IDLE_TIME_MS } from "./constants"
|
||||
import { BackgroundManager } from "./manager"
|
||||
import { _resetForTesting as resetProcessCleanupState } from "./process-cleanup"
|
||||
import { clearBackgroundTaskRegistryForTesting } from "./task-registry"
|
||||
import type { BackgroundTask, ResumeInput } from "./types"
|
||||
|
||||
afterAll(() => { mock.restore() })
|
||||
|
||||
afterEach(() => {
|
||||
clearBackgroundTaskRegistryForTesting()
|
||||
@@ -194,6 +197,30 @@ function cast<T>(value: unknown): T {
|
||||
return value as T
|
||||
}
|
||||
|
||||
async function expectRejectsWithMessage(promise: Promise<unknown>, expectedMessage: string): Promise<void> {
|
||||
await promise.then(
|
||||
() => {
|
||||
throw new Error(`Expected promise to reject with ${expectedMessage}`)
|
||||
},
|
||||
(error: unknown) => {
|
||||
expect(String(error)).toContain(expectedMessage)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
async function expectResolvesDefined(promise: Promise<unknown>): Promise<void> {
|
||||
const result = await promise
|
||||
expect(result).toBeDefined()
|
||||
}
|
||||
|
||||
async function expectResolvesMatchObject<TActual extends object>(
|
||||
promise: Promise<TActual>,
|
||||
expected: Partial<TActual>,
|
||||
): Promise<void> {
|
||||
const result = await promise
|
||||
expect(result).toMatchObject(expected)
|
||||
}
|
||||
|
||||
function createPluginInput(client: unknown, directory = tmpdir()): PluginInput {
|
||||
return cast<PluginInput>({ client, directory })
|
||||
}
|
||||
@@ -492,6 +519,7 @@ describe("BackgroundManager delegated child-session bootstrap", () => {
|
||||
queuedAt: new Date(),
|
||||
prompt: "background bootstrap prompt",
|
||||
agent: "sisyphus-junior",
|
||||
skillContent: "background delegated skill system",
|
||||
category: "quick",
|
||||
model: { providerID: "anthropic", modelID: "claude-haiku-4-5" },
|
||||
fallbackChain: [{ model: "gpt-5.4", providers: ["openai"], variant: "high" }],
|
||||
@@ -508,6 +536,7 @@ describe("BackgroundManager delegated child-session bootstrap", () => {
|
||||
parentTools: task.parentTools,
|
||||
model: task.model,
|
||||
fallbackChain: task.fallbackChain,
|
||||
skillContent: task.skillContent,
|
||||
category: task.category,
|
||||
}
|
||||
|
||||
@@ -519,6 +548,10 @@ describe("BackgroundManager delegated child-session bootstrap", () => {
|
||||
|
||||
//#then
|
||||
expect(observedBootstrapPrompts[0]).toContain("background bootstrap prompt")
|
||||
const bootstrap = getDelegatedChildSessionBootstrap("ses_background_bootstrap")
|
||||
expect(bootstrap?.system).toBe("background delegated skill system")
|
||||
expect(bootstrap?.tools?.question).toBe(false)
|
||||
expect(bootstrap?.tools?.task).toBe(false)
|
||||
expect(getDelegatedChildSessionBootstrap("ses_background_bootstrap")).toBeDefined()
|
||||
|
||||
const completed = await tryCompleteTaskForTest(manager, task)
|
||||
@@ -708,7 +741,13 @@ describe("BackgroundManager retry observability", () => {
|
||||
|
||||
//#then
|
||||
expect(queuePendingParentWake).toHaveBeenCalledTimes(1)
|
||||
const [sessionID, notification, promptContext, shouldReply] = queuePendingParentWake.mock.calls[0]
|
||||
const retryingCall = cast<Array<[string, string, Record<string, unknown>, boolean]>>(
|
||||
queuePendingParentWake.mock.calls,
|
||||
)[0]
|
||||
if (!retryingCall) {
|
||||
throw new Error("Expected retrying parent wake call")
|
||||
}
|
||||
const [sessionID, notification, promptContext, shouldReply] = retryingCall
|
||||
expect(sessionID).toBe("parent-session")
|
||||
expect(promptContext).toEqual({})
|
||||
expect(shouldReply).toBe(false)
|
||||
@@ -2840,7 +2879,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
const result = manager.launch(input)
|
||||
|
||||
// then
|
||||
await expect(result).rejects.toThrow("Agent parameter is required after sanitization")
|
||||
await expectRejectsWithMessage(result, "Agent parameter is required after sanitization")
|
||||
})
|
||||
|
||||
test("should initialize attempt state for a newly launched task", async () => {
|
||||
@@ -3162,7 +3201,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
const result = manager.launch(input)
|
||||
|
||||
// then
|
||||
await expect(result).rejects.toThrow("background_task.maxDepth=3")
|
||||
await expectRejectsWithMessage(result, "background_task.maxDepth=3")
|
||||
})
|
||||
|
||||
test("allows multiple descendants without a root spawn cap", async () => {
|
||||
@@ -3191,7 +3230,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
const result = manager.launch(input)
|
||||
|
||||
// then
|
||||
await expect(result).resolves.toBeDefined()
|
||||
await expectResolvesDefined(result)
|
||||
})
|
||||
|
||||
test("allows spawn assertions after reserveSubagentSpawn without a root spawn cap", async () => {
|
||||
@@ -3212,7 +3251,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
const result = manager.assertCanSpawn("session-root")
|
||||
|
||||
// then
|
||||
await expect(result).resolves.toMatchObject({
|
||||
await expectResolvesMatchObject(result, {
|
||||
rootSessionID: "session-root",
|
||||
childDepth: 1,
|
||||
})
|
||||
@@ -3245,7 +3284,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
const result = manager.launch(input)
|
||||
|
||||
// then
|
||||
await expect(result).rejects.toThrow("background_task.maxDepth cannot be enforced safely")
|
||||
await expectRejectsWithMessage(result, "background_task.maxDepth cannot be enforced safely")
|
||||
})
|
||||
|
||||
test("allows replacement launch when a queued task is cancelled before session starts", async () => {
|
||||
@@ -3745,7 +3784,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
// Complete via internal method (session.status events go through the poller, not handleEvent)
|
||||
await tryCompleteTaskForTest(manager, internalTask)
|
||||
|
||||
await expect(manager.launch(input)).resolves.toBeDefined()
|
||||
await expectResolvesDefined(manager.launch(input))
|
||||
})
|
||||
|
||||
test("allows relaunch after running task is cancelled", async () => {
|
||||
@@ -3774,7 +3813,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
|
||||
await manager.cancelTask(task.id)
|
||||
|
||||
await expect(manager.launch(input)).resolves.toBeDefined()
|
||||
await expectResolvesDefined(manager.launch(input))
|
||||
})
|
||||
|
||||
test("allows relaunch after task errors", async () => {
|
||||
@@ -3807,7 +3846,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
await expect(manager.launch(input)).resolves.toBeDefined()
|
||||
await expectResolvesDefined(manager.launch(input))
|
||||
})
|
||||
|
||||
test("allows repeated relaunch after pending tasks are cancelled", async () => {
|
||||
@@ -3835,8 +3874,8 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
await manager.cancelTask(task1.id)
|
||||
await manager.cancelTask(task2.id)
|
||||
|
||||
await expect(manager.launch(input)).resolves.toBeDefined()
|
||||
await expect(manager.launch(input)).resolves.toBeDefined()
|
||||
await expectResolvesDefined(manager.launch(input))
|
||||
await expectResolvesDefined(manager.launch(input))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5050,10 +5089,12 @@ describe("BackgroundManager.handleEvent - session.error", () => {
|
||||
|
||||
const mockVerifySessionExists = (manager: BackgroundManager, sessionExists: boolean): void => {
|
||||
verifySessionExistsSpy?.mockRestore()
|
||||
verifySessionExistsSpy = spyOn(
|
||||
const spy = spyOn(
|
||||
cast<{ verifySessionExists: (sessionID: string) => Promise<boolean> }>(manager),
|
||||
"verifySessionExists",
|
||||
).mockResolvedValue(sessionExists)
|
||||
)
|
||||
spy.mockImplementation(async () => sessionExists)
|
||||
verifySessionExistsSpy = spy
|
||||
}
|
||||
|
||||
const stubProcessKey = (manager: BackgroundManager) => {
|
||||
@@ -7072,6 +7113,62 @@ describe("BackgroundManager - tool permission spread order", () => {
|
||||
manager.shutdown()
|
||||
})
|
||||
|
||||
test("startTask updates tracked session agent when launch falls back to general", async () => {
|
||||
//#given
|
||||
const promptCalls: Array<{ path: { id: string }; body: Record<string, unknown> }> = []
|
||||
let promptCallCount = 0
|
||||
const client = {
|
||||
session: {
|
||||
get: async () => ({ data: { directory: "/test/dir" } }),
|
||||
create: async () => ({ data: { id: "session-manager-fallback" } }),
|
||||
promptAsync: async (args: { path: { id: string }; body: Record<string, unknown> }) => {
|
||||
promptCallCount++
|
||||
promptCalls.push(args)
|
||||
if (promptCallCount === 1) {
|
||||
throw new Error("Agent not found: missing-agent")
|
||||
}
|
||||
return {}
|
||||
},
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
|
||||
const task: BackgroundTask = {
|
||||
id: "task-manager-fallback",
|
||||
status: "pending",
|
||||
queuedAt: new Date(),
|
||||
description: "test task",
|
||||
prompt: "test prompt",
|
||||
agent: "missing-agent",
|
||||
parentSessionId: "parent-session",
|
||||
parentMessageId: "parent-message",
|
||||
}
|
||||
const input: import("./types").LaunchInput = {
|
||||
description: task.description,
|
||||
prompt: task.prompt,
|
||||
agent: task.agent,
|
||||
parentSessionId: task.parentSessionId,
|
||||
parentMessageId: task.parentMessageId,
|
||||
}
|
||||
|
||||
try {
|
||||
//#when
|
||||
await (cast<{ startTask: (item: { task: BackgroundTask; input: import("./types").LaunchInput }) => Promise<void> }>(manager))
|
||||
.startTask({ task, input })
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
//#then
|
||||
expect(promptCalls).toHaveLength(2)
|
||||
expect(promptCalls[0].body.agent).toBe("missing-agent")
|
||||
expect(promptCalls[1].body.agent).toBe("general")
|
||||
expect(task.agent).toBe("general")
|
||||
expect(getSessionAgent("session-manager-fallback")).toBe("general")
|
||||
expect(getDelegatedChildSessionBootstrap("session-manager-fallback")?.tools?.call_omo_agent).toBe(true)
|
||||
} finally {
|
||||
manager.shutdown()
|
||||
clearAllDelegatedChildSessionBootstrap()
|
||||
}
|
||||
})
|
||||
|
||||
test("resume respects explore agent restrictions", async () => {
|
||||
//#given
|
||||
let capturedTools: Record<string, unknown> | undefined
|
||||
@@ -7216,6 +7313,7 @@ describe("BackgroundManager.launch - attempt state initialization", () => {
|
||||
describe("BackgroundManager attempt lifecycle bindings", () => {
|
||||
test("startTask binds the created session to the queued attempt ID and mirrors task projection", async () => {
|
||||
//#given
|
||||
resetClaudeCodeSessionState()
|
||||
const client = {
|
||||
session: {
|
||||
get: async () => ({ data: { directory: "/test/dir" } }),
|
||||
@@ -7288,6 +7386,7 @@ describe("BackgroundManager attempt lifecycle bindings", () => {
|
||||
status: "error",
|
||||
error: "first attempt failed",
|
||||
})
|
||||
expect(getSessionAgent("session-attempt-2")).toBe("sisyphus-junior")
|
||||
|
||||
manager.shutdown()
|
||||
})
|
||||
|
||||
@@ -28,7 +28,7 @@ import { SessionCategoryRegistry } from "../../shared/session-category-registry"
|
||||
import { applySessionPromptParams } from "../../shared/session-prompt-params-helpers"
|
||||
import { setSessionTools } from "../../shared/session-tools-store"
|
||||
import { isInsideTmux } from "../../shared/tmux"
|
||||
import { subagentSessions } from "../claude-code-session-state"
|
||||
import { setSessionAgent, subagentSessions, updateSessionAgent } from "../claude-code-session-state"
|
||||
import { MESSAGE_STORAGE } from "../hook-message-injector"
|
||||
import { getTaskToastManager } from "../task-toast-manager"
|
||||
import { abortWithTimeout } from "./abort-with-timeout"
|
||||
@@ -574,6 +574,8 @@ export class BackgroundManager {
|
||||
parentTools: input.parentTools,
|
||||
model: input.model,
|
||||
fallbackChain: input.fallbackChain,
|
||||
skillContent: input.skillContent,
|
||||
sessionPermission: input.sessionPermission,
|
||||
attemptCount: 0,
|
||||
category: input.category,
|
||||
onSessionCreated: input.onSessionCreated,
|
||||
@@ -756,6 +758,7 @@ export class BackgroundManager {
|
||||
await input.onSessionCreated?.(sessionID)
|
||||
this.settlePreStartDescendantReservation(task)
|
||||
subagentSessions.add(sessionID)
|
||||
setSessionAgent(sessionID, input.agent)
|
||||
|
||||
if (this.tasks.get(task.id)?.status === "cancelled") {
|
||||
clearDelegatedChildSessionBootstrap(sessionID)
|
||||
@@ -826,12 +829,39 @@ The fallback retry session is now created and can be inspected directly.
|
||||
this.taskHistory.record(input.parentSessionId, { id: task.id, sessionID, agent: input.agent, description: input.description, status: "running", category: input.category, startedAt: task.startedAt })
|
||||
this.startPolling()
|
||||
|
||||
// Fire-and-forget prompt via promptAsync (no response body needed)
|
||||
// OpenCode prompt payload accepts model provider/model IDs and top-level variant only.
|
||||
// Temperature/topP and provider-specific options are applied through chat.params.
|
||||
const launchModel = input.model
|
||||
? {
|
||||
providerID: input.model.providerID,
|
||||
modelID: input.model.modelID,
|
||||
}
|
||||
: undefined
|
||||
const launchVariant = input.model?.variant
|
||||
|
||||
if (input.model) {
|
||||
applySessionPromptParams(sessionID, input.model)
|
||||
}
|
||||
|
||||
const launchTools = {
|
||||
task: false,
|
||||
call_omo_agent: true,
|
||||
question: false,
|
||||
...getAgentToolRestrictions(input.agent, {
|
||||
includeTeamToolDenylist: input.teamRunId === undefined,
|
||||
}),
|
||||
}
|
||||
setSessionTools(sessionID, launchTools)
|
||||
|
||||
log("[background-agent] Launching task:", { taskId: task.id, sessionID, agent: input.agent })
|
||||
registerDelegatedChildSessionBootstrap({
|
||||
sessionID,
|
||||
promptText: input.prompt,
|
||||
fallbackChain: input.fallbackChain,
|
||||
category: input.category,
|
||||
system: input.skillContent,
|
||||
tools: launchTools,
|
||||
modelFallbackControllerAccessor: this.modelFallbackControllerAccessor,
|
||||
})
|
||||
|
||||
@@ -848,38 +878,12 @@ The fallback retry session is now created and can be inspected directly.
|
||||
promptLength: input.prompt.length,
|
||||
})
|
||||
|
||||
// Fire-and-forget prompt via promptAsync (no response body needed)
|
||||
// OpenCode prompt payload accepts model provider/model IDs and top-level variant only.
|
||||
// Temperature/topP and provider-specific options are applied through chat.params.
|
||||
const launchModel = input.model
|
||||
? {
|
||||
providerID: input.model.providerID,
|
||||
modelID: input.model.modelID,
|
||||
}
|
||||
: undefined
|
||||
const launchVariant = input.model?.variant
|
||||
|
||||
if (input.model) {
|
||||
applySessionPromptParams(sessionID, input.model)
|
||||
}
|
||||
|
||||
const promptBody = {
|
||||
agent: input.agent,
|
||||
...(launchModel ? { model: launchModel } : {}),
|
||||
...(launchVariant ? { variant: launchVariant } : {}),
|
||||
system: input.skillContent,
|
||||
tools: (() => {
|
||||
const tools = {
|
||||
task: false,
|
||||
call_omo_agent: true,
|
||||
question: false,
|
||||
...getAgentToolRestrictions(input.agent, {
|
||||
includeTeamToolDenylist: input.teamRunId === undefined,
|
||||
}),
|
||||
}
|
||||
setSessionTools(sessionID, tools)
|
||||
return tools
|
||||
})(),
|
||||
tools: launchTools,
|
||||
parts: [createInternalAgentTextPart(input.prompt)],
|
||||
}
|
||||
|
||||
@@ -898,7 +902,18 @@ The fallback retry session is now created and can be inspected directly.
|
||||
const fallbackBody = buildFallbackBody(promptBody, FALLBACK_AGENT, {
|
||||
includeTeamToolDenylist: input.teamRunId === undefined,
|
||||
})
|
||||
setSessionTools(sessionID, fallbackBody.tools as Record<string, boolean>)
|
||||
const fallbackTools = fallbackBody.tools as Record<string, boolean>
|
||||
setSessionTools(sessionID, fallbackTools)
|
||||
updateSessionAgent(sessionID, FALLBACK_AGENT)
|
||||
registerDelegatedChildSessionBootstrap({
|
||||
sessionID,
|
||||
promptText: input.prompt,
|
||||
fallbackChain: input.fallbackChain,
|
||||
category: input.category,
|
||||
system: input.skillContent,
|
||||
tools: fallbackTools,
|
||||
modelFallbackControllerAccessor: this.modelFallbackControllerAccessor,
|
||||
})
|
||||
await promptWithRetryInDirectory(this.client, {
|
||||
path: { id: sessionID },
|
||||
body: fallbackBody,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { describe, test, expect, mock, afterEach } from "bun:test"
|
||||
import { createTask, startTask } from "./spawner"
|
||||
import type { BackgroundTask } from "./types"
|
||||
import { afterEach, describe, expect, mock, test } from "bun:test"
|
||||
import {
|
||||
clearSessionPromptParams,
|
||||
getSessionPromptParams,
|
||||
} from "../../shared/session-prompt-params-state"
|
||||
import { createTask, startTask } from "./spawner"
|
||||
import type { BackgroundTask } from "./types"
|
||||
|
||||
describe("background-agent spawner agent-not-found fallback", () => {
|
||||
afterEach(() => {
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import type { BackgroundTask, LaunchInput, ResumeInput } from "./types"
|
||||
import type { OpencodeClient, OnSubagentSessionCreated, QueueItem } from "./constants"
|
||||
import { log, getAgentToolRestrictions, createInternalAgentTextPart, promptWithRetryInDirectory } from "../../shared"
|
||||
import { createInternalAgentTextPart, getAgentToolRestrictions, log, promptWithRetryInDirectory } from "../../shared"
|
||||
import { stripAgentListSortPrefix } from "../../shared/agent-display-names"
|
||||
import { releasePromptAsyncReservation } from "../../shared/prompt-async-gate"
|
||||
import { applySessionPromptParams } from "../../shared/session-prompt-params-helpers"
|
||||
import { subagentSessions } from "../claude-code-session-state"
|
||||
import { getTaskToastManager } from "../task-toast-manager"
|
||||
import { setSessionTools } from "../../shared/session-tools-store"
|
||||
import { isInsideTmux } from "../../shared/tmux"
|
||||
import { stripAgentListSortPrefix } from "../../shared/agent-display-names"
|
||||
import { setSessionAgent, subagentSessions, updateSessionAgent } from "../claude-code-session-state"
|
||||
import { getTaskToastManager } from "../task-toast-manager"
|
||||
import type { ConcurrencyManager } from "./concurrency"
|
||||
import type { OnSubagentSessionCreated, OpencodeClient, QueueItem } from "./constants"
|
||||
import type { BackgroundTask, LaunchInput, ResumeInput } from "./types"
|
||||
|
||||
export const FALLBACK_AGENT = "general"
|
||||
|
||||
@@ -65,7 +66,13 @@ export function createTask(input: LaunchInput): BackgroundTask {
|
||||
teamRunId: input.teamRunId,
|
||||
parentModel: input.parentModel,
|
||||
parentAgent: input.parentAgent,
|
||||
parentTools: input.parentTools,
|
||||
model: input.model,
|
||||
fallbackChain: input.fallbackChain,
|
||||
skillContent: input.skillContent,
|
||||
sessionPermission: input.sessionPermission,
|
||||
category: input.category,
|
||||
isUnstableAgent: input.isUnstableAgent,
|
||||
onSessionCreated: input.onSessionCreated,
|
||||
}
|
||||
}
|
||||
@@ -118,6 +125,7 @@ export async function startTask(
|
||||
const sessionID = createResult.data.id
|
||||
await input.onSessionCreated?.(sessionID)
|
||||
subagentSessions.add(sessionID)
|
||||
setSessionAgent(sessionID, input.agent)
|
||||
|
||||
task.status = "running"
|
||||
task.startedAt = new Date()
|
||||
@@ -170,6 +178,7 @@ export async function startTask(
|
||||
},
|
||||
parts: [createInternalAgentTextPart(input.prompt)],
|
||||
}
|
||||
setSessionTools(sessionID, promptBody.tools)
|
||||
|
||||
// Must fire BEFORE tmux callback: attach client needs session activity to render TUI.
|
||||
const promptChain = promptWithRetryInDirectory(client, {
|
||||
@@ -184,11 +193,15 @@ export async function startTask(
|
||||
})
|
||||
try {
|
||||
releasePromptAsyncReservation(sessionID, "model-suggestion-retry")
|
||||
const fallbackBody = buildFallbackBody(promptBody, FALLBACK_AGENT, {
|
||||
includeTeamToolDenylist: input.teamRunId === undefined,
|
||||
})
|
||||
const fallbackTools = fallbackBody.tools as Record<string, boolean>
|
||||
setSessionTools(sessionID, fallbackTools)
|
||||
updateSessionAgent(sessionID, FALLBACK_AGENT)
|
||||
await promptWithRetryInDirectory(client, {
|
||||
path: { id: sessionID },
|
||||
body: buildFallbackBody(promptBody, FALLBACK_AGENT, {
|
||||
includeTeamToolDenylist: input.teamRunId === undefined,
|
||||
}),
|
||||
body: fallbackBody,
|
||||
}, parentDirectory)
|
||||
task.agent = FALLBACK_AGENT
|
||||
return
|
||||
@@ -310,6 +323,7 @@ export async function resumeTask(
|
||||
},
|
||||
parts: [createInternalAgentTextPart(input.prompt)],
|
||||
}
|
||||
setSessionTools(sessionID, resumeBody.tools)
|
||||
|
||||
promptWithRetryInDirectory(client, {
|
||||
path: { id: sessionID },
|
||||
@@ -323,11 +337,15 @@ export async function resumeTask(
|
||||
})
|
||||
try {
|
||||
releasePromptAsyncReservation(sessionID, "model-suggestion-retry")
|
||||
const fallbackBody = buildFallbackBody(resumeBody, FALLBACK_AGENT, {
|
||||
includeTeamToolDenylist: task.teamRunId === undefined,
|
||||
})
|
||||
const fallbackTools = fallbackBody.tools as Record<string, boolean>
|
||||
setSessionTools(sessionID, fallbackTools)
|
||||
updateSessionAgent(sessionID, FALLBACK_AGENT)
|
||||
await promptWithRetryInDirectory(client, {
|
||||
path: { id: sessionID },
|
||||
body: buildFallbackBody(resumeBody, FALLBACK_AGENT, {
|
||||
includeTeamToolDenylist: task.teamRunId === undefined,
|
||||
}),
|
||||
body: fallbackBody,
|
||||
}, directory)
|
||||
task.agent = FALLBACK_AGENT
|
||||
return
|
||||
|
||||
@@ -73,6 +73,8 @@ export interface BackgroundTask {
|
||||
parentAgent?: string
|
||||
/** Parent session's tool restrictions for notification prompts */
|
||||
parentTools?: Record<string, boolean>
|
||||
skillContent?: string
|
||||
sessionPermission?: SessionPermissionRule[]
|
||||
/** Marks if the task was launched from an unstable agent/category */
|
||||
isUnstableAgent?: boolean
|
||||
/** Category used for this task (e.g., 'quick', 'visual-engineering') */
|
||||
|
||||
Reference in New Issue
Block a user