fix(agent-teams): accept teammate agent IDs in messaging

Normalize send_message recipients so name@team values resolve to member names, preventing false recipient-not-found fallbacks into duplicate delegation paths. Also add delegation consistency coverage and split teammate runtime helpers for clearer spawn and parent-context handling.
This commit is contained in:
Nguyen Khac Trung Kien
2026-02-08 12:22:43 +07:00
committed by YeonGyu-Kim
parent 40f844fb85
commit cf42082c5f
6 changed files with 394 additions and 144 deletions

View File

@@ -0,0 +1,189 @@
/// <reference types="bun-types" />
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<string, { id: string; sessionID: string }>()
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<typeof createAgentTeamsTools>,
toolName: keyof ReturnType<typeof createAgentTeamsTools>,
args: Record<string, unknown>,
context: ToolContextLike,
): Promise<unknown> {
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)
})
})

View File

@@ -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)
}

View File

@@ -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")
}

View File

@@ -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<void> {
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<string | undefined> {
try {
const openCodeConfig = await client.config.get()
return (openCodeConfig as { data?: { model?: string } })?.data?.model
} catch {
return undefined
}
}
async function resolveSpawnExecution(params: SpawnTeammateParams): Promise<SpawnExecution> {
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<TeamTeammateMember> {
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<TeamTe
description: `[team:${params.teamName}] ${params.name}`,
prompt: buildLaunchPrompt(params.teamName, params.name, params.prompt, execution.categoryPromptAppend),
agent: execution.agentType,
parentSessionID: params.context.sessionID,
parentMessageID: params.context.messageID,
parentSessionID: parentContext.sessionID,
parentMessageID: parentContext.messageID,
parentModel: parentContext.model,
...(execution.launchModel ? { model: execution.launchModel } : {}),
...(params.category ? { category: params.category } : {}),
parentAgent: params.context.agent,
parentAgent: parentContext.agent,
})
launchedTaskID = launched.id
@@ -286,13 +168,16 @@ export async function resumeTeammateWithMessage(
return
}
const parentContext = resolveTeamParentContext(context)
try {
await manager.resume({
sessionId: teammate.sessionID,
prompt: buildDeliveryPrompt(teamName, teammate.name, summary, content),
parentSessionID: context.sessionID,
parentMessageID: context.messageID,
parentAgent: context.agent,
parentSessionID: parentContext.sessionID,
parentMessageID: parentContext.messageID,
parentModel: parentContext.model,
parentAgent: parentContext.agent,
})
} catch {
return

View File

@@ -0,0 +1,119 @@
import type { PluginInput } from "@opencode-ai/plugin"
import type { CategoriesConfig } from "../../config/schema"
import type { BackgroundManager } from "../../features/background-agent"
import type { ParentContext } from "../delegate-task/executor"
import { resolveCategoryExecution } from "../delegate-task/executor"
import type { DelegateTaskArgs } from "../delegate-task/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")
}
return {
providerID: model.slice(0, separatorIndex),
modelID: model.slice(separatorIndex + 1),
}
}
async function getSystemDefaultModel(client: PluginInput["client"]): Promise<string | undefined> {
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<SpawnExecutionResult> {
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,
}
}

View File

@@ -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(),