fix(agent-teams): rotate lead session and clear stale teammate inbox

This commit is contained in:
Nguyen Khac Trung Kien
2026-02-08 10:15:56 +07:00
committed by YeonGyu-Kim
parent 11766b085d
commit 3f859828cc
4 changed files with 183 additions and 13 deletions

View File

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

View File

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

View File

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

View File

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