diff --git a/src/tools/agent-teams/inbox-store.ts b/src/tools/agent-teams/inbox-store.ts index d9b4327e..3346f196 100644 --- a/src/tools/agent-teams/inbox-store.ts +++ b/src/tools/agent-teams/inbox-store.ts @@ -84,10 +84,7 @@ export function appendInboxMessage(teamName: string, agentName: string, message: assertValidInboxAgentName(agentName) withInboxLock(teamName, () => { const path = getTeamInboxPath(teamName, agentName) - if (!existsSync(path)) { - writeJsonAtomic(path, []) - } - const messages = readInboxMessages(teamName, agentName) + const messages = existsSync(path) ? parseInboxFile(readFileSync(path, "utf-8")) : [] messages.push(TeamInboxMessageSchema.parse(message)) writeInboxMessages(teamName, agentName, messages) }) @@ -153,18 +150,27 @@ export function readInbox( return selected } + const selectedSet = unreadOnly ? new Set(selected) : null + let changed = false + const updated = messages.map((message) => { if (!unreadOnly) { + if (!message.read) { + changed = true + } return { ...message, read: true } } - if (selected.some((selectedMessage) => selectedMessage.timestamp === message.timestamp && selectedMessage.from === message.from && selectedMessage.text === message.text)) { + if (selectedSet?.has(message)) { + changed = true return { ...message, read: true } } return message }) - writeInboxMessages(teamName, agentName, updated) + if (changed) { + writeInboxMessages(teamName, agentName, updated) + } return selected }) } diff --git a/src/tools/agent-teams/messaging-tools.ts b/src/tools/agent-teams/messaging-tools.ts index 444cf7f9..8010b613 100644 --- a/src/tools/agent-teams/messaging-tools.ts +++ b/src/tools/agent-teams/messaging-tools.ts @@ -1,7 +1,7 @@ import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool" import type { BackgroundManager } from "../../features/background-agent" import { buildShutdownRequestId, readInbox, sendPlainInboxMessage, sendStructuredInboxMessage } from "./inbox-store" -import { getTeamMember, listTeammates, readTeamConfigOrThrow, updateTeamConfig } from "./team-config-store" +import { getTeamMember, listTeammates, readTeamConfigOrThrow } from "./team-config-store" import { validateAgentNameOrLead, validateTeamName } from "./name-validation" import { resumeTeammateWithMessage } from "./teammate-runtime" import { @@ -25,13 +25,6 @@ function resolveSenderFromContext(config: TeamConfig, context: TeamToolContext): return matchedMember?.name ?? null } -function claimLeadSession(teamName: string, nextLeadSessionId: string): TeamConfig { - return updateTeamConfig(teamName, (current) => ({ - ...current, - leadSessionId: nextLeadSessionId, - })) -} - export function createSendMessageTool(manager: BackgroundManager): ToolDefinition { return tool({ description: "Send direct or broadcast team messages and protocol responses.", @@ -57,12 +50,8 @@ export function createSendMessageTool(manager: BackgroundManager): ToolDefinitio if (senderError) { return JSON.stringify({ error: senderError }) } - let config = readTeamConfigOrThrow(input.team_name) - let actor = resolveSenderFromContext(config, context) - if (!actor && requestedSender === "team-lead") { - config = claimLeadSession(input.team_name, context.sessionID) - actor = "team-lead" - } + const config = readTeamConfigOrThrow(input.team_name) + const actor = resolveSenderFromContext(config, context) if (!actor) { return JSON.stringify({ error: "unauthorized_sender_session" }) } @@ -234,12 +223,8 @@ export function createReadInboxTool(): ToolDefinition { if (agentError) { return JSON.stringify({ error: agentError }) } - let config = readTeamConfigOrThrow(input.team_name) - let actor = resolveSenderFromContext(config, context) - if (!actor && input.agent_name === "team-lead") { - config = claimLeadSession(input.team_name, context.sessionID) - actor = "team-lead" - } + const config = readTeamConfigOrThrow(input.team_name) + const actor = resolveSenderFromContext(config, context) if (!actor) { return JSON.stringify({ error: "unauthorized_reader_session" }) } diff --git a/src/tools/agent-teams/team-config-store.ts b/src/tools/agent-teams/team-config-store.ts index 45572966..707659fe 100644 --- a/src/tools/agent-teams/team-config-store.ts +++ b/src/tools/agent-teams/team-config-store.ts @@ -51,12 +51,12 @@ function withTeamLock(teamName: string, operation: () => T): T { } } -function createLeadMember(teamName: string, cwd: string, model: string): TeamLeadMember { +function createLeadMember(teamName: string, cwd: string, leadModel: string): TeamLeadMember { return { agentId: `team-lead@${teamName}`, name: "team-lead", agentType: "team-lead", - model, + model: leadModel, joinedAt: nowMs(), cwd, subscriptions: [], @@ -82,7 +82,7 @@ export function createTeamConfig( description: string, leadSessionId: string, cwd: string, - model: string, + leadModel: string, ): TeamConfig { ensureTeamStorageDirs(teamName) @@ -93,7 +93,7 @@ export function createTeamConfig( createdAt: nowMs(), leadAgentId, leadSessionId, - members: [createLeadMember(teamName, cwd, model)], + members: [createLeadMember(teamName, cwd, leadModel)], } return withTeamLock(teamName, () => { diff --git a/src/tools/agent-teams/team-lifecycle-tools.ts b/src/tools/agent-teams/team-lifecycle-tools.ts index 4d281f88..bd252d03 100644 --- a/src/tools/agent-teams/team-lifecycle-tools.ts +++ b/src/tools/agent-teams/team-lifecycle-tools.ts @@ -30,7 +30,7 @@ export function createTeamCreateTool(): ToolDefinition { input.description ?? "", context.sessionID, process.cwd(), - context.agent ?? "native", + "native/team-lead", ) ensureInbox(config.name, "team-lead") diff --git a/src/tools/agent-teams/teammate-runtime.ts b/src/tools/agent-teams/teammate-runtime.ts index a3f38369..50289bf3 100644 --- a/src/tools/agent-teams/teammate-runtime.ts +++ b/src/tools/agent-teams/teammate-runtime.ts @@ -70,7 +70,7 @@ export interface SpawnTeammateParams { teamName: string name: string prompt: string - category?: string + category: string subagentType: string model?: string planModeRequired: boolean @@ -100,17 +100,20 @@ async function getSystemDefaultModel(client: PluginInput["client"]): Promise { - if (!params.category) { + if (params.model) { const launchModel = parseModel(params.model) return { agentType: params.subagentType, - teammateModel: params.model ?? "native", + teammateModel: params.model, ...(launchModel ? { launchModel } : {}), } } if (!params.categoryContext?.client) { - throw new Error("category_requires_client_context") + return { + agentType: params.subagentType, + teammateModel: "native", + } } const parentContext = resolveParentContext({ @@ -179,7 +182,7 @@ export async function spawnTeammate(params: SpawnTeammateParams): Promise): Promise => { + execute: async (args: Record, context: TeamToolContext): Promise => { try { const input = TeamForceKillInputSchema.parse(args) const teamError = validateTeamName(input.team_name) @@ -112,6 +108,9 @@ export function createForceKillTeammateTool(manager: BackgroundManager): ToolDef return JSON.stringify({ error: agentError }) } const config = readTeamConfigOrThrow(input.team_name) + if (context.sessionID !== config.leadSessionId) { + return JSON.stringify({ error: "unauthorized_lead_session" }) + } const member = getTeamMember(config, input.agent_name) if (!member || !isTeammateMember(member)) { return JSON.stringify({ error: "teammate_not_found" }) @@ -149,7 +148,7 @@ export function createProcessShutdownTool(manager: BackgroundManager): ToolDefin team_name: tool.schema.string().describe("Team name"), agent_name: tool.schema.string().describe("Teammate name"), }, - execute: async (args: Record): Promise => { + execute: async (args: Record, context: TeamToolContext): Promise => { try { const input = TeamProcessShutdownInputSchema.parse(args) const teamError = validateTeamName(input.team_name) @@ -165,6 +164,9 @@ export function createProcessShutdownTool(manager: BackgroundManager): ToolDefin } const config = readTeamConfigOrThrow(input.team_name) + if (context.sessionID !== config.leadSessionId) { + return JSON.stringify({ error: "unauthorized_lead_session" }) + } const member = getTeamMember(config, input.agent_name) if (!member || !isTeammateMember(member)) { return JSON.stringify({ error: "teammate_not_found" }) diff --git a/src/tools/agent-teams/tools.functional.test.ts b/src/tools/agent-teams/tools.functional.test.ts index 347ae446..ccce8281 100644 --- a/src/tools/agent-teams/tools.functional.test.ts +++ b/src/tools/agent-teams/tools.functional.test.ts @@ -181,12 +181,13 @@ describe("agent-teams tools functional", () => { //#when const config = await executeJsonTool(tools, "read_config", { team_name: "core" }, context) as { name: string - members: Array<{ name: string }> + members: Array<{ name: string; model?: string }> } //#then expect(config.name).toBe("core") expect(config.members.map((member) => member.name)).toEqual(["team-lead"]) + expect(config.members[0]?.model).toBe("native/team-lead") //#when const deleted = await executeJsonTool(tools, "team_delete", { team_name: "core" }, context) as { @@ -213,6 +214,7 @@ describe("agent-teams tools functional", () => { team_name: "core", name: "worker_1", prompt: "Handle release prep", + category: "quick", }, context, ) @@ -340,6 +342,30 @@ describe("agent-teams tools functional", () => { expect(teammate?.model).toBe(`${resolvedModel.providerID}/${resolvedModel.modelID}`) }) + test("spawn_teammate requires a category", async () => { + //#given + const { manager } = createMockManager() + const tools = createAgentTeamsTools(manager) + const context = createContext() + + await executeJsonTool(tools, "team_create", { team_name: "core" }, context) + + //#when + const result = await executeJsonTool( + tools, + "spawn_teammate", + { + team_name: "core", + name: "worker_1", + prompt: "Handle release prep", + }, + context, + ) as { error?: string } + + //#then + expect(result.error).toBe("category_required") + }) + test("rejects category with incompatible subagent_type", async () => { //#given const { manager } = createMockManager() @@ -483,6 +509,7 @@ describe("agent-teams tools functional", () => { team_name: "core", name: "worker_1", prompt: "Handle release prep", + category: "quick", }, context, ) as { name: string; session_id: string; task_id: string } @@ -617,6 +644,7 @@ describe("agent-teams tools functional", () => { team_name: "core", name: "worker_1", prompt: "Handle release prep", + category: "quick", }, leadContext, ) @@ -653,6 +681,78 @@ describe("agent-teams tools functional", () => { expect(configAfterShutdown.members.some((member) => member.name === "worker_1")).toBe(false) }) + test("force_kill_teammate requires lead session authorization", async () => { + //#given + const { manager } = createMockManager() + const tools = createAgentTeamsTools(manager) + const leadContext = createContext() + + 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, + ) + + const teammateContext = createContext("ses-worker-1") + + //#when + const unauthorized = await executeJsonTool( + tools, + "force_kill_teammate", + { + team_name: "core", + agent_name: "worker_1", + }, + teammateContext, + ) as { error?: string } + + //#then + expect(unauthorized.error).toBe("unauthorized_lead_session") + }) + + test("process_shutdown_approved requires lead session authorization", async () => { + //#given + const { manager } = createMockManager() + const tools = createAgentTeamsTools(manager) + const leadContext = createContext() + + 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, + ) + + const teammateContext = createContext("ses-worker-1") + + //#when + const unauthorized = await executeJsonTool( + tools, + "process_shutdown_approved", + { + team_name: "core", + agent_name: "worker_1", + }, + teammateContext, + ) as { error?: string } + + //#then + expect(unauthorized.error).toBe("unauthorized_lead_session") + }) + test("rolls back teammate and cancels background task when launch fails", async () => { //#given const { manager, cancelCalls } = createFailingLaunchManager() @@ -668,6 +768,7 @@ describe("agent-teams tools functional", () => { team_name: "core", name: "worker_1", prompt: "Handle release prep", + category: "quick", }, context, ) as { error?: string } @@ -709,6 +810,7 @@ describe("agent-teams tools functional", () => { team_name: "core", name: "worker_1", prompt: "Handle release prep", + category: "quick", model: "invalid-format", }, context, @@ -742,6 +844,7 @@ describe("agent-teams tools functional", () => { team_name: "core", name: "worker_1", prompt: "Handle release prep", + category: "quick", model: "openai/gpt-5.3-codex/reasoning", }, context, @@ -792,6 +895,7 @@ describe("agent-teams tools functional", () => { team_name: "core", name: "worker_1", prompt: "Handle release prep", + category: "quick", }, leadContext, ) @@ -827,14 +931,13 @@ describe("agent-teams tools functional", () => { expect(Array.isArray(ownInbox)).toBe(true) }) - test("allows lead session to rotate after restart using team-lead identity", async () => { + test("rejects unknown session claiming team-lead identity", async () => { //#given const { manager } = createMockManager() const tools = createAgentTeamsTools(manager) - const originalLead = createContext("ses-main") - await executeJsonTool(tools, "team_create", { team_name: "core" }, originalLead) - - const restartedLead = createContext("ses-restarted") + const leadContext = createContext("ses-main") + await executeJsonTool(tools, "team_create", { team_name: "core" }, leadContext) + const unknownContext = createContext("ses-unknown") //#when const sendResult = await executeJsonTool( @@ -848,20 +951,43 @@ describe("agent-teams tools functional", () => { summary: "restart", content: "Lead session migrated", }, - restartedLead, + unknownContext, ) as { success?: boolean; error?: string } //#then - expect(sendResult.error).toBeUndefined() - expect(sendResult.success).toBe(true) + expect(sendResult.success).toBeUndefined() + expect(sendResult.error).toBe("unauthorized_sender_session") //#when - const config = await executeJsonTool(tools, "read_config", { team_name: "core" }, restartedLead) as { + const config = await executeJsonTool(tools, "read_config", { team_name: "core" }, leadContext) as { leadSessionId: string } //#then - expect(config.leadSessionId).toBe("ses-restarted") + expect(config.leadSessionId).toBe("ses-main") + }) + + test("rejects unknown session claiming team-lead inbox", async () => { + //#given + const { manager } = createMockManager() + const tools = createAgentTeamsTools(manager) + const leadContext = createContext("ses-main") + await executeJsonTool(tools, "team_create", { team_name: "core" }, leadContext) + const unknownContext = createContext("ses-unknown") + + //#when + const result = await executeJsonTool( + tools, + "read_inbox", + { + team_name: "core", + agent_name: "team-lead", + }, + unknownContext, + ) as { error?: string } + + //#then + expect(result.error).toBe("unauthorized_reader_session") }) test("clears old inbox when teammate is removed then re-spawned", async () => { @@ -878,6 +1004,7 @@ describe("agent-teams tools functional", () => { team_name: "core", name: "worker_1", prompt: "First run", + category: "quick", }, context, ) @@ -913,6 +1040,7 @@ describe("agent-teams tools functional", () => { team_name: "core", name: "worker_1", prompt: "Second run", + category: "quick", }, context, ) @@ -1004,6 +1132,7 @@ describe("agent-teams tools functional", () => { team_name: "core", name: "worker_1", prompt: "Handle release prep", + category: "quick", }, leadContext, ) diff --git a/src/tools/agent-teams/types.test.ts b/src/tools/agent-teams/types.test.ts index 85291866..ea9a1e4b 100644 --- a/src/tools/agent-teams/types.test.ts +++ b/src/tools/agent-teams/types.test.ts @@ -9,6 +9,7 @@ describe("agent-teams types", () => { agentId: "worker@team", name: "worker", agentType: "team-lead", + category: "quick", model: "native", prompt: "do work", color: "blue", diff --git a/src/tools/agent-teams/types.ts b/src/tools/agent-teams/types.ts index c540fefb..dad40801 100644 --- a/src/tools/agent-teams/types.ts +++ b/src/tools/agent-teams/types.ts @@ -30,7 +30,7 @@ export const TeamTeammateMemberSchema = z.object({ agentType: z.string().refine((value) => value !== "team-lead", { message: "agent_type_reserved", }), - category: z.string().optional(), + category: z.string(), model: z.string(), prompt: z.string(), color: z.string(),