diff --git a/src/features/background-agent/manager-session-permission.test.ts b/src/features/background-agent/manager-session-permission.test.ts index fb34ee0aa..b50687442 100644 --- a/src/features/background-agent/manager-session-permission.test.ts +++ b/src/features/background-agent/manager-session-permission.test.ts @@ -7,6 +7,38 @@ import { BackgroundManager } from "./manager" import { unsafeTestValue } from "../../../test-support/unsafe-test-value" describe("BackgroundManager session permission", () => { + test("passes parent directory route when prompting the child session", async () => { + // given + const promptCalls: Array> = [] + const client = { + session: { + get: async () => ({ data: { directory: "/parent" } }), + create: async () => ({ data: { id: "ses_child" } }), + promptAsync: async (input: Record) => { + promptCalls.push(input) + return {} + }, + abort: async () => ({}), + }, + } + const manager = new BackgroundManager({ pluginContext: unsafeTestValue({ client, directory: tmpdir() }) }) + + // when + await manager.launch({ + description: "Test task", + prompt: "Do something", + agent: "explore", + parentSessionId: "ses_parent", + parentMessageId: "msg_parent", + }) + await new Promise(resolve => setTimeout(resolve, 50)) + manager.shutdown() + + // then + expect(promptCalls).toHaveLength(1) + expect(promptCalls[0]?.query).toEqual({ directory: "/parent" }) + }) + test("passes query directory when loading the parent session", async () => { // given const getCalls: Array> = [] diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index 180c0a5fe..ae963a28d 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -14,9 +14,11 @@ import { getAgentToolRestrictions, normalizePromptTools, normalizeSDKResponse, - promptWithModelSuggestionRetry, resolveInheritedPromptTools, createInternalAgentTextPart, + messagesInDirectory, + promptAsyncInDirectory, + promptWithRetryInDirectory, } from "../../shared" import { resolveMessageEventSessionID, resolveSessionEventID } from "../../shared/event-session-id" import { applySessionPromptParams } from "../../shared/session-prompt-params-helpers" @@ -89,7 +91,6 @@ import { resolveSubagentSpawnContext, type SubagentSpawnContext, } from "./subagent-spawn-limits" - type OpencodeClient = PluginInput["client"] type ParentWakePromptContext = { @@ -781,10 +782,10 @@ The fallback retry session is now created and can be inspected directly. parts: [createInternalAgentTextPart(input.prompt)], } - promptWithModelSuggestionRetry(this.client, { + promptWithRetryInDirectory(this.client, { path: { id: sessionID }, body: promptBody, - }).catch(async (error) => { + }, parentDirectory).catch(async (error) => { // Retry with fallback agent if the original agent was unregistered (e.g., after a model switch) if (isAgentNotFoundError(error) && input.agent !== FALLBACK_AGENT) { log("[background-agent] Agent not found, retrying with fallback agent", { @@ -797,10 +798,10 @@ The fallback retry session is now created and can be inspected directly. includeTeamToolDenylist: input.teamRunId === undefined, }) setSessionTools(sessionID, fallbackBody.tools as Record) - await promptWithModelSuggestionRetry(this.client, { + await promptWithRetryInDirectory(this.client, { path: { id: sessionID }, body: fallbackBody, - }) + }, parentDirectory) task.agent = FALLBACK_AGENT return } catch (retryError) { @@ -1145,7 +1146,7 @@ The fallback retry session is now created and can be inspected directly. applySessionPromptParams(existingTask.sessionId!, existingTask.model) } - this.client.session.promptAsync({ + promptAsyncInDirectory(this.client, { path: { id: existingTask.sessionId }, body: { agent: existingTask.agent, @@ -1165,7 +1166,7 @@ The fallback retry session is now created and can be inspected directly. })(), parts: [createInternalAgentTextPart(input.prompt)], }, - }).catch(async (error) => { + }, this.directory).catch(async (error) => { log("[background-agent] resume prompt error:", error) const errorInfo = { name: extractErrorName(error), @@ -1741,9 +1742,9 @@ The task was re-queued on a fallback model after a retryable failure. } try { - const response = await this.client.session.messages({ + const response = await messagesInDirectory(this.client, { path: { id: sessionID }, - }) + }, this.directory) const messages = normalizeSDKResponse(response, [] as Array<{ info?: { role?: string } }>, { preferResponseOnMissingData: true }) @@ -2152,7 +2153,9 @@ The task was re-queued on a fallback model after a retryable failure. if (this.enableParentSessionNotifications) { try { - const messagesResp = await this.client.session.messages({ path: { id: task.parentSessionId } }) + const messagesResp = await messagesInDirectory(this.client, { + path: { id: task.parentSessionId }, + }, this.directory) const messages = normalizeSDKResponse(messagesResp, [] as Array<{ info?: { agent?: string @@ -2214,14 +2217,14 @@ The task was re-queued on a fallback model after a retryable failure. ...(resolvedTools ? { tools: resolvedTools } : {}), } try { - await this.client.session.promptAsync({ + await promptAsyncInDirectory(this.client, { path: { id: task.parentSessionId }, body: { noReply: !shouldReply, ...parentPromptContext, parts: [createInternalAgentTextPart(notification)], }, - }) + }, this.directory) log("[background-agent] Sent notification to parent session:", { taskId: task.id, allComplete, diff --git a/src/features/background-agent/spawner.test.ts b/src/features/background-agent/spawner.test.ts index dc0ed17d0..f19c6ea7b 100644 --- a/src/features/background-agent/spawner.test.ts +++ b/src/features/background-agent/spawner.test.ts @@ -535,6 +535,58 @@ describe("background-agent spawner fallback model promotion", () => { ]) }) + test("passes parent directory route when prompting the child session", async () => { + // given + const promptCalls: Array> = [] + + const client = { + session: { + get: async () => ({ data: { directory: "/parent/dir" } }), + create: async () => ({ data: { id: "ses_child_query" } }), + promptAsync: async (input: Record) => { + promptCalls.push(input) + return {} + }, + }, + } + + const task = createTask({ + description: "Test task", + prompt: "Do work", + agent: "sisyphus-junior", + 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, + }, + } + + // when + await startTask(item as never, { + client: client as never, + directory: "/fallback", + concurrencyManager: { release: () => {} } as never, + tmuxEnabled: false, + onTaskError: () => {}, + }) + await new Promise((resolve) => setTimeout(resolve, 0)) + + // then + expect(promptCalls).toHaveLength(1) + expect(promptCalls[0]?.query).toEqual({ directory: "/parent/dir" }) + }) + test("strips leading zwsp from prompt body agent before promptAsync", async () => { //#given const promptCalls: Array<{ body?: { agent?: string } }> = [] diff --git a/src/features/background-agent/spawner.ts b/src/features/background-agent/spawner.ts index bfbe675da..7be63ede1 100644 --- a/src/features/background-agent/spawner.ts +++ b/src/features/background-agent/spawner.ts @@ -1,6 +1,6 @@ import type { BackgroundTask, LaunchInput, ResumeInput } from "./types" import type { OpencodeClient, OnSubagentSessionCreated, QueueItem } from "./constants" -import { log, getAgentToolRestrictions, promptWithModelSuggestionRetry, createInternalAgentTextPart } from "../../shared" +import { log, getAgentToolRestrictions, createInternalAgentTextPart, promptWithRetryInDirectory } from "../../shared" import { applySessionPromptParams } from "../../shared/session-prompt-params-helpers" import { subagentSessions } from "../claude-code-session-state" import { getTaskToastManager } from "../task-toast-manager" @@ -171,10 +171,10 @@ export async function startTask( } // Must fire BEFORE tmux callback: attach client needs session activity to render TUI. - const promptChain = promptWithModelSuggestionRetry(client, { + const promptChain = promptWithRetryInDirectory(client, { path: { id: sessionID }, body: promptBody, - }).catch(async (error) => { + }, parentDirectory).catch(async (error) => { if (isAgentNotFoundError(error) && input.agent !== FALLBACK_AGENT) { log("[background-agent] Agent not found, retrying with fallback agent", { original: input.agent, @@ -182,12 +182,12 @@ export async function startTask( taskId: task.id, }) try { - await promptWithModelSuggestionRetry(client, { + await promptWithRetryInDirectory(client, { path: { id: sessionID }, body: buildFallbackBody(promptBody, FALLBACK_AGENT, { includeTeamToolDenylist: input.teamRunId === undefined, }), - }) + }, parentDirectory) task.agent = FALLBACK_AGENT return } catch (retryError) { @@ -227,18 +227,19 @@ export async function startTask( export async function resumeTask( task: BackgroundTask, input: ResumeInput, - ctx: Pick + ctx: Pick ): Promise { - const { client, concurrencyManager, onTaskError } = ctx + const { client, concurrencyManager, directory, onTaskError } = ctx if (!task.sessionId) { throw new Error(`Task has no sessionID: ${task.id}`) } + const sessionID = task.sessionId if (task.status === "running") { log("[background-agent] Resume skipped - task already running:", { taskId: task.id, - sessionID: task.sessionId, + sessionID, }) return } @@ -262,7 +263,7 @@ export async function resumeTask( lastUpdate: new Date(), } - subagentSessions.add(task.sessionId) + subagentSessions.add(sessionID) const toastManager = getTaskToastManager() if (toastManager) { @@ -274,10 +275,10 @@ export async function resumeTask( }) } - log("[background-agent] Resuming task:", { taskId: task.id, sessionID: task.sessionId }) + log("[background-agent] Resuming task:", { taskId: task.id, sessionID }) log("[background-agent] Resuming task - calling prompt (fire-and-forget) with:", { - sessionID: task.sessionId, + sessionID, agent: task.agent, model: task.model, promptLength: input.prompt.length, @@ -291,7 +292,7 @@ export async function resumeTask( : undefined const resumeVariant = task.model?.variant - applySessionPromptParams(task.sessionId, task.model) + applySessionPromptParams(sessionID, task.model) const resumeBody = { agent: task.agent, @@ -308,10 +309,10 @@ export async function resumeTask( parts: [createInternalAgentTextPart(input.prompt)], } - client.session.promptAsync({ - path: { id: task.sessionId }, + promptWithRetryInDirectory(client, { + path: { id: sessionID }, body: resumeBody, - }).catch(async (error) => { + }, directory).catch(async (error) => { if (isAgentNotFoundError(error) && task.agent !== FALLBACK_AGENT) { log("[background-agent] Resume agent not found, retrying with fallback agent", { original: task.agent, @@ -319,12 +320,12 @@ export async function resumeTask( taskId: task.id, }) try { - await promptWithModelSuggestionRetry(client, { - path: { id: task.sessionId! }, + await promptWithRetryInDirectory(client, { + path: { id: sessionID }, body: buildFallbackBody(resumeBody, FALLBACK_AGENT, { includeTeamToolDenylist: task.teamRunId === undefined, }), - }) + }, directory) task.agent = FALLBACK_AGENT return } catch (retryError) { diff --git a/src/features/background-agent/task-completion-cleanup.test.ts b/src/features/background-agent/task-completion-cleanup.test.ts index 0212a5e17..e68e3dfb7 100644 --- a/src/features/background-agent/task-completion-cleanup.test.ts +++ b/src/features/background-agent/task-completion-cleanup.test.ts @@ -12,6 +12,9 @@ type PromptAsyncCall = { noReply?: boolean parts?: unknown[] } + query?: { + directory: string + } } type FakeTimers = { @@ -274,6 +277,24 @@ describe("BackgroundManager.notifyParentSession cleanup scheduling", () => { expect(notificationPayload).toContain(OMO_INTERNAL_INITIATOR_MARKER) }) + test("#when all-complete notification wakes parent #then prompt stays in the same OpenCode directory instance", async () => { + // given + const { manager, promptAsyncCalls } = createManager(true) + managerUnderTest = manager + const directory = Reflect.get(manager, "directory") as string + const task = createTask({ id: "task-a", parentSessionId: "parent-1", description: "task A", status: "completed", completedAt: new Date("2026-03-11T00:01:00.000Z") }) + getTasks(manager).set(task.id, task) + getPendingByParent(manager).set(task.parentSessionId, new Set([task.id])) + + // when + await notifyParentSessionForTest(manager, task) + + // then + expect(promptAsyncCalls).toHaveLength(1) + expect(promptAsyncCalls[0]?.body.noReply).toBe(false) + expect(promptAsyncCalls[0]?.query).toEqual({ directory }) + }) + test("#when busy parent later becomes idle #then completion notification is not replayed as a second parent prompt", async () => { // given const sessionStatuses: Record = { diff --git a/src/shared/index.ts b/src/shared/index.ts index dd637ce77..9b29c6fbe 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -71,6 +71,7 @@ export * from "./project-discovery-dirs" export * from "./normalize-sdk-response" export * from "./record-type-guard" export * from "./session-directory-resolver" +export * from "./session-route" export * from "./prompt-tools" export * from "./compaction-marker" export * from "./internal-initiator-marker" diff --git a/src/shared/session-route.ts b/src/shared/session-route.ts new file mode 100644 index 000000000..53901cf23 --- /dev/null +++ b/src/shared/session-route.ts @@ -0,0 +1,80 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import { + promptSyncWithModelSuggestionRetry, + promptWithModelSuggestionRetry, +} from "./model-suggestion-retry" + +type OpencodeClient = PluginInput["client"] + +type PromptAsyncArgs = Parameters[0] +type SessionMessagesArgs = Parameters[0] +type PromptRetryClient = Parameters[0] +type PromptRetryArgs = Parameters[1] +type PromptSyncRetryClient = Parameters[0] +type PromptSyncRetryArgs = Parameters[1] + +export function routeSessionPrompt(args: PromptAsyncArgs, directory: string): PromptAsyncArgs { + return { + ...args, + query: { directory }, + } +} + +export function routePromptRetry(args: PromptRetryArgs, directory: string): PromptRetryArgs { + return { + ...args, + query: { directory }, + } +} + +export function routePromptSyncRetry( + args: PromptSyncRetryArgs, + directory: string, +): PromptSyncRetryArgs { + return { + ...args, + query: { directory }, + } +} + +export function routeSessionMessages( + args: SessionMessagesArgs, + directory: string, +): SessionMessagesArgs { + return { + ...args, + query: { directory }, + } +} + +export function promptAsyncInDirectory( + client: OpencodeClient, + args: PromptAsyncArgs, + directory: string, +): Promise { + return client.session.promptAsync(routeSessionPrompt(args, directory)) +} + +export function promptWithRetryInDirectory( + client: PromptRetryClient, + args: PromptRetryArgs, + directory: string, +): Promise { + return promptWithModelSuggestionRetry(client, routePromptRetry(args, directory)) +} + +export function promptSyncWithRetryInDirectory( + client: PromptSyncRetryClient, + args: PromptSyncRetryArgs, + directory: string, +): Promise { + return promptSyncWithModelSuggestionRetry(client, routePromptSyncRetry(args, directory)) +} + +export function messagesInDirectory( + client: OpencodeClient, + args: SessionMessagesArgs, + directory: string, +): Promise { + return client.session.messages(routeSessionMessages(args, directory)) +}