fix(agent-teams): rotate lead session and clear stale teammate inbox
This commit is contained in:
committed by
YeonGyu-Kim
parent
11766b085d
commit
3f859828cc
@@ -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" })
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -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` })
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user