fix(background-agent): route session prompts by directory

This commit is contained in:
YeonGyu-Kim
2026-05-12 18:13:39 +09:00
parent 7d09d2c83c
commit 6035a551ad
7 changed files with 221 additions and 31 deletions
@@ -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<Record<string, unknown>> = []
const client = {
session: {
get: async () => ({ data: { directory: "/parent" } }),
create: async () => ({ data: { id: "ses_child" } }),
promptAsync: async (input: Record<string, unknown>) => {
promptCalls.push(input)
return {}
},
abort: async () => ({}),
},
}
const manager = new BackgroundManager({ pluginContext: unsafeTestValue<PluginInput>({ 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<Record<string, unknown>> = []
+16 -13
View File
@@ -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<string, boolean>)
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,
@@ -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<Record<string, unknown>> = []
const client = {
session: {
get: async () => ({ data: { directory: "/parent/dir" } }),
create: async () => ({ data: { id: "ses_child_query" } }),
promptAsync: async (input: Record<string, unknown>) => {
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 } }> = []
+19 -18
View File
@@ -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<SpawnerContext, "client" | "concurrencyManager" | "onTaskError">
ctx: Pick<SpawnerContext, "client" | "concurrencyManager" | "directory" | "onTaskError">
): Promise<void> {
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) {
@@ -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<string, { type: string }> = {
+1
View File
@@ -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"
+80
View File
@@ -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<OpencodeClient["session"]["promptAsync"]>[0]
type SessionMessagesArgs = Parameters<OpencodeClient["session"]["messages"]>[0]
type PromptRetryClient = Parameters<typeof promptWithModelSuggestionRetry>[0]
type PromptRetryArgs = Parameters<typeof promptWithModelSuggestionRetry>[1]
type PromptSyncRetryClient = Parameters<typeof promptSyncWithModelSuggestionRetry>[0]
type PromptSyncRetryArgs = Parameters<typeof promptSyncWithModelSuggestionRetry>[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<unknown> {
return client.session.promptAsync(routeSessionPrompt(args, directory))
}
export function promptWithRetryInDirectory(
client: PromptRetryClient,
args: PromptRetryArgs,
directory: string,
): Promise<void> {
return promptWithModelSuggestionRetry(client, routePromptRetry(args, directory))
}
export function promptSyncWithRetryInDirectory(
client: PromptSyncRetryClient,
args: PromptSyncRetryArgs,
directory: string,
): Promise<void> {
return promptSyncWithModelSuggestionRetry(client, routePromptSyncRetry(args, directory))
}
export function messagesInDirectory(
client: OpencodeClient,
args: SessionMessagesArgs,
directory: string,
): Promise<unknown> {
return client.session.messages(routeSessionMessages(args, directory))
}