diff --git a/src/tools/agent-teams/delegation-consistency.test.ts b/src/tools/agent-teams/delegation-consistency.test.ts
new file mode 100644
index 000000000..01fd6bf46
--- /dev/null
+++ b/src/tools/agent-teams/delegation-consistency.test.ts
@@ -0,0 +1,189 @@
+///
+import { afterEach, beforeEach, describe, expect, test } from "bun:test"
+import { mkdtempSync, rmSync } from "node:fs"
+import { tmpdir } from "node:os"
+import { join } from "node:path"
+import type { BackgroundManager } from "../../features/background-agent"
+import { createAgentTeamsTools } from "./tools"
+
+interface LaunchCall {
+ description: string
+ prompt: string
+ agent: string
+ parentSessionID: string
+ parentMessageID: string
+ parentAgent?: string
+ parentModel?: {
+ providerID: string
+ modelID: string
+ variant?: string
+ }
+}
+
+interface ResumeCall {
+ sessionId: string
+ prompt: string
+ parentSessionID: string
+ parentMessageID: string
+ parentAgent?: string
+ parentModel?: {
+ providerID: string
+ modelID: string
+ variant?: string
+ }
+}
+
+interface ToolContextLike {
+ sessionID: string
+ messageID: string
+ abort: AbortSignal
+ agent?: string
+}
+
+function createMockManager(): {
+ manager: BackgroundManager
+ launchCalls: LaunchCall[]
+ resumeCalls: ResumeCall[]
+} {
+ const launchCalls: LaunchCall[] = []
+ const resumeCalls: ResumeCall[] = []
+ const launchedTasks = new Map()
+ let launchCount = 0
+
+ const manager = {
+ launch: async (args: LaunchCall) => {
+ launchCount += 1
+ launchCalls.push(args)
+ const task = { id: `bg-${launchCount}`, sessionID: `ses-worker-${launchCount}` }
+ launchedTasks.set(task.id, task)
+ return task
+ },
+ getTask: (taskId: string) => launchedTasks.get(taskId),
+ resume: async (args: ResumeCall) => {
+ resumeCalls.push(args)
+ return { id: `resume-${resumeCalls.length}` }
+ },
+ } as unknown as BackgroundManager
+
+ return { manager, launchCalls, resumeCalls }
+}
+
+async function executeJsonTool(
+ tools: ReturnType,
+ toolName: keyof ReturnType,
+ args: Record,
+ context: ToolContextLike,
+): Promise {
+ const output = await tools[toolName].execute(args, context as any)
+ return JSON.parse(output)
+}
+
+describe("agent-teams delegation consistency", () => {
+ let originalCwd: string
+ let tempProjectDir: string
+
+ beforeEach(() => {
+ originalCwd = process.cwd()
+ tempProjectDir = mkdtempSync(join(tmpdir(), "agent-teams-consistency-"))
+ process.chdir(tempProjectDir)
+ })
+
+ afterEach(() => {
+ process.chdir(originalCwd)
+ rmSync(tempProjectDir, { recursive: true, force: true })
+ })
+
+ test("team delegation forwards parent context like normal delegate-task", async () => {
+ //#given
+ const { manager, launchCalls, resumeCalls } = createMockManager()
+ const tools = createAgentTeamsTools(manager)
+ const leadContext: ToolContextLike = {
+ sessionID: "ses-main",
+ messageID: "msg-main",
+ abort: new AbortController().signal,
+ }
+
+ await executeJsonTool(tools, "team_create", { team_name: "core" }, leadContext)
+
+ //#when
+ const spawnResult = await executeJsonTool(
+ tools,
+ "spawn_teammate",
+ {
+ team_name: "core",
+ name: "worker_1",
+ prompt: "Handle release prep",
+ category: "quick",
+ },
+ leadContext,
+ ) as { error?: string }
+
+ //#then
+ expect(spawnResult.error).toBeUndefined()
+ expect(launchCalls).toHaveLength(1)
+ expect(launchCalls[0].parentAgent).toBe("sisyphus")
+ expect("parentModel" in launchCalls[0]).toBe(true)
+
+ //#when
+ const messageResult = await executeJsonTool(
+ tools,
+ "send_message",
+ {
+ team_name: "core",
+ type: "message",
+ recipient: "worker_1",
+ summary: "sync",
+ content: "Please update status.",
+ },
+ leadContext,
+ ) as { error?: string }
+
+ //#then
+ expect(messageResult.error).toBeUndefined()
+ expect(resumeCalls).toHaveLength(1)
+ expect(resumeCalls[0].parentAgent).toBe("sisyphus")
+ expect("parentModel" in resumeCalls[0]).toBe(true)
+ })
+
+ test("send_message accepts teammate agent_id as recipient", async () => {
+ //#given
+ const { manager, resumeCalls } = createMockManager()
+ const tools = createAgentTeamsTools(manager)
+ const leadContext: ToolContextLike = {
+ sessionID: "ses-main",
+ messageID: "msg-main",
+ abort: new AbortController().signal,
+ }
+
+ await executeJsonTool(tools, "team_create", { team_name: "core" }, leadContext)
+ await executeJsonTool(
+ tools,
+ "spawn_teammate",
+ {
+ team_name: "core",
+ name: "worker_1",
+ prompt: "Handle release prep",
+ category: "quick",
+ },
+ leadContext,
+ )
+
+ //#when
+ const messageResult = await executeJsonTool(
+ tools,
+ "send_message",
+ {
+ team_name: "core",
+ type: "message",
+ recipient: "worker_1@core",
+ summary: "sync",
+ content: "Please update status.",
+ },
+ leadContext,
+ ) as { error?: string }
+
+ //#then
+ expect(messageResult.error).toBeUndefined()
+ expect(resumeCalls).toHaveLength(1)
+ })
+})
diff --git a/src/tools/agent-teams/teammate-parent-context.ts b/src/tools/agent-teams/teammate-parent-context.ts
new file mode 100644
index 000000000..caeace6c9
--- /dev/null
+++ b/src/tools/agent-teams/teammate-parent-context.ts
@@ -0,0 +1,13 @@
+import type { ParentContext } from "../delegate-task/executor"
+import { resolveParentContext } from "../delegate-task/executor"
+import type { ToolContextWithMetadata } from "../delegate-task/types"
+import type { TeamToolContext } from "./types"
+
+export function resolveTeamParentContext(context: TeamToolContext): ParentContext {
+ return resolveParentContext({
+ sessionID: context.sessionID,
+ messageID: context.messageID,
+ agent: context.agent ?? "sisyphus",
+ abort: new AbortController().signal,
+ } as ToolContextWithMetadata)
+}
diff --git a/src/tools/agent-teams/teammate-prompts.ts b/src/tools/agent-teams/teammate-prompts.ts
new file mode 100644
index 000000000..051995967
--- /dev/null
+++ b/src/tools/agent-teams/teammate-prompts.ts
@@ -0,0 +1,28 @@
+export function buildLaunchPrompt(
+ teamName: string,
+ teammateName: string,
+ userPrompt: string,
+ categoryPromptAppend?: string,
+): string {
+ const sections = [
+ `You are teammate "${teammateName}" in team "${teamName}".`,
+ `When you need updates, call read_inbox with team_name="${teamName}" and agent_name="${teammateName}".`,
+ "Initial assignment:",
+ userPrompt,
+ ]
+
+ if (categoryPromptAppend) {
+ sections.push("Category guidance:", categoryPromptAppend)
+ }
+
+ return sections.join("\n\n")
+}
+
+export function buildDeliveryPrompt(teamName: string, teammateName: string, summary: string, content: string): string {
+ return [
+ `New team message for "${teammateName}" in team "${teamName}".`,
+ `Summary: ${summary}`,
+ "Content:",
+ content,
+ ].join("\n\n")
+}
diff --git a/src/tools/agent-teams/teammate-runtime.ts b/src/tools/agent-teams/teammate-runtime.ts
index 484233d0f..38b87ffdc 100644
--- a/src/tools/agent-teams/teammate-runtime.ts
+++ b/src/tools/agent-teams/teammate-runtime.ts
@@ -1,25 +1,10 @@
-import type { PluginInput } from "@opencode-ai/plugin"
-import type { CategoriesConfig } from "../../config/schema"
import type { BackgroundManager } from "../../features/background-agent"
-import { resolveCategoryExecution, resolveParentContext } from "../delegate-task/executor"
-import type { DelegateTaskArgs, ToolContextWithMetadata } from "../delegate-task/types"
import { clearInbox, ensureInbox, sendPlainInboxMessage } from "./inbox-store"
import { assignNextColor, getTeamMember, removeTeammate, updateTeamConfig, upsertTeammate } from "./team-config-store"
import type { TeamTeammateMember, TeamToolContext } from "./types"
-
-function parseModel(model: string | undefined): { providerID: string; modelID: string } | undefined {
- if (!model) {
- return undefined
- }
- const separatorIndex = model.indexOf("/")
- if (separatorIndex <= 0 || separatorIndex >= model.length - 1) {
- throw new Error("invalid_model_override_format")
- }
-
- const providerID = model.slice(0, separatorIndex)
- const modelID = model.slice(separatorIndex + 1)
- return { providerID, modelID }
-}
+import { resolveTeamParentContext } from "./teammate-parent-context"
+import { buildDeliveryPrompt, buildLaunchPrompt } from "./teammate-prompts"
+import { resolveSpawnExecution, type TeamCategoryContext } from "./teammate-spawn-execution"
function delay(ms: number): Promise {
return new Promise((resolve) => setTimeout(resolve, ms))
@@ -37,35 +22,6 @@ function resolveLaunchFailureMessage(status: string | undefined, error: string |
return "teammate_launch_timeout"
}
-function buildLaunchPrompt(
- teamName: string,
- teammateName: string,
- userPrompt: string,
- categoryPromptAppend?: string,
-): string {
- const sections = [
- `You are teammate "${teammateName}" in team "${teamName}".`,
- `When you need updates, call read_inbox with team_name="${teamName}" and agent_name="${teammateName}".`,
- "Initial assignment:",
- userPrompt,
- ]
-
- if (categoryPromptAppend) {
- sections.push("Category guidance:", categoryPromptAppend)
- }
-
- return sections.join("\n\n")
-}
-
-function buildDeliveryPrompt(teamName: string, teammateName: string, summary: string, content: string): string {
- return [
- `New team message for "${teammateName}" in team "${teamName}".`,
- `Summary: ${summary}`,
- "Content:",
- content,
- ].join("\n\n")
-}
-
export interface SpawnTeammateParams {
teamName: string
name: string
@@ -76,99 +32,24 @@ export interface SpawnTeammateParams {
planModeRequired: boolean
context: TeamToolContext
manager: BackgroundManager
- categoryContext?: {
- client: PluginInput["client"]
- userCategories?: CategoriesConfig
- sisyphusJuniorModel?: string
- }
-}
-
-interface SpawnExecution {
- agentType: string
- teammateModel: string
- launchModel?: { providerID: string; modelID: string; variant?: string }
- categoryPromptAppend?: string
-}
-
-async function getSystemDefaultModel(client: PluginInput["client"]): Promise {
- try {
- const openCodeConfig = await client.config.get()
- return (openCodeConfig as { data?: { model?: string } })?.data?.model
- } catch {
- return undefined
- }
-}
-
-async function resolveSpawnExecution(params: SpawnTeammateParams): Promise {
- if (params.model) {
- const launchModel = parseModel(params.model)
- return {
- agentType: params.subagentType,
- teammateModel: params.model,
- ...(launchModel ? { launchModel } : {}),
- }
- }
-
- if (!params.categoryContext?.client) {
- return {
- agentType: params.subagentType,
- teammateModel: "native",
- }
- }
-
- const parentContext = resolveParentContext({
- sessionID: params.context.sessionID,
- messageID: params.context.messageID,
- agent: params.context.agent ?? "sisyphus",
- abort: new AbortController().signal,
- } as ToolContextWithMetadata)
-
- const inheritedModel = parentContext.model
- ? `${parentContext.model.providerID}/${parentContext.model.modelID}`
- : undefined
-
- const systemDefaultModel = await getSystemDefaultModel(params.categoryContext.client)
-
- const delegateArgs: DelegateTaskArgs = {
- description: `[team:${params.teamName}] ${params.name}`,
- prompt: params.prompt,
- category: params.category,
- subagent_type: "sisyphus-junior",
- run_in_background: true,
- load_skills: [],
- }
-
- const resolution = await resolveCategoryExecution(
- delegateArgs,
- {
- manager: params.manager,
- client: params.categoryContext.client,
- directory: process.cwd(),
- userCategories: params.categoryContext.userCategories,
- sisyphusJuniorModel: params.categoryContext.sisyphusJuniorModel,
- },
- inheritedModel,
- systemDefaultModel,
- )
-
- if (resolution.error) {
- throw new Error(resolution.error)
- }
-
- if (!resolution.categoryModel) {
- throw new Error("category_model_not_resolved")
- }
-
- return {
- agentType: resolution.agentToUse,
- teammateModel: `${resolution.categoryModel.providerID}/${resolution.categoryModel.modelID}`,
- launchModel: resolution.categoryModel,
- categoryPromptAppend: resolution.categoryPromptAppend,
- }
+ categoryContext?: TeamCategoryContext
}
export async function spawnTeammate(params: SpawnTeammateParams): Promise {
- const execution = await resolveSpawnExecution(params)
+ const parentContext = resolveTeamParentContext(params.context)
+ const execution = await resolveSpawnExecution(
+ {
+ teamName: params.teamName,
+ name: params.name,
+ prompt: params.prompt,
+ category: params.category,
+ subagentType: params.subagentType,
+ model: params.model,
+ manager: params.manager,
+ categoryContext: params.categoryContext,
+ },
+ parentContext,
+ )
let teammate: TeamTeammateMember | undefined
let launchedTaskID: string | undefined
@@ -209,11 +90,12 @@ export async function spawnTeammate(params: SpawnTeammateParams): Promise= model.length - 1) {
+ throw new Error("invalid_model_override_format")
+ }
+
+ return {
+ providerID: model.slice(0, separatorIndex),
+ modelID: model.slice(separatorIndex + 1),
+ }
+}
+
+async function getSystemDefaultModel(client: PluginInput["client"]): Promise {
+ try {
+ const openCodeConfig = await client.config.get()
+ return (openCodeConfig as { data?: { model?: string } })?.data?.model
+ } catch {
+ return undefined
+ }
+}
+
+export interface TeamCategoryContext {
+ client: PluginInput["client"]
+ userCategories?: CategoriesConfig
+ sisyphusJuniorModel?: string
+}
+
+export interface SpawnExecutionRequest {
+ teamName: string
+ name: string
+ prompt: string
+ category: string
+ subagentType: string
+ model?: string
+ manager: BackgroundManager
+ categoryContext?: TeamCategoryContext
+}
+
+export interface SpawnExecutionResult {
+ agentType: string
+ teammateModel: string
+ launchModel?: { providerID: string; modelID: string; variant?: string }
+ categoryPromptAppend?: string
+}
+
+export async function resolveSpawnExecution(
+ request: SpawnExecutionRequest,
+ parentContext: ParentContext,
+): Promise {
+ if (request.model) {
+ const launchModel = parseModel(request.model)
+ return {
+ agentType: request.subagentType,
+ teammateModel: request.model,
+ ...(launchModel ? { launchModel } : {}),
+ }
+ }
+
+ if (!request.categoryContext?.client) {
+ return {
+ agentType: request.subagentType,
+ teammateModel: "native",
+ }
+ }
+
+ const inheritedModel = parentContext.model
+ ? `${parentContext.model.providerID}/${parentContext.model.modelID}`
+ : undefined
+
+ const systemDefaultModel = await getSystemDefaultModel(request.categoryContext.client)
+
+ const delegateArgs: DelegateTaskArgs = {
+ description: `[team:${request.teamName}] ${request.name}`,
+ prompt: request.prompt,
+ category: request.category,
+ subagent_type: "sisyphus-junior",
+ run_in_background: true,
+ load_skills: [],
+ }
+
+ const resolution = await resolveCategoryExecution(
+ delegateArgs,
+ {
+ manager: request.manager,
+ client: request.categoryContext.client,
+ directory: process.cwd(),
+ userCategories: request.categoryContext.userCategories,
+ sisyphusJuniorModel: request.categoryContext.sisyphusJuniorModel,
+ },
+ inheritedModel,
+ systemDefaultModel,
+ )
+
+ if (resolution.error) {
+ throw new Error(resolution.error)
+ }
+
+ if (!resolution.categoryModel) {
+ throw new Error("category_model_not_resolved")
+ }
+
+ return {
+ agentType: resolution.agentToUse,
+ teammateModel: `${resolution.categoryModel.providerID}/${resolution.categoryModel.modelID}`,
+ launchModel: resolution.categoryModel,
+ categoryPromptAppend: resolution.categoryPromptAppend,
+ }
+}
diff --git a/src/tools/agent-teams/types.ts b/src/tools/agent-teams/types.ts
index 89296c578..099dcb856 100644
--- a/src/tools/agent-teams/types.ts
+++ b/src/tools/agent-teams/types.ts
@@ -115,10 +115,26 @@ export const TeamSpawnInputSchema = z.object({
plan_mode_required: z.boolean().optional(),
})
+function normalizeTeamRecipient(recipient: string): string {
+ const trimmed = recipient.trim()
+ const atIndex = trimmed.indexOf("@")
+ if (atIndex <= 0) {
+ return trimmed
+ }
+
+ return trimmed.slice(0, atIndex)
+}
+
export const TeamSendMessageInputSchema = z.object({
team_name: z.string(),
type: TeamSendMessageTypeSchema,
- recipient: z.string().optional(),
+ recipient: z.string().optional().transform((value) => {
+ if (value === undefined) {
+ return undefined
+ }
+
+ return normalizeTeamRecipient(value)
+ }),
content: z.string().optional(),
summary: z.string().optional(),
request_id: z.string().optional(),