From 40f844fb8542f3dad9364693293d46ec8f3a8f27 Mon Sep 17 00:00:00 2001 From: Nguyen Khac Trung Kien Date: Sun, 8 Feb 2026 11:26:20 +0700 Subject: [PATCH] fix(agent-teams): align spawn schema and harden inbox rollback behavior --- src/tools/agent-teams/inbox-store.ts | 28 ++++---- src/tools/agent-teams/teammate-runtime.ts | 19 +++++- src/tools/agent-teams/teammate-tools.ts | 6 +- .../agent-teams/tools.functional.test.ts | 67 ++++++++++++++++++- src/tools/agent-teams/types.ts | 2 +- 5 files changed, 102 insertions(+), 20 deletions(-) diff --git a/src/tools/agent-teams/inbox-store.ts b/src/tools/agent-teams/inbox-store.ts index 3346f196f..4b9fbfdb0 100644 --- a/src/tools/agent-teams/inbox-store.ts +++ b/src/tools/agent-teams/inbox-store.ts @@ -144,24 +144,28 @@ export function readInbox( return withInboxLock(teamName, () => { const messages = readInboxMessages(teamName, agentName) - const selected = unreadOnly ? messages.filter((message) => !message.read) : [...messages] + const selectedIndexes = new Set() + const selected = unreadOnly + ? messages.filter((message, index) => { + if (!message.read) { + selectedIndexes.add(index) + return true + } + return false + }) + : messages.map((message, index) => { + selectedIndexes.add(index) + return message + }) if (!markAsRead || selected.length === 0) { 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 (selectedSet?.has(message)) { + const updated = messages.map((message, index) => { + if (selectedIndexes.has(index) && !message.read) { changed = true return { ...message, read: true } } @@ -171,7 +175,7 @@ export function readInbox( if (changed) { writeInboxMessages(teamName, agentName, updated) } - return selected + return updated.filter((_, index) => selectedIndexes.has(index)) }) } diff --git a/src/tools/agent-teams/teammate-runtime.ts b/src/tools/agent-teams/teammate-runtime.ts index 50289bf3f..484233d0f 100644 --- a/src/tools/agent-teams/teammate-runtime.ts +++ b/src/tools/agent-teams/teammate-runtime.ts @@ -246,6 +246,8 @@ export async function spawnTeammate(params: SpawnTeammateParams): Promise upsertTeammate(current, nextMember)) return nextMember } catch (error) { + const originalError = error + if (launchedTaskID) { await params.manager .cancelTask(launchedTaskID, { @@ -255,9 +257,20 @@ export async function spawnTeammate(params: SpawnTeammateParams): Promise undefined) } - updateTeamConfig(params.teamName, (current) => removeTeammate(current, params.name)) - clearInbox(params.teamName, params.name) - throw error + + try { + updateTeamConfig(params.teamName, (current) => removeTeammate(current, params.name)) + } catch (cleanupError) { + void cleanupError + } + + try { + clearInbox(params.teamName, params.name) + } catch (cleanupError) { + void cleanupError + } + + throw originalError } } diff --git a/src/tools/agent-teams/teammate-tools.ts b/src/tools/agent-teams/teammate-tools.ts index 6f1c2e969..8b60f3c66 100644 --- a/src/tools/agent-teams/teammate-tools.ts +++ b/src/tools/agent-teams/teammate-tools.ts @@ -28,7 +28,7 @@ export function createSpawnTeammateTool(manager: BackgroundManager, options?: Ag team_name: tool.schema.string().describe("Team name"), name: tool.schema.string().describe("Teammate name"), prompt: tool.schema.string().describe("Initial teammate prompt"), - category: tool.schema.string().optional().describe("Required category for teammate metadata and routing"), + category: tool.schema.string().describe("Required category for teammate metadata and routing"), subagent_type: tool.schema.string().optional().describe("Agent name to run (default: sisyphus-junior)"), model: tool.schema.string().optional().describe("Optional model override in provider/model format"), plan_mode_required: tool.schema.boolean().optional().describe("Enable plan mode flag in teammate metadata"), @@ -46,11 +46,11 @@ export function createSpawnTeammateTool(manager: BackgroundManager, options?: Ag return JSON.stringify({ error: agentError }) } - if (!input.category || !input.category.trim()) { + if (!input.category.trim()) { return JSON.stringify({ error: "category_required" }) } - if (input.category && input.subagent_type && input.subagent_type !== "sisyphus-junior") { + if (input.subagent_type && input.subagent_type !== "sisyphus-junior") { return JSON.stringify({ error: "category_conflicts_with_subagent_type" }) } diff --git a/src/tools/agent-teams/tools.functional.test.ts b/src/tools/agent-teams/tools.functional.test.ts index ccce82817..c34dc1ac5 100644 --- a/src/tools/agent-teams/tools.functional.test.ts +++ b/src/tools/agent-teams/tools.functional.test.ts @@ -363,7 +363,8 @@ describe("agent-teams tools functional", () => { ) as { error?: string } //#then - expect(result.error).toBe("category_required") + expect(result.error).toBeDefined() + expect(result.error).toContain("category") }) test("rejects category with incompatible subagent_type", async () => { @@ -931,6 +932,70 @@ describe("agent-teams tools functional", () => { expect(Array.isArray(ownInbox)).toBe(true) }) + test("read_inbox returns messages with read=true when mark_as_read is enabled", async () => { + //#given + const { manager } = createMockManager() + const tools = createAgentTeamsTools(manager) + const context = createContext() + + await executeJsonTool(tools, "team_create", { team_name: "core" }, context) + await executeJsonTool( + tools, + "spawn_teammate", + { + team_name: "core", + name: "worker_1", + prompt: "Handle release prep", + category: "quick", + }, + context, + ) + + //#when + const unreadBefore = await executeJsonTool( + tools, + "read_inbox", + { + team_name: "core", + agent_name: "worker_1", + unread_only: true, + mark_as_read: false, + }, + context, + ) as Array<{ read: boolean }> + + const markedRead = await executeJsonTool( + tools, + "read_inbox", + { + team_name: "core", + agent_name: "worker_1", + unread_only: true, + mark_as_read: true, + }, + context, + ) as Array<{ read: boolean }> + + const unreadAfter = await executeJsonTool( + tools, + "read_inbox", + { + team_name: "core", + agent_name: "worker_1", + unread_only: true, + mark_as_read: false, + }, + context, + ) as Array<{ read: boolean }> + + //#then + expect(unreadBefore.length).toBeGreaterThan(0) + expect(unreadBefore.every((message) => message.read === false)).toBe(true) + expect(markedRead.length).toBeGreaterThan(0) + expect(markedRead.every((message) => message.read === true)).toBe(true) + expect(unreadAfter).toHaveLength(0) + }) + test("rejects unknown session claiming team-lead identity", async () => { //#given const { manager } = createMockManager() diff --git a/src/tools/agent-teams/types.ts b/src/tools/agent-teams/types.ts index dad408019..89296c578 100644 --- a/src/tools/agent-teams/types.ts +++ b/src/tools/agent-teams/types.ts @@ -109,7 +109,7 @@ export const TeamSpawnInputSchema = z.object({ team_name: z.string(), name: z.string(), prompt: z.string(), - category: z.string().optional(), + category: z.string(), subagent_type: z.string().optional(), model: z.string().optional(), plan_mode_required: z.boolean().optional(),