fix(background-agent): route session prompts by directory
This commit is contained in:
@@ -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>> = []
|
||||
|
||||
@@ -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 } }> = []
|
||||
|
||||
@@ -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 }> = {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user