diff --git a/src/tools/agent-teams/messaging-tools.ts b/src/tools/agent-teams/messaging-tools.ts index 8010b613d..444cf7f94 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 } from "./team-config-store" +import { getTeamMember, listTeammates, readTeamConfigOrThrow, updateTeamConfig } from "./team-config-store" import { validateAgentNameOrLead, validateTeamName } from "./name-validation" import { resumeTeammateWithMessage } from "./teammate-runtime" import { @@ -25,6 +25,13 @@ 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.", @@ -50,8 +57,12 @@ export function createSendMessageTool(manager: BackgroundManager): ToolDefinitio if (senderError) { return JSON.stringify({ error: senderError }) } - const config = readTeamConfigOrThrow(input.team_name) - const actor = resolveSenderFromContext(config, context) + 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" + } if (!actor) { return JSON.stringify({ error: "unauthorized_sender_session" }) } @@ -223,8 +234,12 @@ export function createReadInboxTool(): ToolDefinition { if (agentError) { return JSON.stringify({ error: agentError }) } - const config = readTeamConfigOrThrow(input.team_name) - const actor = resolveSenderFromContext(config, context) + 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" + } if (!actor) { return JSON.stringify({ error: "unauthorized_reader_session" }) } diff --git a/src/tools/agent-teams/teammate-runtime.ts b/src/tools/agent-teams/teammate-runtime.ts index 8aabdf6a0..ace04047b 100644 --- a/src/tools/agent-teams/teammate-runtime.ts +++ b/src/tools/agent-teams/teammate-runtime.ts @@ -7,10 +7,13 @@ function parseModel(model: string | undefined): { providerID: string; modelID: s if (!model) { return undefined } - const [providerID, modelID] = model.split("/", 2) - if (!providerID || !modelID) { + 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 } } diff --git a/src/tools/agent-teams/teammate-tools.ts b/src/tools/agent-teams/teammate-tools.ts index 9b93c55ff..59d8c712c 100644 --- a/src/tools/agent-teams/teammate-tools.ts +++ b/src/tools/agent-teams/teammate-tools.ts @@ -1,5 +1,6 @@ import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool" import type { BackgroundManager } from "../../features/background-agent" +import { clearInbox } from "./inbox-store" import { validateAgentName, validateTeamName } from "./name-validation" import { TeamForceKillInputSchema, @@ -8,7 +9,7 @@ import { TeamToolContext, isTeammateMember, } from "./types" -import { getTeamMember, readTeamConfigOrThrow, removeTeammate, updateTeamConfig, writeTeamConfig } from "./team-config-store" +import { getTeamMember, readTeamConfigOrThrow, removeTeammate, updateTeamConfig } from "./team-config-store" import { cancelTeammateRun, spawnTeammate } from "./teammate-runtime" import { resetOwnerTasks } from "./team-task-store" @@ -86,11 +87,20 @@ export function createForceKillTeammateTool(manager: BackgroundManager): ToolDef } await cancelTeammateRun(manager, member) - const refreshedConfig = readTeamConfigOrThrow(input.team_name) - const refreshedMember = getTeamMember(refreshedConfig, input.agent_name) - if (refreshedMember && isTeammateMember(refreshedMember)) { - writeTeamConfig(input.team_name, removeTeammate(refreshedConfig, input.agent_name)) + let removed = false + updateTeamConfig(input.team_name, (current) => { + const refreshedMember = getTeamMember(current, input.agent_name) + if (!refreshedMember || !isTeammateMember(refreshedMember)) { + return current + } + removed = true + return removeTeammate(current, input.agent_name) + }) + + if (removed) { + clearInbox(input.team_name, input.agent_name) } + resetOwnerTasks(input.team_name, input.agent_name) return JSON.stringify({ success: true, message: `${input.agent_name} stopped` }) @@ -130,16 +140,21 @@ export function createProcessShutdownTool(manager: BackgroundManager): ToolDefin } await cancelTeammateRun(manager, member) + let removed = false updateTeamConfig(input.team_name, (current) => { const refreshedMember = getTeamMember(current, input.agent_name) if (!refreshedMember || !isTeammateMember(refreshedMember)) { return current } - + removed = true return removeTeammate(current, input.agent_name) }) + if (removed) { + clearInbox(input.team_name, input.agent_name) + } + resetOwnerTasks(input.team_name, input.agent_name) return JSON.stringify({ success: true, message: `${input.agent_name} removed` }) diff --git a/src/tools/agent-teams/tools.functional.test.ts b/src/tools/agent-teams/tools.functional.test.ts index d9ee7078d..f229fea8a 100644 --- a/src/tools/agent-teams/tools.functional.test.ts +++ b/src/tools/agent-teams/tools.functional.test.ts @@ -636,6 +636,36 @@ describe("agent-teams tools functional", () => { expect(config.members.map((member) => member.name)).toEqual(["team-lead"]) }) + test("keeps full model id suffix when override contains extra slashes", async () => { + //#given + const { manager, launchCalls } = createMockManager() + const tools = createAgentTeamsTools(manager) + const context = createContext() + await executeJsonTool(tools, "team_create", { team_name: "core" }, context) + + //#when + const spawnResult = await executeJsonTool( + tools, + "spawn_teammate", + { + team_name: "core", + name: "worker_1", + prompt: "Handle release prep", + model: "openai/gpt-5.3-codex/reasoning", + }, + context, + ) as { name?: string; error?: string } + + //#then + expect(spawnResult.error).toBeUndefined() + expect(spawnResult.name).toBe("worker_1") + expect(launchCalls).toHaveLength(1) + expect(launchCalls[0].model).toEqual({ + providerID: "openai", + modelID: "gpt-5.3-codex/reasoning", + }) + }) + test("read_inbox returns team_not_found for unknown team", async () => { //#given const { manager } = createMockManager() @@ -706,6 +736,113 @@ describe("agent-teams tools functional", () => { expect(Array.isArray(ownInbox)).toBe(true) }) + test("allows lead session to rotate after restart using 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") + + //#when + const sendResult = await executeJsonTool( + tools, + "send_message", + { + team_name: "core", + type: "message", + sender: "team-lead", + recipient: "team-lead", + summary: "restart", + content: "Lead session migrated", + }, + restartedLead, + ) as { success?: boolean; error?: string } + + //#then + expect(sendResult.error).toBeUndefined() + expect(sendResult.success).toBe(true) + + //#when + const config = await executeJsonTool(tools, "read_config", { team_name: "core" }, restartedLead) as { + leadSessionId: string + } + + //#then + expect(config.leadSessionId).toBe("ses-restarted") + }) + + test("clears old inbox when teammate is removed then re-spawned", 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: "First run", + }, + context, + ) + + await executeJsonTool( + tools, + "send_message", + { + team_name: "core", + type: "message", + recipient: "worker_1", + summary: "legacy", + content: "legacy payload", + }, + context, + ) + + await executeJsonTool( + tools, + "force_kill_teammate", + { + team_name: "core", + agent_name: "worker_1", + }, + context, + ) + + //#when + await executeJsonTool( + tools, + "spawn_teammate", + { + team_name: "core", + name: "worker_1", + prompt: "Second run", + }, + context, + ) + + const inbox = await executeJsonTool( + tools, + "read_inbox", + { + team_name: "core", + agent_name: "worker_1", + unread_only: true, + mark_as_read: false, + }, + context, + ) as Array<{ text: string; summary?: string }> + + //#then + expect(inbox.some((message) => message.text.includes("legacy payload"))).toBe(false) + expect(inbox.some((message) => message.summary === "initial_prompt" && message.text.includes("Second run"))).toBe(true) + }) + test("cannot add pending blockers to already in-progress task without status change", async () => { //#given const { manager } = createMockManager()