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 { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
|
||||||
import type { BackgroundManager } from "../../features/background-agent"
|
import type { BackgroundManager } from "../../features/background-agent"
|
||||||
import { buildShutdownRequestId, readInbox, sendPlainInboxMessage, sendStructuredInboxMessage } from "./inbox-store"
|
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 { validateAgentNameOrLead, validateTeamName } from "./name-validation"
|
||||||
import { resumeTeammateWithMessage } from "./teammate-runtime"
|
import { resumeTeammateWithMessage } from "./teammate-runtime"
|
||||||
import {
|
import {
|
||||||
@@ -25,6 +25,13 @@ function resolveSenderFromContext(config: TeamConfig, context: TeamToolContext):
|
|||||||
return matchedMember?.name ?? null
|
return matchedMember?.name ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function claimLeadSession(teamName: string, nextLeadSessionId: string): TeamConfig {
|
||||||
|
return updateTeamConfig(teamName, (current) => ({
|
||||||
|
...current,
|
||||||
|
leadSessionId: nextLeadSessionId,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
export function createSendMessageTool(manager: BackgroundManager): ToolDefinition {
|
export function createSendMessageTool(manager: BackgroundManager): ToolDefinition {
|
||||||
return tool({
|
return tool({
|
||||||
description: "Send direct or broadcast team messages and protocol responses.",
|
description: "Send direct or broadcast team messages and protocol responses.",
|
||||||
@@ -50,8 +57,12 @@ export function createSendMessageTool(manager: BackgroundManager): ToolDefinitio
|
|||||||
if (senderError) {
|
if (senderError) {
|
||||||
return JSON.stringify({ error: senderError })
|
return JSON.stringify({ error: senderError })
|
||||||
}
|
}
|
||||||
const config = readTeamConfigOrThrow(input.team_name)
|
let config = readTeamConfigOrThrow(input.team_name)
|
||||||
const actor = resolveSenderFromContext(config, context)
|
let actor = resolveSenderFromContext(config, context)
|
||||||
|
if (!actor && requestedSender === "team-lead") {
|
||||||
|
config = claimLeadSession(input.team_name, context.sessionID)
|
||||||
|
actor = "team-lead"
|
||||||
|
}
|
||||||
if (!actor) {
|
if (!actor) {
|
||||||
return JSON.stringify({ error: "unauthorized_sender_session" })
|
return JSON.stringify({ error: "unauthorized_sender_session" })
|
||||||
}
|
}
|
||||||
@@ -223,8 +234,12 @@ export function createReadInboxTool(): ToolDefinition {
|
|||||||
if (agentError) {
|
if (agentError) {
|
||||||
return JSON.stringify({ error: agentError })
|
return JSON.stringify({ error: agentError })
|
||||||
}
|
}
|
||||||
const config = readTeamConfigOrThrow(input.team_name)
|
let config = readTeamConfigOrThrow(input.team_name)
|
||||||
const actor = resolveSenderFromContext(config, context)
|
let actor = resolveSenderFromContext(config, context)
|
||||||
|
if (!actor && input.agent_name === "team-lead") {
|
||||||
|
config = claimLeadSession(input.team_name, context.sessionID)
|
||||||
|
actor = "team-lead"
|
||||||
|
}
|
||||||
if (!actor) {
|
if (!actor) {
|
||||||
return JSON.stringify({ error: "unauthorized_reader_session" })
|
return JSON.stringify({ error: "unauthorized_reader_session" })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,10 +7,13 @@ function parseModel(model: string | undefined): { providerID: string; modelID: s
|
|||||||
if (!model) {
|
if (!model) {
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
const [providerID, modelID] = model.split("/", 2)
|
const separatorIndex = model.indexOf("/")
|
||||||
if (!providerID || !modelID) {
|
if (separatorIndex <= 0 || separatorIndex >= model.length - 1) {
|
||||||
throw new Error("invalid_model_override_format")
|
throw new Error("invalid_model_override_format")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const providerID = model.slice(0, separatorIndex)
|
||||||
|
const modelID = model.slice(separatorIndex + 1)
|
||||||
return { providerID, modelID }
|
return { providerID, modelID }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
|
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
|
||||||
import type { BackgroundManager } from "../../features/background-agent"
|
import type { BackgroundManager } from "../../features/background-agent"
|
||||||
|
import { clearInbox } from "./inbox-store"
|
||||||
import { validateAgentName, validateTeamName } from "./name-validation"
|
import { validateAgentName, validateTeamName } from "./name-validation"
|
||||||
import {
|
import {
|
||||||
TeamForceKillInputSchema,
|
TeamForceKillInputSchema,
|
||||||
@@ -8,7 +9,7 @@ import {
|
|||||||
TeamToolContext,
|
TeamToolContext,
|
||||||
isTeammateMember,
|
isTeammateMember,
|
||||||
} from "./types"
|
} 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 { cancelTeammateRun, spawnTeammate } from "./teammate-runtime"
|
||||||
import { resetOwnerTasks } from "./team-task-store"
|
import { resetOwnerTasks } from "./team-task-store"
|
||||||
|
|
||||||
@@ -86,11 +87,20 @@ export function createForceKillTeammateTool(manager: BackgroundManager): ToolDef
|
|||||||
}
|
}
|
||||||
|
|
||||||
await cancelTeammateRun(manager, member)
|
await cancelTeammateRun(manager, member)
|
||||||
const refreshedConfig = readTeamConfigOrThrow(input.team_name)
|
let removed = false
|
||||||
const refreshedMember = getTeamMember(refreshedConfig, input.agent_name)
|
updateTeamConfig(input.team_name, (current) => {
|
||||||
if (refreshedMember && isTeammateMember(refreshedMember)) {
|
const refreshedMember = getTeamMember(current, input.agent_name)
|
||||||
writeTeamConfig(input.team_name, removeTeammate(refreshedConfig, 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)
|
resetOwnerTasks(input.team_name, input.agent_name)
|
||||||
|
|
||||||
return JSON.stringify({ success: true, message: `${input.agent_name} stopped` })
|
return JSON.stringify({ success: true, message: `${input.agent_name} stopped` })
|
||||||
@@ -130,16 +140,21 @@ export function createProcessShutdownTool(manager: BackgroundManager): ToolDefin
|
|||||||
}
|
}
|
||||||
|
|
||||||
await cancelTeammateRun(manager, member)
|
await cancelTeammateRun(manager, member)
|
||||||
|
let removed = false
|
||||||
|
|
||||||
updateTeamConfig(input.team_name, (current) => {
|
updateTeamConfig(input.team_name, (current) => {
|
||||||
const refreshedMember = getTeamMember(current, input.agent_name)
|
const refreshedMember = getTeamMember(current, input.agent_name)
|
||||||
if (!refreshedMember || !isTeammateMember(refreshedMember)) {
|
if (!refreshedMember || !isTeammateMember(refreshedMember)) {
|
||||||
return current
|
return current
|
||||||
}
|
}
|
||||||
|
removed = true
|
||||||
return removeTeammate(current, input.agent_name)
|
return removeTeammate(current, input.agent_name)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (removed) {
|
||||||
|
clearInbox(input.team_name, input.agent_name)
|
||||||
|
}
|
||||||
|
|
||||||
resetOwnerTasks(input.team_name, input.agent_name)
|
resetOwnerTasks(input.team_name, input.agent_name)
|
||||||
|
|
||||||
return JSON.stringify({ success: true, message: `${input.agent_name} removed` })
|
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"])
|
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 () => {
|
test("read_inbox returns team_not_found for unknown team", async () => {
|
||||||
//#given
|
//#given
|
||||||
const { manager } = createMockManager()
|
const { manager } = createMockManager()
|
||||||
@@ -706,6 +736,113 @@ describe("agent-teams tools functional", () => {
|
|||||||
expect(Array.isArray(ownInbox)).toBe(true)
|
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 () => {
|
test("cannot add pending blockers to already in-progress task without status change", async () => {
|
||||||
//#given
|
//#given
|
||||||
const { manager } = createMockManager()
|
const { manager } = createMockManager()
|
||||||
|
|||||||
Reference in New Issue
Block a user