diff --git a/src/agents/agent-builder.test.ts b/src/agents/agent-builder.test.ts index 56abb8ac7..f9be614aa 100644 --- a/src/agents/agent-builder.test.ts +++ b/src/agents/agent-builder.test.ts @@ -1,16 +1,15 @@ -import { describe, test, expect } from "bun:test" +import { describe, expect, test } from "bun:test" import { buildAgent } from "./agent-builder" import type { AgentFactory } from "./types" describe("#given an agent factory with mode", () => { - const mockFactory = ((model: string) => ({ + const mockFactory: AgentFactory = Object.assign((model: string) => ({ name: "test-agent", description: "Test", instructions: "test", model, temperature: 0.1, - })) as AgentFactory - mockFactory.mode = "subagent" + }), { mode: "subagent" as const }) test("#when building agent from factory", () => { const agent = buildAgent(mockFactory, "test-model") @@ -19,14 +18,13 @@ describe("#given an agent factory with mode", () => { }) describe("#given an agent factory with mode=primary", () => { - const mockFactory = ((model: string) => ({ + const mockFactory: AgentFactory = Object.assign((model: string) => ({ name: "primary-agent", description: "Primary Test", instructions: "test", model, temperature: 0.1, - })) as AgentFactory - mockFactory.mode = "primary" + }), { mode: "primary" as const }) test("#when building agent from factory", () => { const agent = buildAgent(mockFactory, "test-model") @@ -50,15 +48,14 @@ describe("#given an agent config object without mode", () => { }) describe("#given an agent factory with mode but config already has mode", () => { - const mockFactory = ((model: string) => ({ + const mockFactory: AgentFactory = Object.assign((model: string) => ({ name: "override-agent", description: "Override Test", instructions: "test", model, temperature: 0.1, - mode: "all", - })) as AgentFactory - mockFactory.mode = "subagent" + mode: "all" as const, + }), { mode: "subagent" as const }) test("#when building agent from factory", () => { const agent = buildAgent(mockFactory, "test-model") diff --git a/src/agents/builtin-agents/available-skills.test.ts b/src/agents/builtin-agents/available-skills.test.ts index 2505af5bb..fe5ea8441 100644 --- a/src/agents/builtin-agents/available-skills.test.ts +++ b/src/agents/builtin-agents/available-skills.test.ts @@ -2,10 +2,12 @@ import { describe, expect, test } from "bun:test" import { buildAvailableSkills } from "./available-skills" +type DiscoveredSkills = Parameters[0] + describe("buildAvailableSkills", () => { test("includes team-mode when team mode is enabled", () => { // given - const discoveredSkills = [] + const discoveredSkills: DiscoveredSkills = [] // when const availableSkills = buildAvailableSkills(discoveredSkills, undefined, undefined, true) @@ -16,7 +18,7 @@ describe("buildAvailableSkills", () => { test("excludes team-mode when team mode is disabled", () => { // given - const discoveredSkills = [] + const discoveredSkills: DiscoveredSkills = [] // when const availableSkills = buildAvailableSkills(discoveredSkills, undefined, undefined, false) diff --git a/src/features/background-agent/fallback-retry-handler.test.ts b/src/features/background-agent/fallback-retry-handler.test.ts index 53b7b7ef8..fc86f554f 100644 --- a/src/features/background-agent/fallback-retry-handler.test.ts +++ b/src/features/background-agent/fallback-retry-handler.test.ts @@ -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", ) diff --git a/src/features/background-agent/fallback-retry-handler.ts b/src/features/background-agent/fallback-retry-handler.ts index 3cf1453ef..92a92de95 100644 --- a/src/features/background-agent/fallback-retry-handler.ts +++ b/src/features/background-agent/fallback-retry-handler.ts @@ -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, diff --git a/src/features/background-agent/manager.test.ts b/src/features/background-agent/manager.test.ts index 3adbab455..edb79de57 100644 --- a/src/features/background-agent/manager.test.ts +++ b/src/features/background-agent/manager.test.ts @@ -1,21 +1,32 @@ -declare const require: (name: string) => any -const { describe, test, expect, beforeEach, afterEach, afterAll, spyOn, mock } = require("bun:test") +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 { + 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() }) -import { getSessionPromptParams, clearSessionPromptParams } from "../../shared/session-prompt-params-state" -import { tmpdir } from "node:os" -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" - +afterEach(() => { + clearBackgroundTaskRegistryForTesting() +}) const TASK_TTL_MS = 30 * 60 * 1000 type PendingParentWakeForTest = { @@ -186,6 +197,30 @@ function cast(value: unknown): T { return value as T } +async function expectRejectsWithMessage(promise: Promise, expectedMessage: string): Promise { + await promise.then( + () => { + throw new Error(`Expected promise to reject with ${expectedMessage}`) + }, + (error: unknown) => { + expect(String(error)).toContain(expectedMessage) + }, + ) +} + +async function expectResolvesDefined(promise: Promise): Promise { + const result = await promise + expect(result).toBeDefined() +} + +async function expectResolvesMatchObject( + promise: Promise, + expected: Partial, +): Promise { + const result = await promise + expect(result).toMatchObject(expected) +} + function createPluginInput(client: unknown, directory = tmpdir()): PluginInput { return cast({ client, directory }) } @@ -458,6 +493,77 @@ describe("BackgroundManager session.error fallback hydration", () => { }) }) +describe("BackgroundManager delegated child-session bootstrap", () => { + test("registers launch bootstrap before first prompt and clears it after completion", async () => { + //#given + clearAllDelegatedChildSessionBootstrap() + const observedBootstrapPrompts: string[] = [] + const client = { + session: { + get: async () => ({ data: { directory: tmpdir() } }), + create: async () => ({ data: { id: "ses_background_bootstrap" } }), + promptAsync: async () => { + const bootstrap = getDelegatedChildSessionBootstrap("ses_background_bootstrap") + observedBootstrapPrompts.push(bootstrap?.retryParts[0]?.text ?? "") + return {} + }, + abort: async () => ({}), + }, + } + const manager = new BackgroundManager({ pluginContext: createPluginInput(client) }) + stubNotifyParentSession(manager) + const task = createMockTask({ + id: "bg_bootstrap", + parentSessionId: "parent-session", + status: "pending", + 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" }], + }) + getTaskMap(manager).set(task.id, task) + const input = { + description: task.description, + prompt: task.prompt, + agent: task.agent, + parentSessionId: task.parentSessionId, + parentMessageId: task.parentMessageId, + parentModel: task.parentModel, + parentAgent: task.parentAgent, + parentTools: task.parentTools, + model: task.model, + fallbackChain: task.fallbackChain, + skillContent: task.skillContent, + category: task.category, + } + + try { + //#when + await (cast<{ startTask: (item: { task: BackgroundTask; input: typeof input }) => Promise }>(manager)) + .startTask({ task, input }) + await flushBackgroundNotifications() + + //#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) + expect(completed).toBe(true) + expect(getDelegatedChildSessionBootstrap("ses_background_bootstrap")).toBeUndefined() + } finally { + manager.shutdown() + clearAllDelegatedChildSessionBootstrap() + } + }) +}) + describe("BackgroundManager prompt rejection fallback routing", () => { test("routes launch-time prompt rejections into tryFallbackRetry before marking interrupt", async () => { //#given @@ -635,7 +741,13 @@ describe("BackgroundManager retry observability", () => { //#then expect(queuePendingParentWake).toHaveBeenCalledTimes(1) - const [sessionID, notification, promptContext, shouldReply] = queuePendingParentWake.mock.calls[0] + const retryingCall = cast, 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) @@ -2767,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 () => { @@ -3089,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 () => { @@ -3118,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 () => { @@ -3139,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, }) @@ -3172,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 () => { @@ -3672,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 () => { @@ -3701,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 () => { @@ -3734,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 () => { @@ -3762,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)) }) }) @@ -4977,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 }>(manager), "verifySessionExists", - ).mockResolvedValue(sessionExists) + ) + spy.mockImplementation(async () => sessionExists) + verifySessionExistsSpy = spy } const stubProcessKey = (manager: BackgroundManager) => { @@ -6721,6 +6835,157 @@ describe("BackgroundManager regression fixes - resume and aborted notification", manager.shutdown() }) + test("should resolve a completed task registered by an earlier plugin manager instance", () => { + //#given + const firstManager = createBackgroundManager() + const secondManager = createBackgroundManager() + const task: BackgroundTask = { + id: "task-cross-manager-regression", + sessionId: "session-cross-manager-regression", + parentSessionId: "parent-session", + parentMessageId: "msg-1", + description: "cross manager regression", + prompt: "test", + agent: "explore", + status: "completed", + startedAt: new Date(), + completedAt: new Date(), + } + + //#when + ;(cast<{ addTask: (task: BackgroundTask) => void }>(firstManager)).addTask(task) + + //#then + const resolvedTask = secondManager.getTask(task.id) + expect(resolvedTask?.sessionId).toBe(task.sessionId) + + firstManager.shutdown() + secondManager.shutdown() + }) + + test("should redact active task prompts resolved from an earlier plugin manager instance", () => { + //#given + const firstManager = createBackgroundManager() + const secondManager = createBackgroundManager() + const task: BackgroundTask = { + id: "task-cross-manager-active-redaction", + parentSessionId: "parent-session", + parentMessageId: "msg-1", + description: "cross manager active redaction", + prompt: "secret prompt", + agent: "explore", + status: "pending", + queuedAt: new Date(), + } + + //#when + ;(cast<{ addTask: (task: BackgroundTask) => void }>(firstManager)).addTask(task) + task.sessionId = "session-cross-manager-active-redaction" + task.status = "running" + task.startedAt = new Date() + task.progress = { + lastUpdate: new Date(), + toolCalls: 1, + countedToolPartIDs: new Set(["part-1"]), + } + + //#then + const localTask = firstManager.getTask(task.id) + const registeredTask = secondManager.getTask(task.id) + expect(localTask?.prompt).toBe("secret prompt") + expect(registeredTask?.sessionId).toBe(task.sessionId) + expect(registeredTask?.prompt).toBe("[redacted]") + expect(registeredTask?.progress?.countedToolPartIDs).toEqual(new Set(["part-1"])) + + firstManager.shutdown() + secondManager.shutdown() + }) + + test("should resolve archived completed task from an earlier plugin manager instance", () => { + //#given + const firstManager = createBackgroundManager() + const secondManager = createBackgroundManager() + const task: BackgroundTask = { + id: "task-cross-manager-archive-regression", + sessionId: "session-cross-manager-archive-regression", + parentSessionId: "parent-session", + parentMessageId: "msg-1", + description: "cross manager archive regression", + prompt: "sensitive prompt", + agent: "explore", + status: "completed", + startedAt: new Date(), + completedAt: new Date(), + } + getTaskMap(firstManager).set(task.id, task) + + //#when + ;(cast<{ removeTask: (task: BackgroundTask) => void }>(firstManager)).removeTask(task) + + //#then + const resolvedTask = secondManager.getTask(task.id) + expect(resolvedTask?.sessionId).toBe(task.sessionId) + expect(resolvedTask?.prompt).toBe("[redacted]") + + firstManager.shutdown() + secondManager.shutdown() + }) + + test("should archive terminal registry tasks during earlier manager shutdown", async () => { + //#given + const firstManager = createBackgroundManager() + const secondManager = createBackgroundManager() + const task: BackgroundTask = { + id: "task-shutdown-archive-regression", + sessionId: "session-shutdown-archive-regression", + parentSessionId: "parent-session", + parentMessageId: "msg-1", + description: "shutdown archive regression", + prompt: "sensitive shutdown prompt", + agent: "explore", + status: "completed", + startedAt: new Date(), + completedAt: new Date(), + } + ;(cast<{ addTask: (task: BackgroundTask) => void }>(firstManager)).addTask(task) + + //#when + await firstManager.shutdown() + + //#then + const resolvedTask = secondManager.getTask(task.id) + expect(resolvedTask?.sessionId).toBe(task.sessionId) + expect(resolvedTask?.prompt).toBe("[redacted]") + + await secondManager.shutdown() + }) + + test("should forget active registry tasks during earlier manager shutdown", async () => { + //#given + const firstManager = createBackgroundManager() + const secondManager = createBackgroundManager() + const task: BackgroundTask = { + id: "task-shutdown-active-regression", + sessionId: "session-shutdown-active-regression", + parentSessionId: "parent-session", + parentMessageId: "msg-1", + description: "shutdown active regression", + prompt: "test", + agent: "explore", + status: "running", + startedAt: new Date(), + } + ;(cast<{ addTask: (task: BackgroundTask) => void }>(firstManager)).addTask(task) + + //#when + await firstManager.shutdown() + + //#then + expect(secondManager.getTask(task.id)).toBeUndefined() + + await secondManager.shutdown() + }) + test("should cap completed task archive size at 100 entries", () => { //#given const manager = createBackgroundManager() @@ -6848,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 }> = [] + 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 }) => { + 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 }>(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 | undefined @@ -6992,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" } }), @@ -7064,6 +7386,68 @@ describe("BackgroundManager attempt lifecycle bindings", () => { status: "error", error: "first attempt failed", }) + expect(getSessionAgent("session-attempt-2")).toBe("sisyphus-junior") + + manager.shutdown() + }) + + test("startTask clears child session agent state when task is cancelled before launch binding", async () => { + //#given + resetClaudeCodeSessionState() + const sessionID = "session-cancelled-prelaunch" + const client = { + session: { + get: async () => ({ data: { directory: "/test/dir" } }), + create: async () => ({ data: { id: sessionID } }), + promptAsync: async () => ({}), + abort: async () => ({}), + }, + } + const manager = new BackgroundManager({ pluginContext: createPluginInput(client) }) + const task: BackgroundTask = { + id: "task-cancel-prelaunch", + status: "pending", + queuedAt: new Date(), + description: "cancel before bind", + prompt: "continue", + agent: "sisyphus-junior", + parentSessionId: "parent-session", + parentMessageId: "parent-message", + model: { providerID: "anthropic", modelID: "claude-haiku-4.5" }, + attempts: [ + { + attemptId: "attempt-1", + attemptNumber: 1, + providerId: "anthropic", + modelId: "claude-haiku-4.5", + status: "pending", + }, + ], + currentAttemptID: "attempt-1", + attemptCount: 1, + } + const input: import("./types").LaunchInput = { + description: task.description, + prompt: task.prompt, + agent: task.agent, + parentSessionId: task.parentSessionId, + parentMessageId: task.parentMessageId, + model: task.model, + onSessionCreated: async () => { + // simulate parent flipping task to cancelled between create and bind + task.status = "cancelled" + const internal = cast<{ tasks: Map }>(manager) + internal.tasks.set(task.id, task) + }, + } + + //#when + await (cast<{ + startTask: (item: { task: BackgroundTask; input: import("./types").LaunchInput; attemptID: string }) => Promise + }>(manager)).startTask({ task, input, attemptID: "attempt-1" }) + + //#then + expect(getSessionAgent(sessionID)).toBeUndefined() manager.shutdown() }) diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index fab28ccd5..a93a7f6b6 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -1,99 +1,107 @@ - +import { join } from "node:path" import type { PluginInput } from "@opencode-ai/plugin" +import type { BackgroundTaskConfig, TmuxConfig } from "../../config/schema" +import { setContinuationMarkerSource } from "../../features/run-continuation-state" import type { ModelFallbackControllerAccessor } from "../../hooks/model-fallback" -import { isAgentNotFoundError, FALLBACK_AGENT, buildFallbackBody } from "./spawner" -import type { - BackgroundTask, - BackgroundTaskAttempt, - LaunchInput, - ResumeInput, -} from "./types" -import { TaskHistory } from "./task-history" +import { type PromptAsyncGateResult, promptAsyncAfterSessionIdle } from "../../hooks/shared/prompt-async-gate" +import { isSessionActive as isOpenCodeSessionActive } from "../../hooks/shared/session-idle-settle" import { - log, + createInternalAgentTextPart, getAgentToolRestrictions, + log, + messagesInDirectory, normalizePromptTools, normalizeSDKResponse, - resolveInheritedPromptTools, - createInternalAgentTextPart, - messagesInDirectory, promptWithRetryInDirectory, + resolveInheritedPromptTools, } from "../../shared" +import { + clearDelegatedChildSessionBootstrap, + registerDelegatedChildSessionBootstrap, +} from "../../shared/delegated-child-session-bootstrap" import { resolveMessageEventSessionID, resolveSessionEventID } from "../../shared/event-session-id" +import { + hasMoreFallbacks, + shouldRetryError, +} from "../../shared/model-error-classifier" +import { SessionCategoryRegistry } from "../../shared/session-category-registry" import { applySessionPromptParams } from "../../shared/session-prompt-params-helpers" import { setSessionTools } from "../../shared/session-tools-store" -import { SessionCategoryRegistry } from "../../shared/session-category-registry" -import { ConcurrencyManager } from "./concurrency" -import type { BackgroundTaskConfig, TmuxConfig } from "../../config/schema" import { isInsideTmux } from "../../shared/tmux" -import { - shouldRetryError, - hasMoreFallbacks, -} from "../../shared/model-error-classifier" -import { - POLLING_INTERVAL_MS, - TASK_CLEANUP_DELAY_MS, - TASK_TTL_MS, - type QueueItem, -} from "./constants" - -import { subagentSessions } from "../claude-code-session-state" +import { clearSessionAgent, setSessionAgent, subagentSessions, updateSessionAgent } from "../claude-code-session-state" +import { MESSAGE_STORAGE } from "../hook-message-injector" import { getTaskToastManager } from "../task-toast-manager" -import { formatDuration } from "./duration-formatter" -import { - buildBackgroundTaskNotificationText, - type BackgroundTaskNotificationTask, -} from "./background-task-notification-template" -import { - isAbortedSessionError, - extractErrorName, - extractErrorMessage, - extractErrorStatusCode, - getSessionErrorMessage, - isRecord, -} from "./error-classifier" -import { tryFallbackRetry } from "./fallback-retry-handler" +import { abortWithTimeout } from "./abort-with-timeout" import { bindAttemptSession, ensureCurrentAttempt, - findAttemptBySession, finalizeAttempt, + findAttemptBySession, getCurrentAttempt, startAttempt, } from "./attempt-lifecycle" -import { registerManagerForCleanup, unregisterManagerForCleanup } from "./process-cleanup" -import { setContinuationMarkerSource } from "../../features/run-continuation-state" -import { isSessionActive as isOpenCodeSessionActive } from "../../hooks/shared/session-idle-settle" -import { promptAsyncAfterSessionIdle, type PromptAsyncGateResult } from "../../hooks/shared/prompt-async-gate" +import { + type BackgroundTaskNotificationTask, + buildBackgroundTaskNotificationText, +} from "./background-task-notification-template" import { findNearestMessageExcludingCompaction, resolvePromptContextFromSessionMessages, } from "./compaction-aware-message-resolver" -import { handleSessionIdleBackgroundEvent } from "./session-idle-event-handler" -import { MESSAGE_STORAGE } from "../hook-message-injector" -import { join } from "node:path" -import { pruneStaleTasksAndNotifications, type SessionStatusMap } from "./task-poller" -import { checkAndInterruptStaleTasks } from "./task-poller" +import { ConcurrencyManager } from "./concurrency" +import { + POLLING_INTERVAL_MS, + type QueueItem, + TASK_CLEANUP_DELAY_MS, + TASK_TTL_MS, +} from "./constants" +import { formatDuration } from "./duration-formatter" +import { + extractErrorMessage, + extractErrorName, + extractErrorStatusCode, + getSessionErrorMessage, + isAbortedSessionError, + isRecord, +} from "./error-classifier" +import { tryFallbackRetry } from "./fallback-retry-handler" +import { + type CircuitBreakerSettings, + detectRepetitiveToolUse, + recordToolCall, + resolveCircuitBreakerSettings, +} from "./loop-detector" +import { ParentWakeNotifier, type ParentWakePromptContext } from "./parent-wake-notifier" +import { registerManagerForCleanup, unregisterManagerForCleanup } from "./process-cleanup" import { removeTaskToastTracking } from "./remove-task-toast-tracking" -import { abortWithTimeout } from "./abort-with-timeout" import { MIN_SESSION_GONE_POLLS, verifySessionExists as verifySessionStillExists, } from "./session-existence" +import { handleSessionIdleBackgroundEvent } from "./session-idle-event-handler" import { isActiveSessionStatus, isTerminalSessionStatus } from "./session-status-classifier" -import { - detectRepetitiveToolUse, - recordToolCall, - resolveCircuitBreakerSettings, - type CircuitBreakerSettings, -} from "./loop-detector" +import { buildFallbackBody, FALLBACK_AGENT, isAgentNotFoundError } from "./spawner" import { createSubagentDepthLimitError, getMaxSubagentDepth, resolveSubagentSpawnContext, type SubagentSpawnContext, } from "./subagent-spawn-limits" -import { ParentWakeNotifier, type ParentWakePromptContext } from "./parent-wake-notifier" +import { TaskHistory } from "./task-history" +import { checkAndInterruptStaleTasks, pruneStaleTasksAndNotifications, type SessionStatusMap } from "./task-poller" +import { + archiveBackgroundTask, + forgetBackgroundTask, + getRegisteredBackgroundTask, + rememberBackgroundTask, +} from "./task-registry" +import type { + BackgroundTask, + BackgroundTaskAttempt, + LaunchInput, + ResumeInput, +} from "./types" + type OpencodeClient = PluginInput["client"] type ResumeTaskSnapshot = { @@ -111,6 +119,13 @@ type ResumeTaskSnapshot = { concurrencyGroup?: string } +const TERMINAL_BACKGROUND_TASK_STATUSES = new Set([ + "completed", + "error", + "cancelled", + "interrupt", +]) + const PENDING_PARENT_WAKE_RETRY_MS = 1_000 const PENDING_PARENT_WAKE_DEBOUNCE_MS = 100 const PARENT_WAKE_ACCEPTED_MESSAGE_SKEW_MS = 5_000 @@ -376,6 +391,7 @@ export class BackgroundManager { private addTask(task: BackgroundTask): void { this.completedTaskArchive.delete(task.id) this.tasks.set(task.id, task) + rememberBackgroundTask(task) if (!task.parentSessionId) { return } @@ -387,6 +403,7 @@ export class BackgroundManager { private removeTask(task: BackgroundTask): void { this.archiveCompletedTask(task) + archiveBackgroundTask(task) this.tasks.delete(task.id) this.removeTaskFromParentIndex(task.id, task.parentSessionId) } @@ -557,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, @@ -659,6 +678,7 @@ export class BackgroundManager { // Abort the orphaned session if one was created before the error if (item.task.sessionId) { + clearDelegatedChildSessionBootstrap(item.task.sessionId) await this.abortSessionWithLogging(item.task.sessionId, "startTask error cleanup") } @@ -729,6 +749,7 @@ export class BackgroundManager { const sessionID = createResult.data.id if (task.status === "cancelled") { + clearDelegatedChildSessionBootstrap(sessionID) await this.abortSessionWithLogging(sessionID, "cancelled pre-start cleanup") this.concurrencyManager.release(concurrencyKey) return @@ -737,8 +758,11 @@ 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) + clearSessionAgent(sessionID) await this.abortSessionWithLogging(sessionID, "cancelled during launch setup") subagentSessions.delete(sessionID) if (task.rootSessionId) { @@ -750,6 +774,8 @@ export class BackgroundManager { const boundAttempt = bindAttemptSession(task, attemptID, sessionID, input.model) if (!boundAttempt) { + clearDelegatedChildSessionBootstrap(sessionID) + clearSessionAgent(sessionID) await this.abortSessionWithLogging(sessionID, "stale attempt binding cleanup") subagentSessions.delete(sessionID) if (task.rootSessionId) { @@ -805,21 +831,6 @@ 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() - log("[background-agent] Launching task:", { taskId: task.id, sessionID, agent: input.agent }) - - const toastManager = getTaskToastManager() - if (toastManager) { - toastManager.updateTask(task.id, "running") - } - - log("[background-agent] Calling prompt (fire-and-forget) for launch with:", { - sessionID, - agent: input.agent, - model: input.model, - hasSkillContent: !!input.skillContent, - 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. @@ -835,23 +846,46 @@ The fallback retry session is now created and can be inspected directly. 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, + }) + + const toastManager = getTaskToastManager() + if (toastManager) { + toastManager.updateTask(task.id, "running") + } + + log("[background-agent] Calling prompt (fire-and-forget) for launch with:", { + sessionID, + agent: input.agent, + model: input.model, + hasSkillContent: !!input.skillContent, + promptLength: input.prompt.length, + }) + 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)], } @@ -870,7 +904,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) + const fallbackTools = fallbackBody.tools as Record + 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, @@ -926,6 +971,7 @@ The fallback retry session is now created and can be inspected directly. // Abort the session to prevent infinite polling hang // Awaited to prevent dangling promise during subagent teardown (Bun/WebKit SIGABRT) + clearDelegatedChildSessionBootstrap(sessionID) await this.abortSessionWithLogging(sessionID, "launch error cleanup") this.markForNotification(existingTask) @@ -960,7 +1006,7 @@ The fallback retry session is now created and can be inspected directly. } getTask(id: string): BackgroundTask | undefined { - return this.tasks.get(id) ?? this.completedTaskArchive.get(id) + return this.tasks.get(id) ?? this.completedTaskArchive.get(id) ?? getRegisteredBackgroundTask(id) } getTasksByParentSession(sessionID: string): BackgroundTask[] { @@ -1313,6 +1359,7 @@ The fallback retry session is now created and can be inspected directly. // Abort the session to prevent infinite polling hang // Awaited to prevent dangling promise during subagent teardown (Bun/WebKit SIGABRT) if (existingTask.sessionId) { + clearDelegatedChildSessionBootstrap(existingTask.sessionId) await this.abortSessionWithLogging(existingTask.sessionId, "resume error cleanup") } @@ -1650,6 +1697,7 @@ The fallback retry session is now created and can be inspected directly. } this.rootDescendantCounts.delete(sessionID) + clearDelegatedChildSessionBootstrap(sessionID) SessionCategoryRegistry.remove(sessionID) } @@ -1732,6 +1780,7 @@ The fallback retry session is now created and can be inspected directly. this.scheduleTaskRemoval(task.id) if (task.sessionId) { + clearDelegatedChildSessionBootstrap(task.sessionId) SessionCategoryRegistry.remove(task.sessionId) await this.abortSessionWithLogging(task.sessionId, `${reason} cleanup`) } @@ -1838,6 +1887,7 @@ The fallback retry session is now created and can be inspected directly. } this.scheduleTaskRemoval(task.id) if (task.sessionId) { + clearDelegatedChildSessionBootstrap(task.sessionId) SessionCategoryRegistry.remove(task.sessionId) } @@ -1895,6 +1945,7 @@ The task was re-queued on a fallback model after a retryable failure. if (retried && previousSessionID) { this.clearSessionOutputObserved(previousSessionID) this.clearSessionTodoObservation(previousSessionID) + clearDelegatedChildSessionBootstrap(previousSessionID) subagentSessions.delete(previousSessionID) } return retried @@ -2059,6 +2110,7 @@ The task was re-queued on a fallback model after a retryable failure. this.clearTaskHistoryWhenParentTasksGone(task.parentSessionId) if (task.sessionId) { subagentSessions.delete(task.sessionId) + clearDelegatedChildSessionBootstrap(task.sessionId) SessionCategoryRegistry.remove(task.sessionId) } log("[background-agent] Removed completed task from memory:", taskId) @@ -2134,6 +2186,7 @@ The task was re-queued on a fallback model after a retryable failure. // Awaited to prevent dangling promise during subagent teardown (Bun/WebKit SIGABRT) await this.abortSessionWithLogging(task.sessionId, `task cancellation (${source})`) + clearDelegatedChildSessionBootstrap(task.sessionId) SessionCategoryRegistry.remove(task.sessionId) } @@ -2259,6 +2312,7 @@ The task was re-queued on a fallback model after a retryable failure. // Awaited to prevent dangling promise during subagent teardown (Bun/WebKit SIGABRT) await this.abortSessionWithLogging(task.sessionId, `task completion (${source})`) + clearDelegatedChildSessionBootstrap(task.sessionId) SessionCategoryRegistry.remove(task.sessionId) } @@ -2588,6 +2642,7 @@ The task was re-queued on a fallback model after a retryable failure. removeTaskToastTracking(task.id) this.scheduleTaskRemoval(task.id) if (task.sessionId) { + clearDelegatedChildSessionBootstrap(task.sessionId) SessionCategoryRegistry.remove(task.sessionId) } @@ -2775,6 +2830,12 @@ The task was re-queued on a fallback model after a retryable failure. // Release concurrency for all running tasks for (const task of this.tasks.values()) { + if (TERMINAL_BACKGROUND_TASK_STATUSES.has(task.status)) { + archiveBackgroundTask(task) + } else { + forgetBackgroundTask(task.id) + } + if (task.concurrencyKey) { this.concurrencyManager.release(task.concurrencyKey) task.concurrencyKey = undefined @@ -2795,6 +2856,7 @@ The task was re-queued on a fallback model after a retryable failure. for (const sessionID of trackedSessionIDs) { subagentSessions.delete(sessionID) + clearDelegatedChildSessionBootstrap(sessionID) SessionCategoryRegistry.remove(sessionID) } diff --git a/src/features/background-agent/spawner.test.ts b/src/features/background-agent/spawner.test.ts index f19c6ea7b..558cbefb7 100644 --- a/src/features/background-agent/spawner.test.ts +++ b/src/features/background-agent/spawner.test.ts @@ -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(() => { @@ -694,6 +694,67 @@ describe("background-agent spawner fallback model promotion", () => { expect(promptCalls).toHaveLength(1) expect(promptCalls[0]?.body?.agent).toBe("Hephaestus - Deep Agent") }) + + test("persists the same normalized agent used by promptAsync into session-agent state (GH-3259 follow-up)", async () => { + //#given - ZWSP+sort-prefix wrapped agent name + const promptCalls: Array<{ body?: { agent?: string } }> = [] + const sessionID = "ses_child_normalized" + const wrappedAgent = "\u200B\u200B5|Hephaestus - Deep Agent" + + const client = { + session: { + get: async () => ({ data: { directory: "/parent/dir" } }), + create: async () => ({ data: { id: sessionID } }), + promptAsync: async (args?: { body?: { agent?: string } }) => { + promptCalls.push(args ?? {}) + return {} + }, + }, + } + + const { _resetForTesting: resetState, getSessionAgent } = await import("../claude-code-session-state") + resetState() + + const task = createTask({ + description: "Normalized agent storage", + prompt: "Do work", + agent: wrappedAgent, + parentSessionId: "ses_parent", + parentMessageId: "msg_parent", + }) + + const item = { + task, + input: { + description: task.description, + prompt: task.prompt, + agent: task.agent, + parentSessionId: task.parentSessionId, + parentMessageId: task.parentMessageId, + parentModel: task.parentModel, + parentAgent: task.parentAgent, + model: task.model, + }, + } + + const ctx = { + client, + directory: "/fallback", + concurrencyManager: { release: () => {} }, + tmuxEnabled: false, + onTaskError: () => {}, + } + + //#when + await startTask(item as never, ctx as never) + await new Promise((resolve) => setTimeout(resolve, 0)) + + //#then + expect(promptCalls).toHaveLength(1) + const dispatchedAgent = promptCalls[0]?.body?.agent + expect(dispatchedAgent).toBe("Hephaestus - Deep Agent") + expect(getSessionAgent(sessionID)).toBe(dispatchedAgent) + }) }) describe("background-agent spawner tmux callback ordering", () => { diff --git a/src/features/background-agent/spawner.ts b/src/features/background-agent/spawner.ts index e690c2f18..cb2961aea 100644 --- a/src/features/background-agent/spawner.ts +++ b/src/features/background-agent/spawner.ts @@ -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, } } @@ -116,8 +123,10 @@ export async function startTask( } const sessionID = createResult.data.id + const normalizedAgent = stripAgentListSortPrefix(input.agent) await input.onSessionCreated?.(sessionID) subagentSessions.add(sessionID) + setSessionAgent(sessionID, normalizedAgent) task.status = "running" task.startedAt = new Date() @@ -129,7 +138,7 @@ export async function startTask( task.concurrencyKey = concurrencyKey task.concurrencyGroup = concurrencyKey - log("[background-agent] Launching task:", { taskId: task.id, sessionID, agent: input.agent }) + log("[background-agent] Launching task:", { taskId: task.id, sessionID, agent: normalizedAgent }) const toastManager = getTaskToastManager() if (toastManager) { @@ -138,7 +147,7 @@ export async function startTask( log("[background-agent] Calling prompt (fire-and-forget) for launch with:", { sessionID, - agent: input.agent, + agent: normalizedAgent, model: input.model, hasSkillContent: !!input.skillContent, promptLength: input.prompt.length, @@ -151,7 +160,6 @@ export async function startTask( } : undefined const launchVariant = input.model?.variant - const normalizedAgent = stripAgentListSortPrefix(input.agent) applySessionPromptParams(sessionID, input.model) @@ -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 + 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 + 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 diff --git a/src/features/background-agent/task-registry.ts b/src/features/background-agent/task-registry.ts new file mode 100644 index 000000000..8a027e667 --- /dev/null +++ b/src/features/background-agent/task-registry.ts @@ -0,0 +1,137 @@ +import type { BackgroundTask } from "./types" + +const MAX_COMPLETED_TASK_REGISTRY_SIZE = 100 +const REGISTRY_KEY = "__omoBackgroundTaskRegistry" + +type BackgroundTaskRegistry = { + activeTasks: Map BackgroundTask> + completedTasks: Map +} + +type GlobalWithBackgroundTaskRegistry = typeof globalThis & { + [REGISTRY_KEY]?: BackgroundTaskRegistry +} + +const TERMINAL_TASK_STATUSES = new Set([ + "completed", + "error", + "cancelled", + "interrupt", +]) + +function getRegistry(): BackgroundTaskRegistry { + const registryGlobal = globalThis as GlobalWithBackgroundTaskRegistry + registryGlobal[REGISTRY_KEY] ??= { + activeTasks: new Map BackgroundTask>(), + completedTasks: new Map(), + } + const registry = registryGlobal[REGISTRY_KEY] + return registry +} + +function cloneProgress(progress: BackgroundTask["progress"]): BackgroundTask["progress"] { + if (!progress) { + return undefined + } + + return { + ...progress, + countedToolPartIDs: progress.countedToolPartIDs ? new Set(progress.countedToolPartIDs) : undefined, + } +} + +function cloneAttempts(attempts: BackgroundTask["attempts"]): BackgroundTask["attempts"] { + if (!attempts) { + return undefined + } + + return attempts.map((attempt) => ({ ...attempt })) +} + +function cloneRegisteredTask(task: BackgroundTask): BackgroundTask { + return { + id: task.id, + rootSessionId: task.rootSessionId, + parentSessionId: task.parentSessionId, + parentMessageId: task.parentMessageId, + teamRunId: task.teamRunId, + description: task.description, + prompt: "[redacted]", + agent: task.agent, + spawnDepth: task.spawnDepth, + sessionId: task.sessionId, + status: task.status, + queuedAt: task.queuedAt, + startedAt: task.startedAt, + completedAt: task.completedAt, + result: task.result, + progress: cloneProgress(task.progress), + parentModel: task.parentModel, + model: task.model, + fallbackChain: task.fallbackChain, + attemptCount: task.attemptCount, + concurrencyKey: task.concurrencyKey, + concurrencyGroup: task.concurrencyGroup, + parentAgent: task.parentAgent, + parentTools: task.parentTools, + isUnstableAgent: task.isUnstableAgent, + error: task.error, + category: task.category, + retryNotification: task.retryNotification ? { ...task.retryNotification } : undefined, + attempts: cloneAttempts(task.attempts), + currentAttemptID: task.currentAttemptID, + lastMsgCount: task.lastMsgCount, + stablePolls: task.stablePolls, + consecutiveMissedPolls: task.consecutiveMissedPolls, + } +} + +function trimCompletedTasks(registry: BackgroundTaskRegistry): void { + while (registry.completedTasks.size > MAX_COMPLETED_TASK_REGISTRY_SIZE) { + const oldestTaskID = registry.completedTasks.keys().next().value + if (typeof oldestTaskID !== "string") { + return + } + registry.completedTasks.delete(oldestTaskID) + } +} + +export function rememberBackgroundTask(task: BackgroundTask): void { + const registry = getRegistry() + registry.completedTasks.delete(task.id) + registry.activeTasks.set(task.id, () => cloneRegisteredTask(task)) +} + +export function archiveBackgroundTask(task: BackgroundTask): void { + const registry = getRegistry() + registry.activeTasks.delete(task.id) + registry.completedTasks.delete(task.id) + if (!task.sessionId || !TERMINAL_TASK_STATUSES.has(task.status)) { + return + } + registry.completedTasks.set(task.id, cloneRegisteredTask(task)) + trimCompletedTasks(registry) +} + +export function getRegisteredBackgroundTask(taskID: string): BackgroundTask | undefined { + const registry = getRegistry() + const activeTask = registry.activeTasks.get(taskID) + if (activeTask) { + return activeTask() + } + + const completedTask = registry.completedTasks.get(taskID) + return completedTask ? cloneRegisteredTask(completedTask) : undefined +} + +export function forgetBackgroundTask(taskID: string): void { + const registry = getRegistry() + registry.activeTasks.delete(taskID) + registry.completedTasks.delete(taskID) +} + +export function clearBackgroundTaskRegistryForTesting(): void { + const registry = getRegistry() + registry.activeTasks.clear() + registry.completedTasks.clear() +} diff --git a/src/features/background-agent/types.ts b/src/features/background-agent/types.ts index 76c242a41..f864a3fa3 100644 --- a/src/features/background-agent/types.ts +++ b/src/features/background-agent/types.ts @@ -73,6 +73,8 @@ export interface BackgroundTask { parentAgent?: string /** Parent session's tool restrictions for notification prompts */ parentTools?: Record + 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') */ diff --git a/src/hooks/runtime-fallback/auto-retry.ts b/src/hooks/runtime-fallback/auto-retry.ts index 73b2105d6..74d7932b5 100644 --- a/src/hooks/runtime-fallback/auto-retry.ts +++ b/src/hooks/runtime-fallback/auto-retry.ts @@ -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++ diff --git a/src/hooks/runtime-fallback/index.test.ts b/src/hooks/runtime-fallback/index.test.ts index dbb4dc227..2f1949781 100644 --- a/src/hooks/runtime-fallback/index.test.ts +++ b/src/hooks/runtime-fallback/index.test.ts @@ -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 status?: () => Promise } - }) { - return unsafeTestValue({ + }): RuntimeFallbackPluginInput { + return unsafeTestValue({ 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> = [] + const hook = createRuntimeFallbackHook( + createMockPluginInput({ + session: { + messages: async () => ({ data: [] }), + promptAsync: async (args) => { + promptCalls.push(args as Record) + 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 + 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> = [] + 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) + 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 + } | 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 }), diff --git a/src/hooks/runtime-fallback/last-user-retry-parts.ts b/src/hooks/runtime-fallback/last-user-retry-parts.ts index 899572a73..38a73eeec 100644 --- a/src/hooks/runtime-fallback/last-user-retry-parts.ts +++ b/src/hooks/runtime-fallback/last-user-retry-parts.ts @@ -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 +} 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 } : {}), + } } diff --git a/src/hooks/runtime-fallback/types.ts b/src/hooks/runtime-fallback/types.ts index c05bfe952..aaa6436e1 100644 --- a/src/hooks/runtime-fallback/types.ts +++ b/src/hooks/runtime-fallback/types.ts @@ -16,6 +16,8 @@ export interface RuntimeFallbackPluginInput { body: { agent?: string model: { providerID: string; modelID: string } + system?: string + tools?: Record parts: Array<{ type: "text"; text: string }> } query: { directory: string } diff --git a/src/hooks/shared/prompt-async-gate.test.ts b/src/hooks/shared/prompt-async-gate.test.ts index b21abd163..450b70de4 100644 --- a/src/hooks/shared/prompt-async-gate.test.ts +++ b/src/hooks/shared/prompt-async-gate.test.ts @@ -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" }, + }) + }) }) diff --git a/src/shared/delegated-child-session-bootstrap.ts b/src/shared/delegated-child-session-bootstrap.ts new file mode 100644 index 000000000..a9d360829 --- /dev/null +++ b/src/shared/delegated-child-session-bootstrap.ts @@ -0,0 +1,85 @@ +import type { ModelFallbackControllerAccessor } from "../hooks/model-fallback" +import { createInternalAgentTextPart } from "./internal-initiator-marker" +import type { FallbackEntry } from "./model-requirements" +import { SessionCategoryRegistry } from "./session-category-registry" + +export type DelegatedChildSessionRetryPart = { + type: "text" + text: string +} + +export type DelegatedChildSessionBootstrap = { + retryParts: DelegatedChildSessionRetryPart[] + fallbackChain?: FallbackEntry[] + category?: string + system?: string + tools?: Record +} + +const delegatedChildSessionBootstraps = new Map() + +function cloneRetryParts(parts: DelegatedChildSessionRetryPart[]): DelegatedChildSessionRetryPart[] { + return parts.map((part) => ({ type: part.type, text: part.text })) +} + +function cloneFallbackChain(fallbackChain: FallbackEntry[] | undefined): FallbackEntry[] | undefined { + return fallbackChain?.map((entry) => ({ + ...entry, + providers: [...entry.providers], + })) +} + +function cloneTools(tools: Record | undefined): Record | undefined { + return tools ? { ...tools } : undefined +} + +export function registerDelegatedChildSessionBootstrap(_args: { + sessionID: string + promptText: string + fallbackChain?: FallbackEntry[] + category?: string + system?: string + tools?: Record + modelFallbackControllerAccessor?: ModelFallbackControllerAccessor +}): void { + const retryParts = [createInternalAgentTextPart(_args.promptText)] + const fallbackChain = cloneFallbackChain(_args.fallbackChain) + const tools = cloneTools(_args.tools) + delegatedChildSessionBootstraps.set(_args.sessionID, { + retryParts, + ...(fallbackChain ? { fallbackChain } : {}), + ...(_args.category ? { category: _args.category } : {}), + ...(_args.system ? { system: _args.system } : {}), + ...(tools ? { tools } : {}), + }) + + _args.modelFallbackControllerAccessor?.setSessionFallbackChain(_args.sessionID, fallbackChain) + if (_args.category) { + SessionCategoryRegistry.register(_args.sessionID, _args.category) + } +} + +export function getDelegatedChildSessionBootstrap(_sessionID: string): DelegatedChildSessionBootstrap | undefined { + const bootstrap = delegatedChildSessionBootstraps.get(_sessionID) + if (!bootstrap) { + return undefined + } + + const fallbackChain = cloneFallbackChain(bootstrap.fallbackChain) + const tools = cloneTools(bootstrap.tools) + return { + retryParts: cloneRetryParts(bootstrap.retryParts), + ...(fallbackChain ? { fallbackChain } : {}), + ...(bootstrap.category ? { category: bootstrap.category } : {}), + ...(bootstrap.system ? { system: bootstrap.system } : {}), + ...(tools ? { tools } : {}), + } +} + +export function clearDelegatedChildSessionBootstrap(_sessionID: string): void { + delegatedChildSessionBootstraps.delete(_sessionID) +} + +export function clearAllDelegatedChildSessionBootstrap(): void { + delegatedChildSessionBootstraps.clear() +} diff --git a/src/shared/prompt-async-gate.ts b/src/shared/prompt-async-gate.ts index 6a967c5e9..3822ccf11 100644 --- a/src/shared/prompt-async-gate.ts +++ b/src/shared/prompt-async-gate.ts @@ -217,12 +217,13 @@ export async function promptAsyncAfterSessionIdle(arg } = args const postDispatchHoldMs = args.postDispatchHoldMs ?? DEFAULT_PROMPT_ASYNC_POST_DISPATCH_HOLD_MS const dispatchTimeoutMs = args.dispatchTimeoutMs ?? DEFAULT_PROMPT_DISPATCH_TIMEOUT_MS - const promptAsync = client.session?.promptAsync + const session = client.session - if (typeof promptAsync !== "function") { + if (typeof session?.promptAsync !== "function") { log("[prompt-async-gate] promptAsync unavailable", { sessionID, source }) return { status: "unavailable" } } + const dispatchPromptAsync = session.promptAsync.bind(session) return dispatchAfterSessionIdle({ sessionName: "promptAsync", @@ -234,7 +235,7 @@ export async function promptAsyncAfterSessionIdle(arg postDispatchHoldMs, dispatchTimeoutMs, checkStatus: args.checkStatus !== false, - dispatch: (dispatchInput) => promptAsync(dispatchInput), + dispatch: (dispatchInput) => dispatchPromptAsync(dispatchInput), }) } @@ -257,12 +258,13 @@ export async function promptAfterSessionIdle(args: { } = args const postDispatchHoldMs = args.postDispatchHoldMs ?? DEFAULT_PROMPT_ASYNC_POST_DISPATCH_HOLD_MS const dispatchTimeoutMs = args.dispatchTimeoutMs ?? DEFAULT_PROMPT_DISPATCH_TIMEOUT_MS - const prompt = client.session?.prompt + const session = client.session - if (typeof prompt !== "function") { + if (typeof session?.prompt !== "function") { log("[prompt-async-gate] prompt unavailable", { sessionID, source }) return { status: "unavailable" } } + const dispatchPrompt = session.prompt.bind(session) return dispatchAfterSessionIdle({ sessionName: "prompt", @@ -274,7 +276,7 @@ export async function promptAfterSessionIdle(args: { postDispatchHoldMs, dispatchTimeoutMs, checkStatus: args.checkStatus !== false, - dispatch: (dispatchInput) => prompt(dispatchInput), + dispatch: (dispatchInput) => dispatchPrompt(dispatchInput), }) } diff --git a/src/tools/call-omo-agent/sync-executor.test.ts b/src/tools/call-omo-agent/sync-executor.test.ts index 3b0e0db5f..58d56a16d 100644 --- a/src/tools/call-omo-agent/sync-executor.test.ts +++ b/src/tools/call-omo-agent/sync-executor.test.ts @@ -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 } } @@ -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 | undefined + bootstrap: ReturnType + }> = [] + 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() diff --git a/src/tools/call-omo-agent/sync-executor.ts b/src/tools/call-omo-agent/sync-executor.ts index dfb47b62b..59a802263 100644 --- a/src/tools/call-omo-agent/sync-executor.ts +++ b/src/tools/call-omo-agent/sync-executor.ts @@ -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 }) => Promise @@ -58,6 +63,14 @@ function buildPromptGenerationParams(model: DelegatedModelConfig | undefined): R } } +function buildSyncPromptTools(agent: string): Record { + 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) } } } diff --git a/src/tools/delegate-task/sync-prompt-sender.ts b/src/tools/delegate-task/sync-prompt-sender.ts index bfdac5fba..a67e0db39 100644 --- a/src/tools/delegate-task/sync-prompt-sender.ts +++ b/src/tools/delegate-task/sync-prompt-sender.ts @@ -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 { + 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 { - 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) diff --git a/src/tools/delegate-task/sync-task.test.ts b/src/tools/delegate-task/sync-task.test.ts index e1d792cfe..b7ff4a7db 100644 --- a/src/tools/delegate-task/sync-task.test.ts +++ b/src/tools/delegate-task/sync-task.test.ts @@ -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 = [] + const observedBootstrapTools: Array | 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: { diff --git a/src/tools/delegate-task/sync-task.ts b/src/tools/delegate-task/sync-task.ts index 44a6f65ef..4b2f8dd87 100644 --- a/src/tools/delegate-task/sync-task.ts +++ b/src/tools/delegate-task/sync-task.ts @@ -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 => { 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) }