Count sync subagent spawns against descendant limits
This commit is contained in:
@@ -249,4 +249,52 @@ describe("executeSync", () => {
|
||||
expect(deps.waitForCompletion).not.toHaveBeenCalled()
|
||||
expect(deps.processMessages).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("commits reserved descendant quota after creating a new sync session", async () => {
|
||||
//#given
|
||||
const { executeSync } = require("./sync-executor")
|
||||
|
||||
const deps = {
|
||||
createOrGetSession: mock(async () => ({ sessionID: "ses-test-789", isNew: true })),
|
||||
waitForCompletion: mock(async () => {}),
|
||||
processMessages: mock(async () => "agent response"),
|
||||
setSessionFallbackChain: mock(() => {}),
|
||||
}
|
||||
|
||||
const spawnReservation = {
|
||||
commit: mock(() => 1),
|
||||
rollback: mock(() => {}),
|
||||
}
|
||||
|
||||
const args = {
|
||||
subagent_type: "explore",
|
||||
description: "test task",
|
||||
prompt: "find something",
|
||||
}
|
||||
|
||||
const toolContext = {
|
||||
sessionID: "parent-session",
|
||||
messageID: "msg-4",
|
||||
agent: "sisyphus",
|
||||
abort: new AbortController().signal,
|
||||
metadata: mock(async () => {}),
|
||||
}
|
||||
|
||||
const ctx = {
|
||||
client: {
|
||||
session: {
|
||||
promptAsync: mock(async () => ({ data: {} })),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
//#when
|
||||
await executeSync(args, toolContext, ctx as any, deps, undefined, spawnReservation)
|
||||
|
||||
//#then
|
||||
expect(spawnReservation.commit).toHaveBeenCalledTimes(1)
|
||||
expect(spawnReservation.rollback).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
})
|
||||
|
||||
export {}
|
||||
|
||||
@@ -19,6 +19,11 @@ type ExecuteSyncDeps = {
|
||||
setSessionFallbackChain: typeof setSessionFallbackChain
|
||||
}
|
||||
|
||||
type SpawnReservation = {
|
||||
commit: () => number
|
||||
rollback: () => void
|
||||
}
|
||||
|
||||
const defaultDeps: ExecuteSyncDeps = {
|
||||
createOrGetSession,
|
||||
waitForCompletion,
|
||||
@@ -33,54 +38,67 @@ export async function executeSync(
|
||||
messageID: string
|
||||
agent: string
|
||||
abort: AbortSignal
|
||||
metadata?: (input: { title?: string; metadata?: Record<string, unknown> }) => void
|
||||
metadata?: (input: { title?: string; metadata?: Record<string, unknown> }) => void | Promise<void>
|
||||
},
|
||||
ctx: PluginInput,
|
||||
deps: ExecuteSyncDeps = defaultDeps,
|
||||
fallbackChain?: FallbackEntry[],
|
||||
spawnReservation?: SpawnReservation,
|
||||
): Promise<string> {
|
||||
const { sessionID } = await deps.createOrGetSession(args, toolContext, ctx)
|
||||
|
||||
if (fallbackChain && fallbackChain.length > 0) {
|
||||
deps.setSessionFallbackChain(sessionID, fallbackChain)
|
||||
}
|
||||
|
||||
await toolContext.metadata?.({
|
||||
title: args.description,
|
||||
metadata: { sessionId: sessionID },
|
||||
})
|
||||
|
||||
log(`[call_omo_agent] Sending prompt to session ${sessionID}`)
|
||||
log(`[call_omo_agent] Prompt text:`, args.prompt.substring(0, 100))
|
||||
let sessionID: string | undefined
|
||||
|
||||
try {
|
||||
await (ctx.client.session as unknown as SessionWithPromptAsync).promptAsync({
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
agent: args.subagent_type,
|
||||
tools: {
|
||||
...getAgentToolRestrictions(args.subagent_type),
|
||||
task: false,
|
||||
question: false,
|
||||
},
|
||||
parts: [{ type: "text", text: args.prompt }],
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
log(`[call_omo_agent] Prompt error:`, errorMessage)
|
||||
if (errorMessage.includes("agent.name") || errorMessage.includes("undefined")) {
|
||||
return `Error: Agent "${args.subagent_type}" not found. Make sure the agent is registered in your opencode.json or provided by a plugin.\n\n<task_metadata>\nsession_id: ${sessionID}\n</task_metadata>`
|
||||
const session = await deps.createOrGetSession(args, toolContext, ctx)
|
||||
sessionID = session.sessionID
|
||||
|
||||
if (session.isNew) {
|
||||
spawnReservation?.commit()
|
||||
}
|
||||
return `Error: Failed to send prompt: ${errorMessage}\n\n<task_metadata>\nsession_id: ${sessionID}\n</task_metadata>`
|
||||
|
||||
if (fallbackChain && fallbackChain.length > 0) {
|
||||
deps.setSessionFallbackChain(sessionID, fallbackChain)
|
||||
}
|
||||
|
||||
await toolContext.metadata?.({
|
||||
title: args.description,
|
||||
metadata: { sessionId: sessionID },
|
||||
})
|
||||
|
||||
log(`[call_omo_agent] Sending prompt to session ${sessionID}`)
|
||||
log(`[call_omo_agent] Prompt text:`, args.prompt.substring(0, 100))
|
||||
|
||||
try {
|
||||
await (ctx.client.session as unknown as SessionWithPromptAsync).promptAsync({
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
agent: args.subagent_type,
|
||||
tools: {
|
||||
...getAgentToolRestrictions(args.subagent_type),
|
||||
task: false,
|
||||
question: false,
|
||||
},
|
||||
parts: [{ type: "text", text: args.prompt }],
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
log(`[call_omo_agent] Prompt error:`, errorMessage)
|
||||
if (errorMessage.includes("agent.name") || errorMessage.includes("undefined")) {
|
||||
return `Error: Agent "${args.subagent_type}" not found. Make sure the agent is registered in your opencode.json or provided by a plugin.\n\n<task_metadata>\nsession_id: ${sessionID}\n</task_metadata>`
|
||||
}
|
||||
return `Error: Failed to send prompt: ${errorMessage}\n\n<task_metadata>\nsession_id: ${sessionID}\n</task_metadata>`
|
||||
}
|
||||
|
||||
await deps.waitForCompletion(sessionID, toolContext, ctx)
|
||||
|
||||
const responseText = await deps.processMessages(sessionID, ctx)
|
||||
|
||||
const output =
|
||||
responseText + "\n\n" + ["<task_metadata>", `session_id: ${sessionID}`, "</task_metadata>"].join("\n")
|
||||
|
||||
return output
|
||||
} catch (error) {
|
||||
spawnReservation?.rollback()
|
||||
throw error
|
||||
}
|
||||
|
||||
await deps.waitForCompletion(sessionID, toolContext, ctx)
|
||||
|
||||
const responseText = await deps.processMessages(sessionID, ctx)
|
||||
|
||||
const output =
|
||||
responseText + "\n\n" + ["<task_metadata>", `session_id: ${sessionID}`, "</task_metadata>"].join("\n")
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
import { describe, test, expect, mock } from "bun:test"
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { BackgroundManager } from "../../features/background-agent"
|
||||
import type { FallbackEntry } from "../../shared/model-requirements"
|
||||
import { createCallOmoAgent } from "./tools"
|
||||
const { beforeEach, describe, test, expect, mock } = require("bun:test")
|
||||
const { createCallOmoAgent } = require("./tools")
|
||||
|
||||
describe("createCallOmoAgent", () => {
|
||||
const assertCanSpawnMock = mock(() => Promise.resolve(undefined))
|
||||
const reserveCommitMock = mock(() => 1)
|
||||
const reserveRollbackMock = mock(() => {})
|
||||
const reserveSubagentSpawnMock = mock(() => Promise.resolve({
|
||||
spawnContext: { rootSessionID: "root-session", parentDepth: 0, childDepth: 1 },
|
||||
descendantCount: 1,
|
||||
commit: reserveCommitMock,
|
||||
rollback: reserveRollbackMock,
|
||||
}))
|
||||
const mockCtx = {
|
||||
client: {},
|
||||
directory: "/test",
|
||||
} as unknown as PluginInput
|
||||
}
|
||||
|
||||
const mockBackgroundManager = {
|
||||
assertCanSpawn: assertCanSpawnMock,
|
||||
reserveSubagentSpawn: reserveSubagentSpawnMock,
|
||||
launch: mock(() => Promise.resolve({
|
||||
id: "test-task-id",
|
||||
sessionID: null,
|
||||
@@ -20,7 +26,14 @@ describe("createCallOmoAgent", () => {
|
||||
agent: "test-agent",
|
||||
status: "pending",
|
||||
})),
|
||||
} as unknown as BackgroundManager
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
assertCanSpawnMock.mockClear()
|
||||
reserveSubagentSpawnMock.mockClear()
|
||||
reserveCommitMock.mockClear()
|
||||
reserveRollbackMock.mockClear()
|
||||
})
|
||||
|
||||
test("should reject agent in disabled_agents list", async () => {
|
||||
//#given
|
||||
@@ -105,7 +118,7 @@ describe("createCallOmoAgent", () => {
|
||||
|
||||
test("uses agent override fallback_models when launching background subagent", async () => {
|
||||
//#given
|
||||
const launch = mock((_input: { fallbackChain?: FallbackEntry[] }) => Promise.resolve({
|
||||
const launch = mock((_input: { fallbackChain?: Array<{ providers: string[]; model: string; variant?: string }> }) => Promise.resolve({
|
||||
id: "task-fallback",
|
||||
sessionID: "sub-session",
|
||||
description: "Test task",
|
||||
@@ -115,7 +128,7 @@ describe("createCallOmoAgent", () => {
|
||||
const managerWithLaunch = {
|
||||
launch,
|
||||
getTask: mock(() => undefined),
|
||||
} as unknown as BackgroundManager
|
||||
}
|
||||
const toolDef = createCallOmoAgent(
|
||||
mockCtx,
|
||||
managerWithLaunch,
|
||||
@@ -154,7 +167,7 @@ describe("createCallOmoAgent", () => {
|
||||
|
||||
test("should return a tool error when sync spawn depth validation fails", async () => {
|
||||
//#given
|
||||
assertCanSpawnMock.mockRejectedValueOnce(new Error("Subagent spawn blocked: child depth 4 exceeds background_task.maxDepth=3."))
|
||||
reserveSubagentSpawnMock.mockRejectedValueOnce(new Error("Subagent spawn blocked: child depth 4 exceeds background_task.maxDepth=3."))
|
||||
const toolDef = createCallOmoAgent(mockCtx, mockBackgroundManager, [])
|
||||
const executeFunc = toolDef.execute as Function
|
||||
|
||||
@@ -173,3 +186,5 @@ describe("createCallOmoAgent", () => {
|
||||
expect(result).toContain("background_task.maxDepth=3")
|
||||
})
|
||||
})
|
||||
|
||||
export {}
|
||||
|
||||
@@ -96,9 +96,12 @@ export function createCallOmoAgent(
|
||||
}
|
||||
|
||||
if (!args.session_id) {
|
||||
let spawnReservation: Awaited<ReturnType<BackgroundManager["reserveSubagentSpawn"]>> | undefined
|
||||
try {
|
||||
await backgroundManager.assertCanSpawn(toolCtx.sessionID)
|
||||
spawnReservation = await backgroundManager.reserveSubagentSpawn(toolCtx.sessionID)
|
||||
return await executeSync(args, toolCtx, ctx, undefined, fallbackChain, spawnReservation)
|
||||
} catch (error) {
|
||||
spawnReservation?.rollback()
|
||||
return `Error: ${error instanceof Error ? error.message : String(error)}`
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user