Compare commits
41 Commits
v3.12.3
...
feat/nativ
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a83020b51 | ||
|
|
16e034492c | ||
|
|
3d5754089e | ||
|
|
eabc20de9e | ||
|
|
48441b831c | ||
|
|
88be194805 | ||
|
|
4a38e09a33 | ||
|
|
aa83b05f1f | ||
|
|
d67138575c | ||
|
|
4c52bf32cd | ||
|
|
f0ae1131de | ||
|
|
d65912bc63 | ||
|
|
3e2e4e29df | ||
|
|
5e06db0c60 | ||
|
|
4282de139b | ||
|
|
386521d185 | ||
|
|
accb874155 | ||
|
|
1e2c10e7b0 | ||
|
|
a9d4cefdfe | ||
|
|
2a57feb810 | ||
|
|
f422cfc7af | ||
|
|
0f0ba0f71b | ||
|
|
c15bad6d00 | ||
|
|
805df45722 | ||
|
|
cf42082c5f | ||
|
|
40f844fb85 | ||
|
|
fe05a1f254 | ||
|
|
e984ce7493 | ||
|
|
3f859828cc | ||
|
|
11766b085d | ||
|
|
2103061123 | ||
|
|
79c3823762 | ||
|
|
dc3d81a0b8 | ||
|
|
7ad60cbedb | ||
|
|
1a5030d359 | ||
|
|
dbcad8fd97 | ||
|
|
0ec6afcd9e | ||
|
|
f4e4fdb2e4 | ||
|
|
db08cc22cc | ||
|
|
766794e0f5 | ||
|
|
0f9c93fd55 |
@@ -649,7 +649,21 @@ describe("ExperimentalConfigSchema feature flags", () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("both fields are optional", () => {
|
||||
test("accepts team_system as boolean", () => {
|
||||
//#given
|
||||
const config = { team_system: true }
|
||||
|
||||
//#when
|
||||
const result = ExperimentalConfigSchema.safeParse(config)
|
||||
|
||||
//#then
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.team_system).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
test("defaults team_system to false when not provided", () => {
|
||||
//#given
|
||||
const config = {}
|
||||
|
||||
@@ -659,10 +673,34 @@ describe("ExperimentalConfigSchema feature flags", () => {
|
||||
//#then
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.plugin_load_timeout_ms).toBeUndefined()
|
||||
expect(result.data.safe_hook_creation).toBeUndefined()
|
||||
expect(result.data.team_system).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
test("accepts team_system as false", () => {
|
||||
//#given
|
||||
const config = { team_system: false }
|
||||
|
||||
//#when
|
||||
const result = ExperimentalConfigSchema.safeParse(config)
|
||||
|
||||
//#then
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.team_system).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
test("rejects non-boolean team_system", () => {
|
||||
//#given
|
||||
const config = { team_system: "true" }
|
||||
|
||||
//#when
|
||||
const result = ExperimentalConfigSchema.safeParse(config)
|
||||
|
||||
//#then
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("GitMasterConfigSchema", () => {
|
||||
|
||||
@@ -15,6 +15,10 @@ export const ExperimentalConfigSchema = z.object({
|
||||
plugin_load_timeout_ms: z.number().min(1000).optional(),
|
||||
/** Wrap hook creation in try/catch to prevent one failing hook from crashing the plugin (default: true at call site) */
|
||||
safe_hook_creation: z.boolean().optional(),
|
||||
/** Enable experimental agent teams toolset (default: false) */
|
||||
agent_teams: z.boolean().optional(),
|
||||
/** Enable experimental team system (default: false) */
|
||||
team_system: z.boolean().default(false),
|
||||
})
|
||||
|
||||
export type ExperimentalConfig = z.infer<typeof ExperimentalConfigSchema>
|
||||
|
||||
72
src/plugin/tool-registry.test.ts
Normal file
72
src/plugin/tool-registry.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/// <reference types="bun-types" />
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createToolRegistry } from "./tool-registry"
|
||||
import type { OhMyOpenCodeConfig } from "../config/schema"
|
||||
|
||||
describe("team system tool registration", () => {
|
||||
test("registers team tools when experimental.team_system is true", () => {
|
||||
const pluginConfig = {
|
||||
experimental: { team_system: true },
|
||||
} as unknown as OhMyOpenCodeConfig
|
||||
|
||||
const result = createToolRegistry({
|
||||
ctx: {} as any,
|
||||
pluginConfig,
|
||||
managers: {} as any,
|
||||
skillContext: {} as any,
|
||||
availableCategories: [],
|
||||
})
|
||||
|
||||
expect(Object.keys(result.filteredTools)).toContain("team_create")
|
||||
expect(Object.keys(result.filteredTools)).toContain("team_delete")
|
||||
expect(Object.keys(result.filteredTools)).toContain("send_message")
|
||||
expect(Object.keys(result.filteredTools)).toContain("read_inbox")
|
||||
expect(Object.keys(result.filteredTools)).toContain("read_config")
|
||||
expect(Object.keys(result.filteredTools)).toContain("force_kill_teammate")
|
||||
expect(Object.keys(result.filteredTools)).toContain("process_shutdown_approved")
|
||||
})
|
||||
|
||||
test("does not register team tools when experimental.team_system is false", () => {
|
||||
const pluginConfig = {
|
||||
experimental: { team_system: false },
|
||||
} as unknown as OhMyOpenCodeConfig
|
||||
|
||||
const result = createToolRegistry({
|
||||
ctx: {} as any,
|
||||
pluginConfig,
|
||||
managers: {} as any,
|
||||
skillContext: {} as any,
|
||||
availableCategories: [],
|
||||
})
|
||||
|
||||
expect(Object.keys(result.filteredTools)).not.toContain("team_create")
|
||||
expect(Object.keys(result.filteredTools)).not.toContain("team_delete")
|
||||
expect(Object.keys(result.filteredTools)).not.toContain("send_message")
|
||||
expect(Object.keys(result.filteredTools)).not.toContain("read_inbox")
|
||||
expect(Object.keys(result.filteredTools)).not.toContain("read_config")
|
||||
expect(Object.keys(result.filteredTools)).not.toContain("force_kill_teammate")
|
||||
expect(Object.keys(result.filteredTools)).not.toContain("process_shutdown_approved")
|
||||
})
|
||||
|
||||
test("does not register team tools when experimental.team_system is undefined", () => {
|
||||
const pluginConfig = {
|
||||
experimental: {},
|
||||
} as unknown as OhMyOpenCodeConfig
|
||||
|
||||
const result = createToolRegistry({
|
||||
ctx: {} as any,
|
||||
pluginConfig,
|
||||
managers: {} as any,
|
||||
skillContext: {} as any,
|
||||
availableCategories: [],
|
||||
})
|
||||
|
||||
expect(Object.keys(result.filteredTools)).not.toContain("team_create")
|
||||
expect(Object.keys(result.filteredTools)).not.toContain("team_delete")
|
||||
expect(Object.keys(result.filteredTools)).not.toContain("send_message")
|
||||
expect(Object.keys(result.filteredTools)).not.toContain("read_inbox")
|
||||
expect(Object.keys(result.filteredTools)).not.toContain("read_config")
|
||||
expect(Object.keys(result.filteredTools)).not.toContain("force_kill_teammate")
|
||||
expect(Object.keys(result.filteredTools)).not.toContain("process_shutdown_approved")
|
||||
})
|
||||
})
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
createAstGrepTools,
|
||||
createSessionManagerTools,
|
||||
createDelegateTask,
|
||||
createAgentTeamsTools,
|
||||
discoverCommandsSync,
|
||||
interactive_bash,
|
||||
createTaskCreateTool,
|
||||
@@ -117,6 +118,15 @@ export function createToolRegistry(args: {
|
||||
}
|
||||
: {}
|
||||
|
||||
const teamSystemEnabled = pluginConfig.experimental?.team_system ?? false
|
||||
const agentTeamsRecord: Record<string, ToolDefinition> = teamSystemEnabled
|
||||
? createAgentTeamsTools(managers.backgroundManager, {
|
||||
client: ctx.client,
|
||||
userCategories: pluginConfig.categories,
|
||||
sisyphusJuniorModel: pluginConfig.agents?.["sisyphus-junior"]?.model,
|
||||
})
|
||||
: {}
|
||||
|
||||
const allTools: Record<string, ToolDefinition> = {
|
||||
...builtinTools,
|
||||
...createGrepTools(ctx),
|
||||
@@ -132,6 +142,7 @@ export function createToolRegistry(args: {
|
||||
slashcommand: slashcommandTool,
|
||||
interactive_bash,
|
||||
...taskToolsRecord,
|
||||
...agentTeamsRecord,
|
||||
}
|
||||
|
||||
const filteredTools = filterDisabledTools(allTools, pluginConfig.disabled_tools)
|
||||
|
||||
87
src/tools/agent-teams/config-tools.test.ts
Normal file
87
src/tools/agent-teams/config-tools.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/// <reference types="bun-types" />
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
||||
import { mkdtempSync, rmSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { createTeamConfig, deleteTeamData } from "./team-config-store"
|
||||
import { createReadConfigTool } from "./config-tools"
|
||||
|
||||
describe("read_config tool", () => {
|
||||
let originalCwd: string
|
||||
let tempProjectDir: string
|
||||
let teamName: string
|
||||
const TEST_SESSION_ID = "test-session-123"
|
||||
const TEST_ABORT_CONTROLLER = new AbortController()
|
||||
const TEST_CONTEXT = {
|
||||
sessionID: TEST_SESSION_ID,
|
||||
messageID: "test-message-123",
|
||||
agent: "test-agent",
|
||||
abort: TEST_ABORT_CONTROLLER.signal,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
originalCwd = process.cwd()
|
||||
tempProjectDir = mkdtempSync(join(tmpdir(), "agent-teams-config-tools-"))
|
||||
process.chdir(tempProjectDir)
|
||||
teamName = `test-team-${randomUUID()}`
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
try {
|
||||
deleteTeamData(teamName)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
process.chdir(originalCwd)
|
||||
rmSync(tempProjectDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe("read config action", () => {
|
||||
test("returns team config when team exists", async () => {
|
||||
//#given
|
||||
const config = createTeamConfig(teamName, "Test team", TEST_SESSION_ID, "/tmp", "claude-opus-4-6")
|
||||
const tool = createReadConfigTool()
|
||||
|
||||
//#when
|
||||
const resultStr = await tool.execute({
|
||||
team_name: teamName,
|
||||
}, TEST_CONTEXT)
|
||||
const result = JSON.parse(resultStr)
|
||||
|
||||
//#then
|
||||
expect(result.name).toBe(teamName)
|
||||
expect(result.description).toBe("Test team")
|
||||
expect(result.members).toHaveLength(1)
|
||||
expect(result.members[0].name).toBe("team-lead")
|
||||
expect(result.members[0].agentType).toBe("team-lead")
|
||||
})
|
||||
|
||||
test("returns error for non-existent team", async () => {
|
||||
//#given
|
||||
const tool = createReadConfigTool()
|
||||
|
||||
//#when
|
||||
const resultStr = await tool.execute({
|
||||
team_name: "nonexistent-team-12345",
|
||||
}, TEST_CONTEXT)
|
||||
const result = JSON.parse(resultStr)
|
||||
|
||||
//#then
|
||||
expect(result).toHaveProperty("error")
|
||||
expect(result.error).toBe("team_not_found")
|
||||
})
|
||||
|
||||
test("requires team_name parameter", async () => {
|
||||
//#given
|
||||
const tool = createReadConfigTool()
|
||||
|
||||
//#when
|
||||
const resultStr = await tool.execute({}, TEST_CONTEXT)
|
||||
const result = JSON.parse(resultStr)
|
||||
|
||||
//#then
|
||||
expect(result).toHaveProperty("error")
|
||||
})
|
||||
})
|
||||
})
|
||||
24
src/tools/agent-teams/config-tools.ts
Normal file
24
src/tools/agent-teams/config-tools.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
|
||||
import { readTeamConfig } from "./team-config-store"
|
||||
import { ReadConfigInputSchema } from "./types"
|
||||
|
||||
export function createReadConfigTool(): ToolDefinition {
|
||||
return tool({
|
||||
description: "Read team configuration and member list.",
|
||||
args: {
|
||||
team_name: tool.schema.string().describe("Team name"),
|
||||
},
|
||||
execute: async (args: Record<string, unknown>): Promise<string> => {
|
||||
try {
|
||||
const input = ReadConfigInputSchema.parse(args)
|
||||
const config = readTeamConfig(input.team_name)
|
||||
if (!config) {
|
||||
return JSON.stringify({ error: "team_not_found" })
|
||||
}
|
||||
return JSON.stringify(config)
|
||||
} catch (error) {
|
||||
return JSON.stringify({ error: error instanceof Error ? error.message : "read_config_failed" })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
190
src/tools/agent-teams/delegation-consistency.test.ts
Normal file
190
src/tools/agent-teams/delegation-consistency.test.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
/// <reference types="bun-types" />
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
||||
import { mkdtempSync, rmSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import type { BackgroundManager } from "../../features/background-agent"
|
||||
import { createAgentTeamsTools } from "./tools"
|
||||
|
||||
interface LaunchCall {
|
||||
description: string
|
||||
prompt: string
|
||||
agent: string
|
||||
parentSessionID: string
|
||||
parentMessageID: string
|
||||
parentAgent?: string
|
||||
parentModel?: {
|
||||
providerID: string
|
||||
modelID: string
|
||||
variant?: string
|
||||
}
|
||||
}
|
||||
|
||||
interface ResumeCall {
|
||||
sessionId: string
|
||||
prompt: string
|
||||
parentSessionID: string
|
||||
parentMessageID: string
|
||||
parentAgent?: string
|
||||
parentModel?: {
|
||||
providerID: string
|
||||
modelID: string
|
||||
variant?: string
|
||||
}
|
||||
}
|
||||
|
||||
interface ToolContextLike {
|
||||
sessionID: string
|
||||
messageID: string
|
||||
abort: AbortSignal
|
||||
agent?: string
|
||||
}
|
||||
|
||||
function createMockManager(): {
|
||||
manager: BackgroundManager
|
||||
launchCalls: LaunchCall[]
|
||||
resumeCalls: ResumeCall[]
|
||||
} {
|
||||
const launchCalls: LaunchCall[] = []
|
||||
const resumeCalls: ResumeCall[] = []
|
||||
const launchedTasks = new Map<string, { id: string; sessionID: string }>()
|
||||
let launchCount = 0
|
||||
|
||||
const manager = {
|
||||
launch: async (args: LaunchCall) => {
|
||||
launchCount += 1
|
||||
launchCalls.push(args)
|
||||
const task = { id: `bg-${launchCount}`, sessionID: `ses-worker-${launchCount}` }
|
||||
launchedTasks.set(task.id, task)
|
||||
return task
|
||||
},
|
||||
getTask: (taskId: string) => launchedTasks.get(taskId),
|
||||
resume: async (args: ResumeCall) => {
|
||||
resumeCalls.push(args)
|
||||
return { id: `resume-${resumeCalls.length}` }
|
||||
},
|
||||
} as unknown as BackgroundManager
|
||||
|
||||
return { manager, launchCalls, resumeCalls }
|
||||
}
|
||||
|
||||
async function executeJsonTool(
|
||||
tools: ReturnType<typeof createAgentTeamsTools>,
|
||||
toolName: keyof ReturnType<typeof createAgentTeamsTools>,
|
||||
args: Record<string, unknown>,
|
||||
context: ToolContextLike,
|
||||
): Promise<unknown> {
|
||||
const output = await tools[toolName].execute(args, context as any)
|
||||
return JSON.parse(output)
|
||||
}
|
||||
|
||||
describe("agent-teams delegation consistency", () => {
|
||||
let originalCwd: string
|
||||
let tempProjectDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
originalCwd = process.cwd()
|
||||
tempProjectDir = mkdtempSync(join(tmpdir(), "agent-teams-consistency-"))
|
||||
process.chdir(tempProjectDir)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(originalCwd)
|
||||
rmSync(tempProjectDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test("team delegation forwards parent context like normal delegate-task", async () => {
|
||||
//#given
|
||||
const { manager, launchCalls, resumeCalls } = createMockManager()
|
||||
const tools = createAgentTeamsTools(manager)
|
||||
const leadContext: ToolContextLike = {
|
||||
sessionID: "ses-main",
|
||||
messageID: "msg-main",
|
||||
abort: new AbortController().signal,
|
||||
agent: "sisyphus",
|
||||
}
|
||||
|
||||
await executeJsonTool(tools, "team_create", { team_name: "core" }, leadContext)
|
||||
|
||||
//#when
|
||||
const spawnResult = await executeJsonTool(
|
||||
tools,
|
||||
"spawn_teammate",
|
||||
{
|
||||
team_name: "core",
|
||||
name: "worker_1",
|
||||
prompt: "Handle release prep",
|
||||
category: "quick",
|
||||
},
|
||||
leadContext,
|
||||
) as { error?: string }
|
||||
|
||||
//#then
|
||||
expect(spawnResult.error).toBeUndefined()
|
||||
expect(launchCalls).toHaveLength(1)
|
||||
expect(launchCalls[0].parentAgent).toBe("sisyphus")
|
||||
expect("parentModel" in launchCalls[0]).toBe(true)
|
||||
|
||||
//#when
|
||||
const messageResult = await executeJsonTool(
|
||||
tools,
|
||||
"send_message",
|
||||
{
|
||||
team_name: "core",
|
||||
type: "message",
|
||||
recipient: "worker_1",
|
||||
summary: "sync",
|
||||
content: "Please update status.",
|
||||
},
|
||||
leadContext,
|
||||
) as { error?: string }
|
||||
|
||||
//#then
|
||||
expect(messageResult.error).toBeUndefined()
|
||||
expect(resumeCalls).toHaveLength(1)
|
||||
expect(resumeCalls[0].parentAgent).toBe("sisyphus")
|
||||
expect("parentModel" in resumeCalls[0]).toBe(true)
|
||||
})
|
||||
|
||||
test("send_message accepts teammate agent_id as recipient", async () => {
|
||||
//#given
|
||||
const { manager, resumeCalls } = createMockManager()
|
||||
const tools = createAgentTeamsTools(manager)
|
||||
const leadContext: ToolContextLike = {
|
||||
sessionID: "ses-main",
|
||||
messageID: "msg-main",
|
||||
abort: new AbortController().signal,
|
||||
}
|
||||
|
||||
await executeJsonTool(tools, "team_create", { team_name: "core" }, leadContext)
|
||||
await executeJsonTool(
|
||||
tools,
|
||||
"spawn_teammate",
|
||||
{
|
||||
team_name: "core",
|
||||
name: "worker_1",
|
||||
prompt: "Handle release prep",
|
||||
category: "quick",
|
||||
},
|
||||
leadContext,
|
||||
)
|
||||
|
||||
//#when
|
||||
const messageResult = await executeJsonTool(
|
||||
tools,
|
||||
"send_message",
|
||||
{
|
||||
team_name: "core",
|
||||
type: "message",
|
||||
recipient: "worker_1@core",
|
||||
summary: "sync",
|
||||
content: "Please update status.",
|
||||
},
|
||||
leadContext,
|
||||
) as { error?: string }
|
||||
|
||||
//#then
|
||||
expect(messageResult.error).toBeUndefined()
|
||||
expect(resumeCalls).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
61
src/tools/agent-teams/inbox-message-sender.ts
Normal file
61
src/tools/agent-teams/inbox-message-sender.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { InboxMessageSchema } from "./types"
|
||||
import { appendInboxMessage } from "./inbox-store"
|
||||
|
||||
function nowIso(): string {
|
||||
return new Date().toISOString()
|
||||
}
|
||||
|
||||
const STRUCTURED_TYPE_MAP: Record<string, string> = {
|
||||
shutdown_request: "shutdown_request",
|
||||
shutdown_approved: "shutdown_response",
|
||||
shutdown_rejected: "shutdown_response",
|
||||
plan_approved: "plan_approval_response",
|
||||
plan_rejected: "plan_approval_response",
|
||||
}
|
||||
|
||||
export function buildShutdownRequestId(recipient: string): string {
|
||||
return `shutdown-${recipient}-${randomUUID().slice(0, 8)}`
|
||||
}
|
||||
|
||||
export function sendPlainInboxMessage(
|
||||
teamName: string,
|
||||
sender: string,
|
||||
recipient: string,
|
||||
content: string,
|
||||
summary: string,
|
||||
_color?: string,
|
||||
): void {
|
||||
const message = InboxMessageSchema.parse({
|
||||
id: randomUUID(),
|
||||
type: "message",
|
||||
sender,
|
||||
recipient,
|
||||
content,
|
||||
summary,
|
||||
timestamp: nowIso(),
|
||||
read: false,
|
||||
})
|
||||
appendInboxMessage(teamName, recipient, message)
|
||||
}
|
||||
|
||||
export function sendStructuredInboxMessage(
|
||||
teamName: string,
|
||||
sender: string,
|
||||
recipient: string,
|
||||
data: Record<string, unknown>,
|
||||
summaryType: string,
|
||||
): void {
|
||||
const messageType = STRUCTURED_TYPE_MAP[summaryType] ?? "message"
|
||||
const message = InboxMessageSchema.parse({
|
||||
id: randomUUID(),
|
||||
type: messageType,
|
||||
sender,
|
||||
recipient,
|
||||
content: JSON.stringify(data),
|
||||
summary: summaryType,
|
||||
timestamp: nowIso(),
|
||||
read: false,
|
||||
})
|
||||
appendInboxMessage(teamName, recipient, message)
|
||||
}
|
||||
59
src/tools/agent-teams/inbox-store.test.ts
Normal file
59
src/tools/agent-teams/inbox-store.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/// <reference types="bun-types" />
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { appendInboxMessage, ensureInbox, readInbox } from "./inbox-store"
|
||||
import { getTeamInboxPath } from "./paths"
|
||||
|
||||
describe("agent-teams inbox store", () => {
|
||||
let originalCwd: string
|
||||
let tempProjectDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
originalCwd = process.cwd()
|
||||
tempProjectDir = mkdtempSync(join(tmpdir(), "agent-teams-inbox-store-"))
|
||||
process.chdir(tempProjectDir)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(originalCwd)
|
||||
rmSync(tempProjectDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test("readInbox fails on malformed inbox JSON without overwriting file", () => {
|
||||
//#given
|
||||
ensureInbox("core", "team-lead")
|
||||
const inboxPath = getTeamInboxPath("core", "team-lead")
|
||||
writeFileSync(inboxPath, "{", "utf-8")
|
||||
|
||||
//#when
|
||||
const readMalformedInbox = () => readInbox("core", "team-lead", false, false)
|
||||
|
||||
//#then
|
||||
expect(readMalformedInbox).toThrow("team_inbox_parse_failed")
|
||||
expect(readFileSync(inboxPath, "utf-8")).toBe("{")
|
||||
})
|
||||
|
||||
test("appendInboxMessage fails on schema-invalid inbox JSON without overwriting file", () => {
|
||||
//#given
|
||||
ensureInbox("core", "team-lead")
|
||||
const inboxPath = getTeamInboxPath("core", "team-lead")
|
||||
writeFileSync(inboxPath, JSON.stringify({ invalid: true }), "utf-8")
|
||||
|
||||
//#when
|
||||
const appendIntoInvalidInbox = () => {
|
||||
appendInboxMessage("core", "team-lead", {
|
||||
from: "team-lead",
|
||||
text: "hello",
|
||||
timestamp: new Date().toISOString(),
|
||||
read: false,
|
||||
summary: "note",
|
||||
})
|
||||
}
|
||||
|
||||
//#then
|
||||
expect(appendIntoInvalidInbox).toThrow("team_inbox_schema_invalid")
|
||||
expect(readFileSync(inboxPath, "utf-8")).toBe(JSON.stringify({ invalid: true }))
|
||||
})
|
||||
})
|
||||
197
src/tools/agent-teams/inbox-store.ts
Normal file
197
src/tools/agent-teams/inbox-store.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import { existsSync, readFileSync, unlinkSync } from "node:fs"
|
||||
import { z } from "zod"
|
||||
import { acquireLock, ensureDir, writeJsonAtomic } from "../../features/claude-tasks/storage"
|
||||
import { getTeamInboxPath } from "./paths"
|
||||
import type { InboxMessage } from "./types"
|
||||
import { InboxMessageSchema } from "./types"
|
||||
|
||||
const InboxMessageListSchema = z.array(InboxMessageSchema)
|
||||
|
||||
function assertValidTeamName(teamName: string): void {
|
||||
const errors: string[] = []
|
||||
|
||||
if (!/^[A-Za-z0-9_-]+$/.test(teamName)) {
|
||||
errors.push("Team name must contain only letters, numbers, hyphens, and underscores")
|
||||
}
|
||||
if (teamName.length > 64) {
|
||||
errors.push("Team name must be at most 64 characters")
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new Error(`Invalid team name: ${errors.join(", ")}`)
|
||||
}
|
||||
}
|
||||
|
||||
function assertValidAgentName(agentName: string): void {
|
||||
if (!agentName || agentName.length === 0) {
|
||||
throw new Error("Agent name must not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
function getTeamInboxDirFromName(teamName: string): string {
|
||||
const { dirname } = require("node:path")
|
||||
return dirname(getTeamInboxPath(teamName, "dummy"))
|
||||
}
|
||||
|
||||
function withInboxLock<T>(teamName: string, operation: () => T): T {
|
||||
assertValidTeamName(teamName)
|
||||
const inboxDir = getTeamInboxDirFromName(teamName)
|
||||
ensureDir(inboxDir)
|
||||
const lock = acquireLock(inboxDir)
|
||||
|
||||
if (!lock.acquired) {
|
||||
throw new Error("inbox_lock_unavailable")
|
||||
}
|
||||
|
||||
try {
|
||||
return operation()
|
||||
} finally {
|
||||
lock.release()
|
||||
}
|
||||
}
|
||||
|
||||
function parseInboxFile(content: string): InboxMessage[] {
|
||||
let parsed: unknown
|
||||
|
||||
try {
|
||||
parsed = JSON.parse(content)
|
||||
} catch {
|
||||
throw new Error("team_inbox_parse_failed")
|
||||
}
|
||||
|
||||
const result = InboxMessageListSchema.safeParse(parsed)
|
||||
if (!result.success) {
|
||||
throw new Error("team_inbox_schema_invalid")
|
||||
}
|
||||
|
||||
return result.data
|
||||
}
|
||||
|
||||
function readInboxMessages(teamName: string, agentName: string): InboxMessage[] {
|
||||
assertValidTeamName(teamName)
|
||||
assertValidAgentName(agentName)
|
||||
const path = getTeamInboxPath(teamName, agentName)
|
||||
|
||||
if (!existsSync(path)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return parseInboxFile(readFileSync(path, "utf-8"))
|
||||
}
|
||||
|
||||
function writeInboxMessages(teamName: string, agentName: string, messages: InboxMessage[]): void {
|
||||
assertValidTeamName(teamName)
|
||||
assertValidAgentName(agentName)
|
||||
const path = getTeamInboxPath(teamName, agentName)
|
||||
writeJsonAtomic(path, messages)
|
||||
}
|
||||
|
||||
export function ensureInbox(teamName: string, agentName: string): void {
|
||||
assertValidTeamName(teamName)
|
||||
assertValidAgentName(agentName)
|
||||
|
||||
withInboxLock(teamName, () => {
|
||||
const path = getTeamInboxPath(teamName, agentName)
|
||||
|
||||
if (!existsSync(path)) {
|
||||
writeJsonAtomic(path, [])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function appendInboxMessage(teamName: string, agentName: string, message: InboxMessage): void {
|
||||
assertValidTeamName(teamName)
|
||||
assertValidAgentName(agentName)
|
||||
|
||||
withInboxLock(teamName, () => {
|
||||
const path = getTeamInboxPath(teamName, agentName)
|
||||
const messages = existsSync(path) ? parseInboxFile(readFileSync(path, "utf-8")) : []
|
||||
messages.push(InboxMessageSchema.parse(message))
|
||||
writeInboxMessages(teamName, agentName, messages)
|
||||
})
|
||||
}
|
||||
|
||||
export function readInbox(teamName: string, agentName: string, unreadOnly = false, markAsRead = false): InboxMessage[] {
|
||||
return withInboxLock(teamName, () => {
|
||||
const messages = readInboxMessages(teamName, agentName)
|
||||
|
||||
const selectedIndexes = new Set<number>()
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
let changed = false
|
||||
|
||||
const updated = messages.map((message, index) => {
|
||||
if (selectedIndexes.has(index) && !message.read) {
|
||||
changed = true
|
||||
return { ...message, read: true }
|
||||
}
|
||||
return message
|
||||
})
|
||||
|
||||
if (changed) {
|
||||
writeInboxMessages(teamName, agentName, updated)
|
||||
}
|
||||
|
||||
return updated.filter((_, index) => selectedIndexes.has(index))
|
||||
})
|
||||
}
|
||||
|
||||
export function markMessagesRead(teamName: string, agentName: string, messageIds: string[]): void {
|
||||
assertValidTeamName(teamName)
|
||||
assertValidAgentName(agentName)
|
||||
|
||||
if (messageIds.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
withInboxLock(teamName, () => {
|
||||
const messages = readInboxMessages(teamName, agentName)
|
||||
const idsToMark = new Set(messageIds)
|
||||
|
||||
const updated = messages.map((message) => {
|
||||
if (idsToMark.has(message.id) && !message.read) {
|
||||
return { ...message, read: true }
|
||||
}
|
||||
return message
|
||||
})
|
||||
|
||||
const changed = updated.some((msg, index) => msg.read !== messages[index].read)
|
||||
|
||||
if (changed) {
|
||||
writeInboxMessages(teamName, agentName, updated)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteInbox(teamName: string, agentName: string): void {
|
||||
assertValidTeamName(teamName)
|
||||
assertValidAgentName(agentName)
|
||||
|
||||
withInboxLock(teamName, () => {
|
||||
const path = getTeamInboxPath(teamName, agentName)
|
||||
|
||||
if (existsSync(path)) {
|
||||
unlinkSync(path)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const clearInbox = deleteInbox
|
||||
|
||||
export { buildShutdownRequestId, sendPlainInboxMessage, sendStructuredInboxMessage } from "./inbox-message-sender"
|
||||
182
src/tools/agent-teams/inbox-tools.test.ts
Normal file
182
src/tools/agent-teams/inbox-tools.test.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
/// <reference types="bun-types" />
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
||||
import { mkdtempSync, rmSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { appendInboxMessage, ensureInbox } from "./inbox-store"
|
||||
import { deleteTeamData } from "./team-config-store"
|
||||
import { createReadInboxTool } from "./inbox-tools"
|
||||
|
||||
describe("read_inbox tool", () => {
|
||||
let originalCwd: string
|
||||
let tempProjectDir: string
|
||||
let teamName: string
|
||||
const TEST_SESSION_ID = "test-session-123"
|
||||
const TEST_ABORT_CONTROLLER = new AbortController()
|
||||
const TEST_CONTEXT = {
|
||||
sessionID: TEST_SESSION_ID,
|
||||
messageID: "test-message-123",
|
||||
agent: "test-agent",
|
||||
abort: TEST_ABORT_CONTROLLER.signal,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
originalCwd = process.cwd()
|
||||
tempProjectDir = mkdtempSync(join(tmpdir(), "agent-teams-inbox-tools-"))
|
||||
process.chdir(tempProjectDir)
|
||||
teamName = `test-team-${randomUUID()}`
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
try {
|
||||
deleteTeamData(teamName)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
process.chdir(originalCwd)
|
||||
rmSync(tempProjectDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(originalCwd)
|
||||
rmSync(tempProjectDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe("read inbox action", () => {
|
||||
test("returns all messages when no filters", async () => {
|
||||
//#given
|
||||
ensureInbox(teamName, "team-lead")
|
||||
appendInboxMessage(teamName, "team-lead", {
|
||||
id: "msg-1",
|
||||
type: "message",
|
||||
sender: "user",
|
||||
recipient: "team-lead",
|
||||
content: "Hello",
|
||||
timestamp: new Date().toISOString(),
|
||||
read: false,
|
||||
})
|
||||
appendInboxMessage(teamName, "team-lead", {
|
||||
id: "msg-2",
|
||||
type: "message",
|
||||
sender: "user",
|
||||
recipient: "team-lead",
|
||||
content: "World",
|
||||
timestamp: new Date().toISOString(),
|
||||
read: true,
|
||||
})
|
||||
|
||||
const tool = createReadInboxTool()
|
||||
|
||||
//#when
|
||||
const resultStr = await tool.execute({
|
||||
team_name: teamName,
|
||||
agent_name: "team-lead",
|
||||
}, TEST_CONTEXT)
|
||||
const result = JSON.parse(resultStr)
|
||||
|
||||
//#then
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0].id).toBe("msg-1")
|
||||
expect(result[1].id).toBe("msg-2")
|
||||
})
|
||||
|
||||
test("returns only unread messages when unread_only is true", async () => {
|
||||
//#given
|
||||
ensureInbox(teamName, "team-lead")
|
||||
appendInboxMessage(teamName, "team-lead", {
|
||||
id: "msg-1",
|
||||
type: "message",
|
||||
sender: "user",
|
||||
recipient: "team-lead",
|
||||
content: "Hello",
|
||||
timestamp: new Date().toISOString(),
|
||||
read: false,
|
||||
})
|
||||
appendInboxMessage(teamName, "team-lead", {
|
||||
id: "msg-2",
|
||||
type: "message",
|
||||
sender: "user",
|
||||
recipient: "team-lead",
|
||||
content: "World",
|
||||
timestamp: new Date().toISOString(),
|
||||
read: true,
|
||||
})
|
||||
|
||||
const tool = createReadInboxTool()
|
||||
|
||||
//#when
|
||||
const resultStr = await tool.execute({
|
||||
team_name: teamName,
|
||||
agent_name: "team-lead",
|
||||
unread_only: true,
|
||||
}, TEST_CONTEXT)
|
||||
const result = JSON.parse(resultStr)
|
||||
|
||||
//#then
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].id).toBe("msg-1")
|
||||
})
|
||||
|
||||
test("marks messages as read when mark_as_read is true", async () => {
|
||||
//#given
|
||||
ensureInbox(teamName, "team-lead")
|
||||
appendInboxMessage(teamName, "team-lead", {
|
||||
id: "msg-1",
|
||||
type: "message",
|
||||
sender: "user",
|
||||
recipient: "team-lead",
|
||||
content: "Hello",
|
||||
timestamp: new Date().toISOString(),
|
||||
read: false,
|
||||
})
|
||||
|
||||
const tool = createReadInboxTool()
|
||||
|
||||
//#when
|
||||
await tool.execute({
|
||||
team_name: teamName,
|
||||
agent_name: "team-lead",
|
||||
mark_as_read: true,
|
||||
}, TEST_CONTEXT)
|
||||
|
||||
// Read again to check if marked as read
|
||||
const resultStr = await tool.execute({
|
||||
team_name: teamName,
|
||||
agent_name: "team-lead",
|
||||
unread_only: true,
|
||||
}, TEST_CONTEXT)
|
||||
const result = JSON.parse(resultStr)
|
||||
|
||||
//#then
|
||||
expect(result).toHaveLength(0) // Should be marked as read
|
||||
})
|
||||
|
||||
test("returns empty array for non-existent inbox", async () => {
|
||||
//#given
|
||||
const tool = createReadInboxTool()
|
||||
|
||||
//#when
|
||||
const resultStr = await tool.execute({
|
||||
team_name: "nonexistent",
|
||||
agent_name: "team-lead",
|
||||
}, TEST_CONTEXT)
|
||||
const result = JSON.parse(resultStr)
|
||||
|
||||
//#then
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
test("requires team_name and agent_name parameters", async () => {
|
||||
//#given
|
||||
const tool = createReadInboxTool()
|
||||
|
||||
//#when
|
||||
const resultStr = await tool.execute({}, TEST_CONTEXT)
|
||||
const result = JSON.parse(resultStr)
|
||||
|
||||
//#then
|
||||
expect(result).toHaveProperty("error")
|
||||
})
|
||||
})
|
||||
})
|
||||
29
src/tools/agent-teams/inbox-tools.ts
Normal file
29
src/tools/agent-teams/inbox-tools.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
|
||||
import { readInbox } from "./inbox-store"
|
||||
import { ReadInboxInputSchema } from "./types"
|
||||
|
||||
export function createReadInboxTool(): ToolDefinition {
|
||||
return tool({
|
||||
description: "Read inbox messages for a team member.",
|
||||
args: {
|
||||
team_name: tool.schema.string().describe("Team name"),
|
||||
agent_name: tool.schema.string().describe("Member name"),
|
||||
unread_only: tool.schema.boolean().optional().describe("Return only unread messages"),
|
||||
mark_as_read: tool.schema.boolean().optional().describe("Mark returned messages as read"),
|
||||
},
|
||||
execute: async (args: Record<string, unknown>): Promise<string> => {
|
||||
try {
|
||||
const input = ReadInboxInputSchema.parse(args)
|
||||
const messages = readInbox(
|
||||
input.team_name,
|
||||
input.agent_name,
|
||||
input.unread_only ?? false,
|
||||
input.mark_as_read ?? false,
|
||||
)
|
||||
return JSON.stringify(messages)
|
||||
} catch (error) {
|
||||
return JSON.stringify({ error: error instanceof Error ? error.message : "read_inbox_failed" })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
1
src/tools/agent-teams/index.ts
Normal file
1
src/tools/agent-teams/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { createAgentTeamsTools } from "./tools"
|
||||
467
src/tools/agent-teams/messaging-tools.test.ts
Normal file
467
src/tools/agent-teams/messaging-tools.test.ts
Normal file
@@ -0,0 +1,467 @@
|
||||
/// <reference types="bun-types" />
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { mkdtempSync, rmSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import type { BackgroundManager } from "../../features/background-agent"
|
||||
import { readInbox } from "./inbox-store"
|
||||
import { createAgentTeamsTools } from "./tools"
|
||||
import { readTeamConfig, upsertTeammate, writeTeamConfig } from "./team-config-store"
|
||||
|
||||
interface TestToolContext {
|
||||
sessionID: string
|
||||
messageID: string
|
||||
agent: string
|
||||
abort: AbortSignal
|
||||
}
|
||||
|
||||
interface ResumeCall {
|
||||
sessionId: string
|
||||
prompt: string
|
||||
}
|
||||
|
||||
function createContext(sessionID = "ses-main"): TestToolContext {
|
||||
return {
|
||||
sessionID,
|
||||
messageID: "msg-main",
|
||||
agent: "sisyphus",
|
||||
abort: new AbortController().signal,
|
||||
}
|
||||
}
|
||||
|
||||
async function executeJsonTool(
|
||||
tools: ReturnType<typeof createAgentTeamsTools>,
|
||||
toolName: keyof ReturnType<typeof createAgentTeamsTools>,
|
||||
args: Record<string, unknown>,
|
||||
context: TestToolContext,
|
||||
): Promise<unknown> {
|
||||
const output = await tools[toolName].execute(args, context)
|
||||
return JSON.parse(output)
|
||||
}
|
||||
|
||||
function uniqueTeam(): string {
|
||||
return `msg-${randomUUID().slice(0, 8)}`
|
||||
}
|
||||
|
||||
function createMockManager(): { manager: BackgroundManager; resumeCalls: ResumeCall[] } {
|
||||
const resumeCalls: ResumeCall[] = []
|
||||
let launchCount = 0
|
||||
|
||||
const manager = {
|
||||
launch: async () => {
|
||||
launchCount += 1
|
||||
return { id: `bg-${launchCount}`, sessionID: `ses-worker-${launchCount}` }
|
||||
},
|
||||
getTask: () => undefined,
|
||||
resume: async (args: ResumeCall) => {
|
||||
resumeCalls.push(args)
|
||||
return { id: `resume-${resumeCalls.length}` }
|
||||
},
|
||||
} as unknown as BackgroundManager
|
||||
|
||||
return { manager, resumeCalls }
|
||||
}
|
||||
|
||||
async function setupTeamWithWorker(
|
||||
_tools: ReturnType<typeof createAgentTeamsTools>,
|
||||
context: TestToolContext,
|
||||
teamName = "core",
|
||||
workerName = "worker_1",
|
||||
): Promise<void> {
|
||||
await executeJsonTool(_tools, "team_create", { team_name: teamName }, context)
|
||||
|
||||
const config = readTeamConfig(teamName)
|
||||
if (config) {
|
||||
const teammate = {
|
||||
agentId: `agent-${randomUUID()}`,
|
||||
name: workerName,
|
||||
agentType: "teammate" as const,
|
||||
category: "quick",
|
||||
model: "default",
|
||||
prompt: "Handle tasks",
|
||||
joinedAt: new Date().toISOString(),
|
||||
color: "#FF5733",
|
||||
cwd: process.cwd(),
|
||||
planModeRequired: false,
|
||||
subscriptions: [],
|
||||
backendType: "native" as const,
|
||||
isActive: true,
|
||||
}
|
||||
const updatedConfig = upsertTeammate(config, teammate)
|
||||
writeTeamConfig(teamName, updatedConfig)
|
||||
}
|
||||
}
|
||||
|
||||
async function addTeammateManually(teamName: string, workerName: string): Promise<void> {
|
||||
const config = readTeamConfig(teamName)
|
||||
if (config) {
|
||||
const teammate = {
|
||||
agentId: `agent-${randomUUID()}`,
|
||||
name: workerName,
|
||||
agentType: "teammate" as const,
|
||||
category: "quick",
|
||||
model: "default",
|
||||
prompt: "Handle tasks",
|
||||
joinedAt: new Date().toISOString(),
|
||||
color: "#FF5733",
|
||||
cwd: process.cwd(),
|
||||
planModeRequired: false,
|
||||
subscriptions: [],
|
||||
backendType: "native" as const,
|
||||
isActive: true,
|
||||
}
|
||||
const updatedConfig = upsertTeammate(config, teammate)
|
||||
writeTeamConfig(teamName, updatedConfig)
|
||||
}
|
||||
}
|
||||
|
||||
describe("agent-teams messaging tools", () => {
|
||||
let originalCwd: string
|
||||
let tempProjectDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
originalCwd = process.cwd()
|
||||
tempProjectDir = mkdtempSync(join(tmpdir(), "agent-teams-messaging-"))
|
||||
process.chdir(tempProjectDir)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(originalCwd)
|
||||
rmSync(tempProjectDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe("message type", () => {
|
||||
test("delivers message to recipient inbox", async () => {
|
||||
//#given
|
||||
const tn = uniqueTeam()
|
||||
const { manager } = createMockManager()
|
||||
const tools = createAgentTeamsTools(manager)
|
||||
const leadContext = createContext()
|
||||
await setupTeamWithWorker(tools, leadContext, tn)
|
||||
|
||||
//#when
|
||||
const result = await executeJsonTool(
|
||||
tools,
|
||||
"send_message",
|
||||
{
|
||||
team_name: tn,
|
||||
type: "message",
|
||||
recipient: "worker_1",
|
||||
content: "Please update status.",
|
||||
summary: "status_request",
|
||||
},
|
||||
leadContext,
|
||||
) as { success?: boolean; message?: string }
|
||||
|
||||
//#then
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.message).toBe("message_sent:worker_1")
|
||||
const inbox = readInbox(tn, "worker_1")
|
||||
const delivered = inbox.filter((m) => m.summary === "status_request")
|
||||
expect(delivered.length).toBeGreaterThanOrEqual(1)
|
||||
expect(delivered[0]?.sender).toBe("team-lead")
|
||||
expect(delivered[0]?.content).toBe("Please update status.")
|
||||
})
|
||||
|
||||
test("rejects message to nonexistent recipient", async () => {
|
||||
//#given
|
||||
const tn = uniqueTeam()
|
||||
const { manager } = createMockManager()
|
||||
const tools = createAgentTeamsTools(manager)
|
||||
const leadContext = createContext()
|
||||
await setupTeamWithWorker(tools, leadContext, tn)
|
||||
|
||||
//#when
|
||||
const result = await executeJsonTool(
|
||||
tools,
|
||||
"send_message",
|
||||
{ team_name: tn, type: "message", recipient: "nonexistent", content: "hello", summary: "test" },
|
||||
leadContext,
|
||||
) as { error?: string }
|
||||
|
||||
//#then
|
||||
expect(result.error).toBe("message_recipient_not_found")
|
||||
})
|
||||
|
||||
test("rejects recipient with team suffix mismatch", async () => {
|
||||
//#given
|
||||
const tn = uniqueTeam()
|
||||
const { manager, resumeCalls } = createMockManager()
|
||||
const tools = createAgentTeamsTools(manager)
|
||||
const leadContext = createContext()
|
||||
await setupTeamWithWorker(tools, leadContext, tn)
|
||||
|
||||
//#when
|
||||
const result = await executeJsonTool(
|
||||
tools,
|
||||
"send_message",
|
||||
{ team_name: tn, type: "message", recipient: "worker_1@other-team", summary: "sync", content: "hi" },
|
||||
leadContext,
|
||||
) as { error?: string }
|
||||
|
||||
//#then
|
||||
expect(result.error).toBe("recipient_team_mismatch")
|
||||
expect(resumeCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("rejects recipient with empty team suffix", async () => {
|
||||
//#given
|
||||
const tn = uniqueTeam()
|
||||
const { manager, resumeCalls } = createMockManager()
|
||||
const tools = createAgentTeamsTools(manager)
|
||||
const leadContext = createContext()
|
||||
await setupTeamWithWorker(tools, leadContext, tn)
|
||||
|
||||
//#when
|
||||
const result = await executeJsonTool(
|
||||
tools,
|
||||
"send_message",
|
||||
{ team_name: tn, type: "message", recipient: "worker_1@", summary: "sync", content: "hi" },
|
||||
leadContext,
|
||||
) as { error?: string }
|
||||
|
||||
//#then
|
||||
expect(result.error).toBe("recipient_team_invalid")
|
||||
expect(resumeCalls).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("broadcast type", () => {
|
||||
test("writes to all teammate inboxes", async () => {
|
||||
//#given
|
||||
const tn = uniqueTeam()
|
||||
const { manager } = createMockManager()
|
||||
const tools = createAgentTeamsTools(manager)
|
||||
const leadContext = createContext()
|
||||
await executeJsonTool(tools, "team_create", { team_name: tn }, leadContext)
|
||||
for (const name of ["worker_1", "worker_2"]) {
|
||||
await addTeammateManually(tn, name)
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = await executeJsonTool(
|
||||
tools,
|
||||
"send_message",
|
||||
{ team_name: tn, type: "broadcast", summary: "sync", content: "Status update needed" },
|
||||
leadContext,
|
||||
) as { success?: boolean; message?: string }
|
||||
|
||||
//#then
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.message).toBe("broadcast_sent:2")
|
||||
for (const name of ["worker_1", "worker_2"]) {
|
||||
const inbox = readInbox(tn, name)
|
||||
const broadcastMessages = inbox.filter((m) => m.summary === "sync")
|
||||
expect(broadcastMessages.length).toBeGreaterThanOrEqual(1)
|
||||
}
|
||||
})
|
||||
|
||||
test("rejects broadcast without summary", async () => {
|
||||
//#given
|
||||
const tn = uniqueTeam()
|
||||
const { manager } = createMockManager()
|
||||
const tools = createAgentTeamsTools(manager)
|
||||
const leadContext = createContext()
|
||||
await setupTeamWithWorker(tools, leadContext, tn)
|
||||
|
||||
//#when
|
||||
const result = await executeJsonTool(
|
||||
tools,
|
||||
"send_message",
|
||||
{ team_name: tn, type: "broadcast", content: "hello" },
|
||||
leadContext,
|
||||
) as { error?: string }
|
||||
|
||||
//#then
|
||||
expect(result.error).toBe("broadcast_requires_summary")
|
||||
})
|
||||
})
|
||||
|
||||
describe("shutdown_request type", () => {
|
||||
test("sends shutdown request and returns request_id", async () => {
|
||||
//#given
|
||||
const tn = uniqueTeam()
|
||||
const { manager } = createMockManager()
|
||||
const tools = createAgentTeamsTools(manager)
|
||||
const leadContext = createContext()
|
||||
await setupTeamWithWorker(tools, leadContext, tn)
|
||||
|
||||
//#when
|
||||
const result = await executeJsonTool(
|
||||
tools,
|
||||
"send_message",
|
||||
{ team_name: tn, type: "shutdown_request", recipient: "worker_1", content: "Work completed" },
|
||||
leadContext,
|
||||
) as { success?: boolean; request_id?: string; target?: string }
|
||||
|
||||
//#then
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.request_id).toMatch(/^shutdown-worker_1-/)
|
||||
expect(result.target).toBe("worker_1")
|
||||
})
|
||||
|
||||
test("rejects shutdown targeting team-lead", async () => {
|
||||
//#given
|
||||
const tn = uniqueTeam()
|
||||
const { manager } = createMockManager()
|
||||
const tools = createAgentTeamsTools(manager)
|
||||
const leadContext = createContext()
|
||||
await setupTeamWithWorker(tools, leadContext, tn)
|
||||
|
||||
//#when
|
||||
const result = await executeJsonTool(
|
||||
tools,
|
||||
"send_message",
|
||||
{ team_name: tn, type: "shutdown_request", recipient: "team-lead" },
|
||||
leadContext,
|
||||
) as { error?: string }
|
||||
|
||||
//#then
|
||||
expect(result.error).toBe("cannot_shutdown_team_lead")
|
||||
})
|
||||
})
|
||||
|
||||
describe("shutdown_response type", () => {
|
||||
test("sends approved shutdown response to team-lead inbox", async () => {
|
||||
//#given
|
||||
const tn = uniqueTeam()
|
||||
const { manager } = createMockManager()
|
||||
const tools = createAgentTeamsTools(manager)
|
||||
const leadContext = createContext()
|
||||
await setupTeamWithWorker(tools, leadContext, tn)
|
||||
|
||||
//#when
|
||||
const result = await executeJsonTool(
|
||||
tools,
|
||||
"send_message",
|
||||
{ team_name: tn, type: "shutdown_response", request_id: "shutdown-worker_1-abc12345", approve: true },
|
||||
leadContext,
|
||||
) as { success?: boolean; message?: string }
|
||||
|
||||
//#then
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.message).toBe("shutdown_approved:shutdown-worker_1-abc12345")
|
||||
const leadInbox = readInbox(tn, "team-lead")
|
||||
const shutdownMessages = leadInbox.filter((m) => m.summary === "shutdown_approved")
|
||||
expect(shutdownMessages.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
test("sends rejected shutdown response", async () => {
|
||||
//#given
|
||||
const tn = uniqueTeam()
|
||||
const { manager } = createMockManager()
|
||||
const tools = createAgentTeamsTools(manager)
|
||||
const leadContext = createContext()
|
||||
await setupTeamWithWorker(tools, leadContext, tn)
|
||||
|
||||
//#when
|
||||
const result = await executeJsonTool(
|
||||
tools,
|
||||
"send_message",
|
||||
{
|
||||
team_name: tn,
|
||||
type: "shutdown_response",
|
||||
request_id: "shutdown-worker_1-abc12345",
|
||||
approve: false,
|
||||
content: "Still working on it",
|
||||
},
|
||||
leadContext,
|
||||
) as { success?: boolean; message?: string }
|
||||
|
||||
//#then
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.message).toBe("shutdown_rejected:shutdown-worker_1-abc12345")
|
||||
})
|
||||
})
|
||||
|
||||
describe("plan_approval_response type", () => {
|
||||
test("sends approved plan response", async () => {
|
||||
//#given
|
||||
const tn = uniqueTeam()
|
||||
const { manager } = createMockManager()
|
||||
const tools = createAgentTeamsTools(manager)
|
||||
const leadContext = createContext()
|
||||
await setupTeamWithWorker(tools, leadContext, tn)
|
||||
|
||||
//#when
|
||||
const result = await executeJsonTool(
|
||||
tools,
|
||||
"send_message",
|
||||
{ team_name: tn, type: "plan_approval_response", request_id: "plan-req-001", approve: true, recipient: "worker_1" },
|
||||
leadContext,
|
||||
) as { success?: boolean; message?: string }
|
||||
|
||||
//#then
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.message).toBe("plan_approved:worker_1")
|
||||
})
|
||||
|
||||
test("sends rejected plan response with content", async () => {
|
||||
//#given
|
||||
const tn = uniqueTeam()
|
||||
const { manager } = createMockManager()
|
||||
const tools = createAgentTeamsTools(manager)
|
||||
const leadContext = createContext()
|
||||
await setupTeamWithWorker(tools, leadContext, tn)
|
||||
|
||||
//#when
|
||||
const result = await executeJsonTool(
|
||||
tools,
|
||||
"send_message",
|
||||
{
|
||||
team_name: tn,
|
||||
type: "plan_approval_response",
|
||||
request_id: "plan-req-002",
|
||||
approve: false,
|
||||
recipient: "worker_1",
|
||||
content: "Need more details",
|
||||
},
|
||||
leadContext,
|
||||
) as { success?: boolean; message?: string }
|
||||
|
||||
//#then
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.message).toBe("plan_rejected:worker_1")
|
||||
})
|
||||
})
|
||||
|
||||
describe("authorization", () => {
|
||||
test("rejects message from unauthorized session", async () => {
|
||||
//#given
|
||||
const tn = uniqueTeam()
|
||||
const { manager } = createMockManager()
|
||||
const tools = createAgentTeamsTools(manager)
|
||||
const leadContext = createContext()
|
||||
await setupTeamWithWorker(tools, leadContext, tn)
|
||||
|
||||
//#when
|
||||
const result = await executeJsonTool(
|
||||
tools,
|
||||
"send_message",
|
||||
{ team_name: tn, type: "message", recipient: "worker_1", content: "hello", summary: "test" },
|
||||
createContext("ses-intruder"),
|
||||
) as { error?: string }
|
||||
|
||||
//#then
|
||||
expect(result.error).toBe("unauthorized_sender_session")
|
||||
})
|
||||
|
||||
test("rejects message to nonexistent team", async () => {
|
||||
//#given
|
||||
const { manager } = createMockManager()
|
||||
const tools = createAgentTeamsTools(manager)
|
||||
|
||||
//#when
|
||||
const result = await executeJsonTool(
|
||||
tools,
|
||||
"send_message",
|
||||
{ team_name: "nonexistent-xyz", type: "message", recipient: "w", content: "hello", summary: "test" },
|
||||
createContext(),
|
||||
) as { error?: string }
|
||||
|
||||
//#then
|
||||
expect(result.error).toBe("team_not_found")
|
||||
})
|
||||
})
|
||||
})
|
||||
282
src/tools/agent-teams/messaging-tools.ts
Normal file
282
src/tools/agent-teams/messaging-tools.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
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 { validateAgentNameOrLead, validateTeamName } from "./name-validation"
|
||||
import { resumeTeammateWithMessage } from "./teammate-runtime"
|
||||
import {
|
||||
TeamConfig,
|
||||
TeamReadInboxInputSchema,
|
||||
TeamSendMessageInputSchema,
|
||||
TeamToolContext,
|
||||
isTeammateMember,
|
||||
} from "./types"
|
||||
|
||||
function nowIso(): string {
|
||||
return new Date().toISOString()
|
||||
}
|
||||
|
||||
function validateRecipientTeam(recipient: unknown, teamName: string): string | null {
|
||||
if (typeof recipient !== "string") {
|
||||
return null
|
||||
}
|
||||
|
||||
const trimmed = recipient.trim()
|
||||
const atIndex = trimmed.indexOf("@")
|
||||
if (atIndex <= 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const specifiedTeam = trimmed.slice(atIndex + 1).trim()
|
||||
if (!specifiedTeam) {
|
||||
return "recipient_team_invalid"
|
||||
}
|
||||
if (specifiedTeam === teamName) {
|
||||
return null
|
||||
}
|
||||
|
||||
return "recipient_team_mismatch"
|
||||
}
|
||||
|
||||
function resolveSenderFromContext(config: TeamConfig, context: TeamToolContext): string | null {
|
||||
if (context.sessionID === config.leadSessionId) {
|
||||
return "team-lead"
|
||||
}
|
||||
|
||||
const matchedMember = config.members.find((member) => isTeammateMember(member) && member.sessionID === context.sessionID)
|
||||
return matchedMember?.name ?? null
|
||||
}
|
||||
|
||||
export function createSendMessageTool(manager: BackgroundManager): ToolDefinition {
|
||||
return tool({
|
||||
description: "Send direct or broadcast team messages and protocol responses.",
|
||||
args: {
|
||||
team_name: tool.schema.string().describe("Team name"),
|
||||
type: tool.schema.enum(["message", "broadcast", "shutdown_request", "shutdown_response", "plan_approval_response"]),
|
||||
recipient: tool.schema.string().optional().describe("Message recipient"),
|
||||
content: tool.schema.string().optional().describe("Message body"),
|
||||
summary: tool.schema.string().optional().describe("Short summary"),
|
||||
request_id: tool.schema.string().optional().describe("Protocol request id"),
|
||||
approve: tool.schema.boolean().optional().describe("Approval flag"),
|
||||
sender: tool.schema
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Sender name inferred from calling session; explicit value must match resolved sender."),
|
||||
},
|
||||
execute: async (args: Record<string, unknown>, context: TeamToolContext): Promise<string> => {
|
||||
try {
|
||||
const input = TeamSendMessageInputSchema.parse(args)
|
||||
const teamError = validateTeamName(input.team_name)
|
||||
if (teamError) {
|
||||
return JSON.stringify({ error: teamError })
|
||||
}
|
||||
const recipientTeamError = validateRecipientTeam(args.recipient, input.team_name)
|
||||
if (recipientTeamError) {
|
||||
return JSON.stringify({ error: recipientTeamError })
|
||||
}
|
||||
const requestedSender = input.sender
|
||||
const senderError = requestedSender ? validateAgentNameOrLead(requestedSender) : null
|
||||
if (senderError) {
|
||||
return JSON.stringify({ error: senderError })
|
||||
}
|
||||
const config = readTeamConfigOrThrow(input.team_name)
|
||||
const actor = resolveSenderFromContext(config, context)
|
||||
if (!actor) {
|
||||
return JSON.stringify({ error: "unauthorized_sender_session" })
|
||||
}
|
||||
if (requestedSender && requestedSender !== actor) {
|
||||
return JSON.stringify({ error: "sender_context_mismatch" })
|
||||
}
|
||||
const sender = requestedSender ?? actor
|
||||
|
||||
const memberNames = new Set(config.members.map((member) => member.name))
|
||||
if (sender !== "team-lead" && !memberNames.has(sender)) {
|
||||
return JSON.stringify({ error: "invalid_sender" })
|
||||
}
|
||||
|
||||
if (input.type === "message") {
|
||||
if (!input.recipient || !input.summary || !input.content) {
|
||||
return JSON.stringify({ error: "message_requires_recipient_summary_content" })
|
||||
}
|
||||
if (!memberNames.has(input.recipient)) {
|
||||
return JSON.stringify({ error: "message_recipient_not_found" })
|
||||
}
|
||||
|
||||
const targetMember = getTeamMember(config, input.recipient)
|
||||
const color = targetMember && isTeammateMember(targetMember) ? targetMember.color : undefined
|
||||
sendPlainInboxMessage(input.team_name, sender, input.recipient, input.content, input.summary, color)
|
||||
|
||||
if (targetMember && isTeammateMember(targetMember)) {
|
||||
await resumeTeammateWithMessage(manager, context, input.team_name, targetMember, input.summary, input.content)
|
||||
}
|
||||
|
||||
return JSON.stringify({ success: true, message: `message_sent:${input.recipient}` })
|
||||
}
|
||||
|
||||
if (input.type === "broadcast") {
|
||||
if (!input.summary) {
|
||||
return JSON.stringify({ error: "broadcast_requires_summary" })
|
||||
}
|
||||
const broadcastSummary = input.summary
|
||||
const teammates = listTeammates(config)
|
||||
for (const teammate of teammates) {
|
||||
sendPlainInboxMessage(input.team_name, sender, teammate.name, input.content ?? "", broadcastSummary)
|
||||
}
|
||||
await Promise.allSettled(
|
||||
teammates.map((teammate) =>
|
||||
resumeTeammateWithMessage(manager, context, input.team_name, teammate, broadcastSummary, input.content ?? ""),
|
||||
),
|
||||
)
|
||||
return JSON.stringify({ success: true, message: `broadcast_sent:${teammates.length}` })
|
||||
}
|
||||
|
||||
if (input.type === "shutdown_request") {
|
||||
if (!input.recipient) {
|
||||
return JSON.stringify({ error: "shutdown_request_requires_recipient" })
|
||||
}
|
||||
if (input.recipient === "team-lead") {
|
||||
return JSON.stringify({ error: "cannot_shutdown_team_lead" })
|
||||
}
|
||||
const targetMember = getTeamMember(config, input.recipient)
|
||||
if (!targetMember || !isTeammateMember(targetMember)) {
|
||||
return JSON.stringify({ error: "shutdown_recipient_not_found" })
|
||||
}
|
||||
|
||||
const requestId = buildShutdownRequestId(input.recipient)
|
||||
sendStructuredInboxMessage(
|
||||
input.team_name,
|
||||
sender,
|
||||
input.recipient,
|
||||
{
|
||||
type: "shutdown_request",
|
||||
requestId,
|
||||
from: sender,
|
||||
reason: input.content ?? "",
|
||||
timestamp: nowIso(),
|
||||
},
|
||||
"shutdown_request",
|
||||
)
|
||||
|
||||
await resumeTeammateWithMessage(
|
||||
manager,
|
||||
context,
|
||||
input.team_name,
|
||||
targetMember,
|
||||
"shutdown_request",
|
||||
input.content ?? "Shutdown requested",
|
||||
)
|
||||
|
||||
return JSON.stringify({ success: true, request_id: requestId, target: input.recipient })
|
||||
}
|
||||
|
||||
if (input.type === "shutdown_response") {
|
||||
if (!input.request_id) {
|
||||
return JSON.stringify({ error: "shutdown_response_requires_request_id" })
|
||||
}
|
||||
if (input.approve) {
|
||||
sendStructuredInboxMessage(
|
||||
input.team_name,
|
||||
sender,
|
||||
"team-lead",
|
||||
{
|
||||
type: "shutdown_approved",
|
||||
requestId: input.request_id,
|
||||
from: sender,
|
||||
timestamp: nowIso(),
|
||||
backendType: "native",
|
||||
},
|
||||
"shutdown_approved",
|
||||
)
|
||||
return JSON.stringify({ success: true, message: `shutdown_approved:${input.request_id}` })
|
||||
}
|
||||
|
||||
sendPlainInboxMessage(
|
||||
input.team_name,
|
||||
sender,
|
||||
"team-lead",
|
||||
input.content ?? "Shutdown rejected",
|
||||
"shutdown_rejected",
|
||||
)
|
||||
return JSON.stringify({ success: true, message: `shutdown_rejected:${input.request_id}` })
|
||||
}
|
||||
|
||||
if (!input.recipient) {
|
||||
return JSON.stringify({ error: "plan_response_requires_recipient" })
|
||||
}
|
||||
if (!memberNames.has(input.recipient)) {
|
||||
return JSON.stringify({ error: "plan_response_recipient_not_found" })
|
||||
}
|
||||
|
||||
if (input.approve) {
|
||||
sendStructuredInboxMessage(
|
||||
input.team_name,
|
||||
sender,
|
||||
input.recipient,
|
||||
{
|
||||
type: "plan_approval",
|
||||
approved: true,
|
||||
requestId: input.request_id,
|
||||
},
|
||||
"plan_approved",
|
||||
)
|
||||
return JSON.stringify({ success: true, message: `plan_approved:${input.recipient}` })
|
||||
}
|
||||
|
||||
sendPlainInboxMessage(
|
||||
input.team_name,
|
||||
sender,
|
||||
input.recipient,
|
||||
input.content ?? "Plan rejected",
|
||||
"plan_rejected",
|
||||
)
|
||||
return JSON.stringify({ success: true, message: `plan_rejected:${input.recipient}` })
|
||||
} catch (error) {
|
||||
return JSON.stringify({ error: error instanceof Error ? error.message : "send_message_failed" })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function createReadInboxTool(): ToolDefinition {
|
||||
return tool({
|
||||
description: "Read inbox messages for a team member.",
|
||||
args: {
|
||||
team_name: tool.schema.string().describe("Team name"),
|
||||
agent_name: tool.schema.string().describe("Member name"),
|
||||
unread_only: tool.schema.boolean().optional().describe("Return only unread messages"),
|
||||
mark_as_read: tool.schema.boolean().optional().describe("Mark returned messages as read"),
|
||||
},
|
||||
execute: async (args: Record<string, unknown>, context: TeamToolContext): Promise<string> => {
|
||||
try {
|
||||
const input = TeamReadInboxInputSchema.parse(args)
|
||||
const teamError = validateTeamName(input.team_name)
|
||||
if (teamError) {
|
||||
return JSON.stringify({ error: teamError })
|
||||
}
|
||||
const agentError = validateAgentNameOrLead(input.agent_name)
|
||||
if (agentError) {
|
||||
return JSON.stringify({ error: agentError })
|
||||
}
|
||||
const config = readTeamConfigOrThrow(input.team_name)
|
||||
const actor = resolveSenderFromContext(config, context)
|
||||
if (!actor) {
|
||||
return JSON.stringify({ error: "unauthorized_reader_session" })
|
||||
}
|
||||
|
||||
if (actor !== "team-lead" && actor !== input.agent_name) {
|
||||
return JSON.stringify({ error: "unauthorized_reader_session" })
|
||||
}
|
||||
|
||||
const messages = readInbox(
|
||||
input.team_name,
|
||||
input.agent_name,
|
||||
input.unread_only ?? false,
|
||||
input.mark_as_read ?? true,
|
||||
)
|
||||
return JSON.stringify(messages)
|
||||
} catch (error) {
|
||||
return JSON.stringify({ error: error instanceof Error ? error.message : "read_inbox_failed" })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
79
src/tools/agent-teams/name-validation.test.ts
Normal file
79
src/tools/agent-teams/name-validation.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/// <reference types="bun-types" />
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
validateAgentName,
|
||||
validateAgentNameOrLead,
|
||||
validateTaskId,
|
||||
validateTeamName,
|
||||
} from "./name-validation"
|
||||
|
||||
describe("agent-teams name validation", () => {
|
||||
test("accepts valid team names", () => {
|
||||
//#given
|
||||
const validNames = ["team_1", "alpha-team", "A1"]
|
||||
|
||||
//#when
|
||||
const result = validNames.map(validateTeamName)
|
||||
|
||||
//#then
|
||||
expect(result).toEqual([null, null, null])
|
||||
})
|
||||
|
||||
test("rejects invalid and empty team names", () => {
|
||||
//#given
|
||||
const blank = ""
|
||||
const invalid = "team space"
|
||||
const tooLong = "a".repeat(65)
|
||||
|
||||
//#when
|
||||
const blankResult = validateTeamName(blank)
|
||||
const invalidResult = validateTeamName(invalid)
|
||||
const tooLongResult = validateTeamName(tooLong)
|
||||
|
||||
//#then
|
||||
expect(blankResult).toBe("team_name_required")
|
||||
expect(invalidResult).toBe("team_name_invalid")
|
||||
expect(tooLongResult).toBe("team_name_too_long")
|
||||
})
|
||||
|
||||
test("rejects reserved teammate name", () => {
|
||||
//#given
|
||||
const reservedName = "team-lead"
|
||||
|
||||
//#when
|
||||
const result = validateAgentName(reservedName)
|
||||
|
||||
//#then
|
||||
expect(result).toBe("agent_name_reserved")
|
||||
})
|
||||
|
||||
test("validates regular agent names", () => {
|
||||
//#given
|
||||
const valid = "worker_1"
|
||||
const invalid = "worker one"
|
||||
|
||||
//#when
|
||||
const validResult = validateAgentName(valid)
|
||||
const invalidResult = validateAgentName(invalid)
|
||||
|
||||
//#then
|
||||
expect(validResult).toBeNull()
|
||||
expect(invalidResult).toBe("agent_name_invalid")
|
||||
})
|
||||
|
||||
test("allows team-lead for inbox-compatible validation", () => {
|
||||
//#then
|
||||
expect(validateAgentNameOrLead("team-lead")).toBeNull()
|
||||
expect(validateAgentNameOrLead("worker_1")).toBeNull()
|
||||
expect(validateAgentNameOrLead("worker one")).toBe("agent_name_invalid")
|
||||
})
|
||||
|
||||
test("validates task ids", () => {
|
||||
//#then
|
||||
expect(validateTaskId("T-123")).toBeNull()
|
||||
expect(validateTaskId("123")).toBe("task_id_invalid")
|
||||
expect(validateTaskId("")).toBe("task_id_required")
|
||||
expect(validateTaskId("../../etc/passwd")).toBe("task_id_invalid")
|
||||
expect(validateTaskId(`T-${"a".repeat(127)}`)).toBe("task_id_too_long")
|
||||
})
|
||||
})
|
||||
54
src/tools/agent-teams/name-validation.ts
Normal file
54
src/tools/agent-teams/name-validation.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
const VALID_NAME_RE = /^[A-Za-z0-9_-]+$/
|
||||
const MAX_NAME_LENGTH = 64
|
||||
const VALID_TASK_ID_RE = /^T-[A-Za-z0-9_-]+$/
|
||||
const MAX_TASK_ID_LENGTH = 128
|
||||
|
||||
function validateName(value: string, label: "team" | "agent"): string | null {
|
||||
if (!value || !value.trim()) {
|
||||
return `${label}_name_required`
|
||||
}
|
||||
|
||||
if (!VALID_NAME_RE.test(value)) {
|
||||
return `${label}_name_invalid`
|
||||
}
|
||||
|
||||
if (value.length > MAX_NAME_LENGTH) {
|
||||
return `${label}_name_too_long`
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function validateTeamName(teamName: string): string | null {
|
||||
return validateName(teamName, "team")
|
||||
}
|
||||
|
||||
export function validateAgentName(agentName: string): string | null {
|
||||
if (agentName === "team-lead") {
|
||||
return "agent_name_reserved"
|
||||
}
|
||||
return validateName(agentName, "agent")
|
||||
}
|
||||
|
||||
export function validateAgentNameOrLead(agentName: string): string | null {
|
||||
if (agentName === "team-lead") {
|
||||
return null
|
||||
}
|
||||
return validateName(agentName, "agent")
|
||||
}
|
||||
|
||||
export function validateTaskId(taskId: string): string | null {
|
||||
if (!taskId || !taskId.trim()) {
|
||||
return "task_id_required"
|
||||
}
|
||||
|
||||
if (!VALID_TASK_ID_RE.test(taskId)) {
|
||||
return "task_id_invalid"
|
||||
}
|
||||
|
||||
if (taskId.length > MAX_TASK_ID_LENGTH) {
|
||||
return "task_id_too_long"
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
81
src/tools/agent-teams/paths.test.ts
Normal file
81
src/tools/agent-teams/paths.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/// <reference types="bun-types" />
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import {
|
||||
getAgentTeamsRootDir,
|
||||
getTeamConfigPath,
|
||||
getTeamDir,
|
||||
getTeamInboxDir,
|
||||
getTeamInboxPath,
|
||||
getTeamTaskDir,
|
||||
getTeamTaskPath,
|
||||
getTeamsRootDir,
|
||||
getTeamTasksRootDir,
|
||||
} from "./paths"
|
||||
|
||||
describe("agent-teams paths", () => {
|
||||
test("uses user-global .sisyphus directory as storage root", () => {
|
||||
//#given
|
||||
const expectedRoot = join(homedir(), ".sisyphus", "agent-teams")
|
||||
|
||||
//#when
|
||||
const root = getAgentTeamsRootDir()
|
||||
|
||||
//#then
|
||||
expect(root).toBe(expectedRoot)
|
||||
})
|
||||
|
||||
test("builds expected teams and tasks root directories", () => {
|
||||
//#given
|
||||
const expectedRoot = join(homedir(), ".sisyphus", "agent-teams")
|
||||
|
||||
//#when
|
||||
const teamsRoot = getTeamsRootDir()
|
||||
const tasksRoot = getTeamTasksRootDir()
|
||||
|
||||
//#then
|
||||
expect(teamsRoot).toBe(join(expectedRoot, "teams"))
|
||||
expect(tasksRoot).toBe(join(expectedRoot, "tasks"))
|
||||
})
|
||||
|
||||
test("builds team-scoped config, inbox, and task file paths", () => {
|
||||
//#given
|
||||
const teamName = "alpha_team"
|
||||
const agentName = "worker_1"
|
||||
const taskId = "T-123"
|
||||
const expectedTeamDir = join(getTeamsRootDir(), "alpha_team")
|
||||
|
||||
//#when
|
||||
const teamDir = getTeamDir(teamName)
|
||||
const configPath = getTeamConfigPath(teamName)
|
||||
const inboxDir = getTeamInboxDir(teamName)
|
||||
const inboxPath = getTeamInboxPath(teamName, agentName)
|
||||
const taskDir = getTeamTaskDir(teamName)
|
||||
const taskPath = getTeamTaskPath(teamName, taskId)
|
||||
|
||||
//#then
|
||||
expect(teamDir).toBe(expectedTeamDir)
|
||||
expect(configPath).toBe(join(expectedTeamDir, "config.json"))
|
||||
expect(inboxDir).toBe(join(expectedTeamDir, "inboxes"))
|
||||
expect(inboxPath).toBe(join(expectedTeamDir, "inboxes", `${agentName}.json`))
|
||||
expect(taskDir).toBe(join(getTeamTasksRootDir(), "alpha_team"))
|
||||
expect(taskPath).toBe(join(getTeamTasksRootDir(), "alpha_team", `${taskId}.json`))
|
||||
})
|
||||
|
||||
test("sanitizes team names with invalid characters", () => {
|
||||
//#given
|
||||
const invalidTeamName = "team space/with@special#chars"
|
||||
const expectedSanitized = "team-space-with-special-chars"
|
||||
|
||||
//#when
|
||||
const teamDir = getTeamDir(invalidTeamName)
|
||||
const configPath = getTeamConfigPath(invalidTeamName)
|
||||
const taskDir = getTeamTaskDir(invalidTeamName)
|
||||
|
||||
//#then
|
||||
expect(teamDir).toBe(join(getTeamsRootDir(), expectedSanitized))
|
||||
expect(configPath).toBe(join(getTeamsRootDir(), expectedSanitized, "config.json"))
|
||||
expect(taskDir).toBe(join(getTeamTasksRootDir(), expectedSanitized))
|
||||
})
|
||||
})
|
||||
42
src/tools/agent-teams/paths.ts
Normal file
42
src/tools/agent-teams/paths.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { join } from "node:path"
|
||||
import { homedir } from "node:os"
|
||||
import { sanitizePathSegment } from "../../features/claude-tasks/storage"
|
||||
|
||||
const SISYPHUS_DIR = ".sisyphus"
|
||||
const AGENT_TEAMS_DIR = "agent-teams"
|
||||
|
||||
export function getAgentTeamsRootDir(): string {
|
||||
return join(homedir(), SISYPHUS_DIR, AGENT_TEAMS_DIR)
|
||||
}
|
||||
|
||||
export function getTeamsRootDir(): string {
|
||||
return join(getAgentTeamsRootDir(), "teams")
|
||||
}
|
||||
|
||||
export function getTeamTasksRootDir(): string {
|
||||
return join(getAgentTeamsRootDir(), "tasks")
|
||||
}
|
||||
|
||||
export function getTeamDir(teamName: string): string {
|
||||
return join(getTeamsRootDir(), sanitizePathSegment(teamName))
|
||||
}
|
||||
|
||||
export function getTeamConfigPath(teamName: string): string {
|
||||
return join(getTeamDir(teamName), "config.json")
|
||||
}
|
||||
|
||||
export function getTeamInboxDir(teamName: string): string {
|
||||
return join(getTeamDir(teamName), "inboxes")
|
||||
}
|
||||
|
||||
export function getTeamInboxPath(teamName: string, agentName: string): string {
|
||||
return join(getTeamInboxDir(teamName), `${agentName}.json`)
|
||||
}
|
||||
|
||||
export function getTeamTaskDir(teamName: string): string {
|
||||
return join(getTeamTasksRootDir(), sanitizePathSegment(teamName))
|
||||
}
|
||||
|
||||
export function getTeamTaskPath(teamName: string, taskId: string): string {
|
||||
return join(getTeamTaskDir(teamName), `${taskId}.json`)
|
||||
}
|
||||
214
src/tools/agent-teams/team-config-store.test.ts
Normal file
214
src/tools/agent-teams/team-config-store.test.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
/// <reference types="bun-types" />
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"
|
||||
import { chmodSync, existsSync, mkdtempSync, rmSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { acquireLock } from "../../features/claude-tasks/storage"
|
||||
import { getTeamDir, getTeamTaskDir, getTeamsRootDir } from "./paths"
|
||||
import {
|
||||
createTeamConfig,
|
||||
deleteTeamData,
|
||||
deleteTeamDir,
|
||||
listTeams,
|
||||
readTeamConfigOrThrow,
|
||||
teamExists,
|
||||
upsertTeammate,
|
||||
writeTeamConfig,
|
||||
} from "./team-config-store"
|
||||
|
||||
describe("agent-teams team config store", () => {
|
||||
let originalCwd: string
|
||||
let tempProjectDir: string
|
||||
let createdTeams: string[]
|
||||
let teamPrefix: string
|
||||
|
||||
beforeAll(() => {
|
||||
const allTeams = listTeams()
|
||||
for (const team of allTeams) {
|
||||
if (team.startsWith("core-") || team.startsWith("team-alpha-") || team.startsWith("team-beta-") || team.startsWith("delete-dir-test-")) {
|
||||
try {
|
||||
deleteTeamData(team)
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
originalCwd = process.cwd()
|
||||
tempProjectDir = mkdtempSync(join(tmpdir(), "agent-teams-config-store-"))
|
||||
process.chdir(tempProjectDir)
|
||||
createdTeams = []
|
||||
teamPrefix = randomUUID().slice(0, 8)
|
||||
createTeamConfig(`core-${teamPrefix}`, "Core team", `ses-main-${teamPrefix}`, tempProjectDir, "sisyphus")
|
||||
createdTeams.push(`core-${teamPrefix}`)
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
for (const teamName of createdTeams) {
|
||||
if (teamExists(teamName)) {
|
||||
try {
|
||||
deleteTeamData(teamName)
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
}
|
||||
process.chdir(originalCwd)
|
||||
try {
|
||||
rmSync(tempProjectDir, { recursive: true, force: true })
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
})
|
||||
|
||||
test("deleteTeamData waits for team lock before removing team files", () => {
|
||||
//#given
|
||||
const teamName = createdTeams[0]
|
||||
const lock = acquireLock(getTeamDir(teamName))
|
||||
expect(lock.acquired).toBe(true)
|
||||
|
||||
try {
|
||||
//#when
|
||||
const deleteWhileLocked = () => deleteTeamData(teamName)
|
||||
|
||||
//#then
|
||||
expect(deleteWhileLocked).toThrow("team_lock_unavailable")
|
||||
expect(teamExists(teamName)).toBe(true)
|
||||
} finally {
|
||||
//#when
|
||||
lock.release()
|
||||
}
|
||||
|
||||
deleteTeamData(teamName)
|
||||
|
||||
//#then
|
||||
expect(teamExists(teamName)).toBe(false)
|
||||
})
|
||||
|
||||
test("deleteTeamData waits for task lock before removing task files", () => {
|
||||
//#given
|
||||
const teamName = createdTeams[0]
|
||||
const lock = acquireLock(getTeamTaskDir(teamName))
|
||||
expect(lock.acquired).toBe(true)
|
||||
|
||||
try {
|
||||
//#when
|
||||
const deleteWhileLocked = () => deleteTeamData(teamName)
|
||||
|
||||
//#then
|
||||
expect(deleteWhileLocked).toThrow("team_task_lock_unavailable")
|
||||
expect(teamExists(teamName)).toBe(true)
|
||||
} finally {
|
||||
lock.release()
|
||||
}
|
||||
|
||||
//#when
|
||||
deleteTeamData(teamName)
|
||||
|
||||
//#then
|
||||
expect(teamExists(teamName)).toBe(false)
|
||||
})
|
||||
|
||||
test("deleteTeamData removes task files before deleting team directory", () => {
|
||||
//#given
|
||||
const teamName = createdTeams[0]
|
||||
const taskDir = getTeamTaskDir(teamName)
|
||||
const teamDir = getTeamDir(teamName)
|
||||
const teamsRootDir = getTeamsRootDir()
|
||||
expect(existsSync(taskDir)).toBe(true)
|
||||
expect(existsSync(teamDir)).toBe(true)
|
||||
|
||||
//#when
|
||||
chmodSync(teamsRootDir, 0o555)
|
||||
try {
|
||||
const deleteWithBlockedTeamParent = () => deleteTeamData(teamName)
|
||||
expect(deleteWithBlockedTeamParent).toThrow()
|
||||
} finally {
|
||||
chmodSync(teamsRootDir, 0o755)
|
||||
}
|
||||
|
||||
//#then
|
||||
expect(existsSync(taskDir)).toBe(false)
|
||||
expect(existsSync(teamDir)).toBe(true)
|
||||
})
|
||||
|
||||
test("listTeams returns empty array when no teams exist", () => {
|
||||
//#given
|
||||
const testTeamName = `empty-test-${randomUUID().slice(0, 8)}`
|
||||
const allTeamsBefore = listTeams().filter(t => !t.startsWith("core-") && !t.startsWith("team-alpha-") && !t.startsWith("team-beta-") && !t.startsWith("delete-dir-test-"))
|
||||
const uniqueTestTeam = allTeamsBefore.find(t => t !== testTeamName)
|
||||
|
||||
//#when
|
||||
const teams = listTeams()
|
||||
|
||||
//#then
|
||||
expect(teams.length).toBeGreaterThanOrEqual(allTeamsBefore.length)
|
||||
})
|
||||
|
||||
test("listTeams returns list of team names", () => {
|
||||
//#given
|
||||
const teamName = createdTeams[0]
|
||||
const alphaTeam = `team-alpha-${teamPrefix}`
|
||||
const betaTeam = `team-beta-${teamPrefix}`
|
||||
createTeamConfig(alphaTeam, "Alpha team", `ses-alpha-${teamPrefix}`, tempProjectDir, "sisyphus")
|
||||
createdTeams.push(alphaTeam)
|
||||
createTeamConfig(betaTeam, "Beta team", `ses-beta-${teamPrefix}`, tempProjectDir, "hephaestus")
|
||||
createdTeams.push(betaTeam)
|
||||
|
||||
//#when
|
||||
const teams = listTeams()
|
||||
|
||||
//#then
|
||||
expect(teams).toContain(teamName)
|
||||
expect(teams).toContain(alphaTeam)
|
||||
expect(teams).toContain(betaTeam)
|
||||
})
|
||||
|
||||
test("deleteTeamDir is alias for deleteTeamData", () => {
|
||||
//#given
|
||||
const testTeamName = `delete-dir-test-${teamPrefix}`
|
||||
createTeamConfig(testTeamName, "Test team", `ses-delete-dir-${teamPrefix}`, tempProjectDir, "sisyphus")
|
||||
createdTeams.push(testTeamName)
|
||||
expect(teamExists(testTeamName)).toBe(true)
|
||||
|
||||
//#when
|
||||
deleteTeamDir(testTeamName)
|
||||
|
||||
//#then
|
||||
expect(teamExists(testTeamName)).toBe(false)
|
||||
})
|
||||
|
||||
test("deleteTeamData fails if team has active teammates", () => {
|
||||
//#given
|
||||
const teamName = createdTeams[0]
|
||||
const config = readTeamConfigOrThrow(teamName)
|
||||
const updated = upsertTeammate(config, {
|
||||
agentId: `teammate@${teamName}`,
|
||||
name: "teammate",
|
||||
agentType: "teammate",
|
||||
category: "test",
|
||||
model: "sisyphus",
|
||||
prompt: "test prompt",
|
||||
color: "#000000",
|
||||
planModeRequired: false,
|
||||
joinedAt: new Date().toISOString(),
|
||||
cwd: process.cwd(),
|
||||
subscriptions: [],
|
||||
backendType: "native",
|
||||
isActive: true,
|
||||
sessionID: "ses-sub",
|
||||
})
|
||||
writeTeamConfig(teamName, updated)
|
||||
|
||||
//#when
|
||||
const deleteWithTeammates = () => deleteTeamData(teamName)
|
||||
|
||||
//#then
|
||||
expect(deleteWithTeammates).toThrow("team_has_active_members")
|
||||
expect(teamExists(teamName)).toBe(true)
|
||||
})
|
||||
|
||||
})
|
||||
217
src/tools/agent-teams/team-config-store.ts
Normal file
217
src/tools/agent-teams/team-config-store.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import { existsSync, readdirSync, rmSync } from "node:fs"
|
||||
import { acquireLock, ensureDir, readJsonSafe, writeJsonAtomic } from "../../features/claude-tasks/storage"
|
||||
import {
|
||||
getTeamConfigPath,
|
||||
getTeamDir,
|
||||
getTeamInboxDir,
|
||||
getTeamTaskDir,
|
||||
getTeamTasksRootDir,
|
||||
getTeamsRootDir,
|
||||
} from "./paths"
|
||||
import {
|
||||
TEAM_COLOR_PALETTE,
|
||||
TeamConfig,
|
||||
TeamConfigSchema,
|
||||
TeamLeadMember,
|
||||
TeamMember,
|
||||
TeamTeammateMember,
|
||||
isTeammateMember,
|
||||
} from "./types"
|
||||
import { validateTeamName } from "./name-validation"
|
||||
import { withTeamTaskLock } from "./team-task-store"
|
||||
|
||||
function nowMs(): string {
|
||||
return new Date().toISOString()
|
||||
}
|
||||
|
||||
function assertValidTeamName(teamName: string): void {
|
||||
const validationError = validateTeamName(teamName)
|
||||
if (validationError) {
|
||||
throw new Error(validationError)
|
||||
}
|
||||
}
|
||||
|
||||
function withTeamLock<T>(teamName: string, operation: () => T): T {
|
||||
assertValidTeamName(teamName)
|
||||
const teamDir = getTeamDir(teamName)
|
||||
ensureDir(teamDir)
|
||||
const lock = acquireLock(teamDir)
|
||||
if (!lock.acquired) {
|
||||
throw new Error("team_lock_unavailable")
|
||||
}
|
||||
|
||||
try {
|
||||
return operation()
|
||||
} finally {
|
||||
lock.release()
|
||||
}
|
||||
}
|
||||
|
||||
function createLeadMember(teamName: string, cwd: string, leadModel: string): TeamLeadMember {
|
||||
return {
|
||||
agentId: `team-lead@${teamName}`,
|
||||
name: "team-lead",
|
||||
agentType: "team-lead",
|
||||
color: "#2D3748",
|
||||
model: leadModel,
|
||||
joinedAt: nowMs(),
|
||||
cwd,
|
||||
subscriptions: [],
|
||||
}
|
||||
}
|
||||
|
||||
export function ensureTeamStorageDirs(teamName: string): void {
|
||||
assertValidTeamName(teamName)
|
||||
ensureDir(getTeamsRootDir())
|
||||
ensureDir(getTeamTasksRootDir())
|
||||
ensureDir(getTeamDir(teamName))
|
||||
ensureDir(getTeamInboxDir(teamName))
|
||||
ensureDir(getTeamTaskDir(teamName))
|
||||
}
|
||||
|
||||
export function teamExists(teamName: string): boolean {
|
||||
assertValidTeamName(teamName)
|
||||
return existsSync(getTeamConfigPath(teamName))
|
||||
}
|
||||
|
||||
export function createTeamConfig(
|
||||
teamName: string,
|
||||
description: string,
|
||||
leadSessionId: string,
|
||||
cwd: string,
|
||||
leadModel: string,
|
||||
): TeamConfig {
|
||||
ensureTeamStorageDirs(teamName)
|
||||
|
||||
const leadAgentId = `team-lead@${teamName}`
|
||||
const config: TeamConfig = {
|
||||
name: teamName,
|
||||
description,
|
||||
createdAt: nowMs(),
|
||||
leadAgentId,
|
||||
leadSessionId,
|
||||
members: [createLeadMember(teamName, cwd, leadModel)],
|
||||
}
|
||||
|
||||
return withTeamLock(teamName, () => {
|
||||
if (teamExists(teamName)) {
|
||||
throw new Error("team_already_exists")
|
||||
}
|
||||
writeJsonAtomic(getTeamConfigPath(teamName), TeamConfigSchema.parse(config))
|
||||
return config
|
||||
})
|
||||
}
|
||||
|
||||
export function readTeamConfig(teamName: string): TeamConfig | null {
|
||||
assertValidTeamName(teamName)
|
||||
return readJsonSafe(getTeamConfigPath(teamName), TeamConfigSchema)
|
||||
}
|
||||
|
||||
export function readTeamConfigOrThrow(teamName: string): TeamConfig {
|
||||
const config = readTeamConfig(teamName)
|
||||
if (!config) {
|
||||
throw new Error("team_not_found")
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
export function writeTeamConfig(teamName: string, config: TeamConfig): TeamConfig {
|
||||
assertValidTeamName(teamName)
|
||||
return withTeamLock(teamName, () => {
|
||||
const validated = TeamConfigSchema.parse(config)
|
||||
writeJsonAtomic(getTeamConfigPath(teamName), validated)
|
||||
return validated
|
||||
})
|
||||
}
|
||||
|
||||
export function updateTeamConfig(teamName: string, updater: (config: TeamConfig) => TeamConfig): TeamConfig {
|
||||
assertValidTeamName(teamName)
|
||||
return withTeamLock(teamName, () => {
|
||||
const current = readJsonSafe(getTeamConfigPath(teamName), TeamConfigSchema)
|
||||
if (!current) {
|
||||
throw new Error("team_not_found")
|
||||
}
|
||||
|
||||
const next = TeamConfigSchema.parse(updater(current))
|
||||
writeJsonAtomic(getTeamConfigPath(teamName), next)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
export function listTeammates(config: TeamConfig): TeamTeammateMember[] {
|
||||
return config.members.filter(isTeammateMember)
|
||||
}
|
||||
|
||||
export function getTeamMember(config: TeamConfig, name: string): TeamMember | undefined {
|
||||
return config.members.find((member) => member.name === name)
|
||||
}
|
||||
|
||||
export function upsertTeammate(config: TeamConfig, teammate: TeamTeammateMember): TeamConfig {
|
||||
const members = config.members.filter((member) => member.name !== teammate.name)
|
||||
members.push(teammate)
|
||||
return { ...config, members }
|
||||
}
|
||||
|
||||
export function removeTeammate(config: TeamConfig, agentName: string): TeamConfig {
|
||||
if (agentName === "team-lead") {
|
||||
throw new Error("cannot_remove_team_lead")
|
||||
}
|
||||
|
||||
return {
|
||||
...config,
|
||||
members: config.members.filter((member) => member.name !== agentName),
|
||||
}
|
||||
}
|
||||
|
||||
export function assignNextColor(config: TeamConfig): string {
|
||||
const teammateCount = listTeammates(config).length
|
||||
return TEAM_COLOR_PALETTE[teammateCount % TEAM_COLOR_PALETTE.length]
|
||||
}
|
||||
|
||||
export function deleteTeamData(teamName: string): void {
|
||||
assertValidTeamName(teamName)
|
||||
withTeamLock(teamName, () => {
|
||||
const config = readJsonSafe(getTeamConfigPath(teamName), TeamConfigSchema)
|
||||
if (!config) {
|
||||
throw new Error("team_not_found")
|
||||
}
|
||||
|
||||
if (listTeammates(config).length > 0) {
|
||||
throw new Error("team_has_active_members")
|
||||
}
|
||||
|
||||
withTeamTaskLock(teamName, () => {
|
||||
const teamDir = getTeamDir(teamName)
|
||||
const taskDir = getTeamTaskDir(teamName)
|
||||
|
||||
if (existsSync(taskDir)) {
|
||||
rmSync(taskDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
if (existsSync(teamDir)) {
|
||||
rmSync(teamDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteTeamDir(teamName: string): void {
|
||||
deleteTeamData(teamName)
|
||||
}
|
||||
|
||||
export function listTeams(): string[] {
|
||||
const teamsRootDir = getTeamsRootDir()
|
||||
if (!existsSync(teamsRootDir)) {
|
||||
return []
|
||||
}
|
||||
|
||||
try {
|
||||
const entries = readdirSync(teamsRootDir, { withFileTypes: true })
|
||||
return entries
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name)
|
||||
.filter((name) => existsSync(getTeamConfigPath(name)))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
297
src/tools/agent-teams/team-lifecycle-tools.test.ts
Normal file
297
src/tools/agent-teams/team-lifecycle-tools.test.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
/// <reference types="bun-types" />
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
||||
import { existsSync, mkdtempSync, rmSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { createTeamCreateTool, createTeamDeleteTool } from "./team-lifecycle-tools"
|
||||
import { getTeamConfigPath, getTeamDir, getTeamTaskDir } from "./paths"
|
||||
import { readTeamConfig, listTeammates } from "./team-config-store"
|
||||
import { getTeamsRootDir, getTeamTasksRootDir } from "./paths"
|
||||
import { deleteTeamData } from "./team-config-store"
|
||||
|
||||
const TEST_SUFFIX = randomUUID().substring(0, 8)
|
||||
|
||||
interface TestToolContext {
|
||||
sessionID: string
|
||||
messageID: string
|
||||
agent: string
|
||||
abort: AbortSignal
|
||||
}
|
||||
|
||||
function createContext(sessionID = "ses-main"): TestToolContext {
|
||||
return {
|
||||
sessionID,
|
||||
messageID: "msg-main",
|
||||
agent: "sisyphus",
|
||||
abort: new AbortController().signal as AbortSignal,
|
||||
}
|
||||
}
|
||||
|
||||
async function executeJsonTool(
|
||||
tool: ReturnType<typeof createTeamCreateTool | typeof createTeamDeleteTool>,
|
||||
args: Record<string, unknown>,
|
||||
context: TestToolContext,
|
||||
): Promise<unknown> {
|
||||
const output = await tool.execute(args, context)
|
||||
return JSON.parse(output)
|
||||
}
|
||||
|
||||
describe("team_lifecycle tools", () => {
|
||||
let originalCwd: string
|
||||
let tempProjectDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
originalCwd = process.cwd()
|
||||
tempProjectDir = mkdtempSync(join(tmpdir(), "agent-teams-lifecycle-"))
|
||||
process.chdir(tempProjectDir)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(originalCwd)
|
||||
rmSync(tempProjectDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe("team_create", () => {
|
||||
test("creates team with valid name and description", async () => {
|
||||
//#given
|
||||
const tool = createTeamCreateTool()
|
||||
const context = createContext()
|
||||
const teamName = `test-team-${TEST_SUFFIX}`
|
||||
|
||||
//#when
|
||||
const result = await executeJsonTool(tool, {
|
||||
team_name: teamName,
|
||||
description: "My test team",
|
||||
}, context)
|
||||
|
||||
//#then
|
||||
expect(result).toEqual({
|
||||
team_name: teamName,
|
||||
config_path: getTeamConfigPath(teamName),
|
||||
lead_agent_id: `team-lead@${teamName}`,
|
||||
})
|
||||
|
||||
// Verify team was actually created
|
||||
const teamConfig = readTeamConfig(teamName)
|
||||
expect(teamConfig).not.toBeNull()
|
||||
expect(teamConfig?.name).toBe(teamName)
|
||||
expect(teamConfig?.description).toBe("My test team")
|
||||
expect(teamConfig?.leadAgentId).toBe(`team-lead@${teamName}`)
|
||||
expect(teamConfig?.leadSessionId).toBe("ses-main")
|
||||
expect(teamConfig?.members).toHaveLength(1)
|
||||
expect(teamConfig?.members[0].agentType).toBe("team-lead")
|
||||
})
|
||||
|
||||
test("creates team with only name (description optional)", async () => {
|
||||
//#given
|
||||
const tool = createTeamCreateTool()
|
||||
const context = createContext()
|
||||
const teamName = `minimal-team-${TEST_SUFFIX}`
|
||||
|
||||
//#when
|
||||
const result = await executeJsonTool(tool, {
|
||||
team_name: teamName,
|
||||
}, context)
|
||||
|
||||
//#then
|
||||
expect(result).toEqual({
|
||||
team_name: teamName,
|
||||
config_path: getTeamConfigPath(teamName),
|
||||
lead_agent_id: `team-lead@${teamName}`,
|
||||
})
|
||||
|
||||
const teamConfig = readTeamConfig(teamName)
|
||||
expect(teamConfig?.description).toBe("")
|
||||
})
|
||||
|
||||
test("validates team name format (alphanumeric, hyphens, underscores only)", async () => {
|
||||
//#given
|
||||
const tool = createTeamCreateTool()
|
||||
const context = createContext()
|
||||
|
||||
//#when
|
||||
const result = await executeJsonTool(tool, {
|
||||
team_name: "invalid@name",
|
||||
}, context)
|
||||
|
||||
//#then
|
||||
expect(result).toEqual({
|
||||
error: "team_create_failed",
|
||||
})
|
||||
})
|
||||
|
||||
test("validates team name max length (64 chars)", async () => {
|
||||
//#given
|
||||
const tool = createTeamCreateTool()
|
||||
const context = createContext()
|
||||
const longName = "a".repeat(65)
|
||||
|
||||
//#when
|
||||
const result = await executeJsonTool(tool, {
|
||||
team_name: longName,
|
||||
}, context)
|
||||
|
||||
//#then
|
||||
expect(result).toEqual({
|
||||
error: "team_create_failed",
|
||||
})
|
||||
})
|
||||
|
||||
test("rejects duplicate team names", async () => {
|
||||
//#given
|
||||
const tool = createTeamCreateTool()
|
||||
const context1 = createContext("ses-1")
|
||||
const context2 = createContext("ses-2")
|
||||
const teamName = `duplicate-team-${TEST_SUFFIX}`
|
||||
|
||||
// Create team first
|
||||
await executeJsonTool(tool, {
|
||||
team_name: teamName,
|
||||
}, context1)
|
||||
|
||||
//#when - try to create same team again
|
||||
const result = await executeJsonTool(tool, {
|
||||
team_name: teamName,
|
||||
}, context2)
|
||||
|
||||
//#then
|
||||
expect(result).toEqual({
|
||||
error: "team_already_exists",
|
||||
})
|
||||
|
||||
// Verify first team still exists
|
||||
const teamConfig = readTeamConfig(teamName)
|
||||
expect(teamConfig).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("team_delete", () => {
|
||||
test("deletes team when no active teammates", async () => {
|
||||
//#given
|
||||
const createTool = createTeamCreateTool()
|
||||
const deleteTool = createTeamDeleteTool()
|
||||
const context = createContext()
|
||||
const teamName = `test-delete-team-${TEST_SUFFIX}`
|
||||
|
||||
// Create team first
|
||||
await executeJsonTool(createTool, {
|
||||
team_name: teamName,
|
||||
}, context)
|
||||
|
||||
//#when
|
||||
const result = await executeJsonTool(deleteTool, {
|
||||
team_name: teamName,
|
||||
}, context)
|
||||
|
||||
//#then
|
||||
expect(result).toEqual({
|
||||
deleted: true,
|
||||
team_name: teamName,
|
||||
})
|
||||
|
||||
// Verify team dir is deleted
|
||||
expect(existsSync(getTeamDir(teamName))).toBe(false)
|
||||
expect(existsSync(getTeamTaskDir(teamName))).toBe(false)
|
||||
expect(existsSync(getTeamConfigPath(teamName))).toBe(false)
|
||||
})
|
||||
|
||||
test("blocks deletion when team has active teammates", async () => {
|
||||
//#given
|
||||
const createTool = createTeamCreateTool()
|
||||
const deleteTool = createTeamDeleteTool()
|
||||
const context = createContext()
|
||||
const teamName = `team-with-members-${TEST_SUFFIX}`
|
||||
|
||||
// Create team
|
||||
await executeJsonTool(createTool, {
|
||||
team_name: teamName,
|
||||
}, context)
|
||||
|
||||
// Add a teammate by modifying config directly for test
|
||||
const teamConfig = readTeamConfig(teamName)
|
||||
expect(teamConfig).not.toBeNull()
|
||||
|
||||
// Manually add a teammate to simulate active member
|
||||
const { writeTeamConfig } = await import("./team-config-store")
|
||||
if (teamConfig) {
|
||||
writeTeamConfig(teamName, {
|
||||
...teamConfig,
|
||||
members: [
|
||||
...teamConfig.members,
|
||||
{
|
||||
agentId: "teammate-1",
|
||||
name: "test-teammate",
|
||||
agentType: "teammate",
|
||||
color: "#FF6B6B",
|
||||
category: "test",
|
||||
model: "test-model",
|
||||
prompt: "Test prompt",
|
||||
planModeRequired: false,
|
||||
joinedAt: new Date().toISOString(),
|
||||
cwd: "/tmp",
|
||||
subscriptions: [],
|
||||
backendType: "native",
|
||||
isActive: true,
|
||||
sessionID: "test-session",
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = await executeJsonTool(deleteTool, {
|
||||
team_name: teamName,
|
||||
}, context)
|
||||
|
||||
//#then
|
||||
expect(result).toEqual({
|
||||
error: "team_has_active_members",
|
||||
members: ["test-teammate"],
|
||||
})
|
||||
|
||||
// Cleanup - manually remove teammates first, then delete
|
||||
const configApi = await import("./team-config-store")
|
||||
const cleanupConfig = readTeamConfig(teamName)
|
||||
if (cleanupConfig) {
|
||||
configApi.writeTeamConfig(teamName, {
|
||||
...cleanupConfig,
|
||||
members: cleanupConfig.members.filter((m) => m.agentType === "team-lead"),
|
||||
})
|
||||
configApi.deleteTeamData(teamName)
|
||||
}
|
||||
})
|
||||
|
||||
test("validates team name format on deletion", async () => {
|
||||
//#given
|
||||
const deleteTool = createTeamDeleteTool()
|
||||
const context = createContext()
|
||||
const teamName = `invalid-team-${TEST_SUFFIX}`
|
||||
|
||||
//#when
|
||||
const result = await executeJsonTool(deleteTool, {
|
||||
team_name: "invalid@name",
|
||||
}, context)
|
||||
|
||||
//#then - Zod returns detailed validation error array
|
||||
const parsedResult = result as { error: string }
|
||||
expect(parsedResult.error).toContain("Team name must contain only letters")
|
||||
})
|
||||
|
||||
test("returns error for non-existent team", async () => {
|
||||
//#given
|
||||
const deleteTool = createTeamDeleteTool()
|
||||
const context = createContext()
|
||||
|
||||
//#when
|
||||
const result = await executeJsonTool(deleteTool, {
|
||||
team_name: "non-existent-team",
|
||||
}, context)
|
||||
|
||||
//#then
|
||||
expect(result).toEqual({
|
||||
error: "team_not_found",
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
151
src/tools/agent-teams/team-lifecycle-tools.ts
Normal file
151
src/tools/agent-teams/team-lifecycle-tools.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
|
||||
import { z } from "zod"
|
||||
import { getTeamConfigPath } from "./paths"
|
||||
import { validateTeamName } from "./name-validation"
|
||||
import { ensureInbox } from "./inbox-store"
|
||||
import {
|
||||
TeamConfig,
|
||||
TeamCreateInputSchema,
|
||||
TeamDeleteInputSchema,
|
||||
TeamReadConfigInputSchema,
|
||||
TeamToolContext,
|
||||
isTeammateMember,
|
||||
} from "./types"
|
||||
import { createTeamConfig, deleteTeamData, listTeammates, readTeamConfig, readTeamConfigOrThrow } from "./team-config-store"
|
||||
|
||||
function resolveReaderFromContext(config: TeamConfig, context: TeamToolContext): "team-lead" | string | null {
|
||||
if (context.sessionID === config.leadSessionId) {
|
||||
return "team-lead"
|
||||
}
|
||||
|
||||
const matchedMember = config.members.find((member) => isTeammateMember(member) && member.sessionID === context.sessionID)
|
||||
return matchedMember?.name ?? null
|
||||
}
|
||||
|
||||
function toPublicTeamConfig(config: TeamConfig): {
|
||||
team_name: string
|
||||
description: string | undefined
|
||||
lead_agent_id: string
|
||||
teammates: Array<{ name: string }>
|
||||
} {
|
||||
return {
|
||||
team_name: config.name,
|
||||
description: config.description,
|
||||
lead_agent_id: config.leadAgentId,
|
||||
teammates: listTeammates(config).map((member) => ({ name: member.name })),
|
||||
}
|
||||
}
|
||||
|
||||
export function createTeamCreateTool(): ToolDefinition {
|
||||
return tool({
|
||||
description: "Create a team workspace with config, inboxes, and task storage.",
|
||||
args: {
|
||||
team_name: tool.schema.string().describe("Team name (letters, numbers, hyphens, underscores)"),
|
||||
description: tool.schema.string().optional().describe("Team description"),
|
||||
},
|
||||
execute: async (args: Record<string, unknown>, context: TeamToolContext): Promise<string> => {
|
||||
try {
|
||||
const input = TeamCreateInputSchema.parse(args)
|
||||
|
||||
const config = createTeamConfig(
|
||||
input.team_name,
|
||||
input.description ?? "",
|
||||
context.sessionID,
|
||||
process.cwd(),
|
||||
"native/team-lead",
|
||||
)
|
||||
ensureInbox(config.name, "team-lead")
|
||||
|
||||
return JSON.stringify({
|
||||
team_name: config.name,
|
||||
config_path: getTeamConfigPath(config.name) as string,
|
||||
lead_agent_id: config.leadAgentId,
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === "team_already_exists") {
|
||||
return JSON.stringify({ error: error.message })
|
||||
}
|
||||
return JSON.stringify({ error: "team_create_failed" })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function createTeamDeleteTool(): ToolDefinition {
|
||||
return tool({
|
||||
description: "Delete a team and its stored data. Fails if teammates still exist.",
|
||||
args: {
|
||||
team_name: tool.schema.string().describe("Team name"),
|
||||
},
|
||||
execute: async (args: Record<string, unknown>, _context: TeamToolContext): Promise<string> => {
|
||||
let teamName: string | undefined
|
||||
|
||||
try {
|
||||
const input = TeamDeleteInputSchema.parse(args)
|
||||
teamName = input.team_name
|
||||
const config = readTeamConfig(input.team_name)
|
||||
if (!config) {
|
||||
return JSON.stringify({ error: "team_not_found" })
|
||||
}
|
||||
|
||||
const teammates = listTeammates(config)
|
||||
if (teammates.length > 0) {
|
||||
return JSON.stringify({
|
||||
error: "team_has_active_members",
|
||||
members: teammates.map((member) => member.name),
|
||||
})
|
||||
}
|
||||
|
||||
deleteTeamData(input.team_name)
|
||||
return JSON.stringify({ deleted: true, team_name: input.team_name })
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.message === "team_has_active_members") {
|
||||
const config = readTeamConfig(teamName!)
|
||||
const activeMembers = config ? listTeammates(config) : []
|
||||
return JSON.stringify({
|
||||
error: "team_has_active_members",
|
||||
members: activeMembers.map((member) => member.name),
|
||||
})
|
||||
}
|
||||
if (error.message === "team_not_found") {
|
||||
return JSON.stringify({ error: "team_not_found" })
|
||||
}
|
||||
return JSON.stringify({ error: error.message })
|
||||
}
|
||||
return JSON.stringify({ error: "team_delete_failed" })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function createTeamReadConfigTool(): ToolDefinition {
|
||||
return tool({
|
||||
description: "Read team configuration and member list.",
|
||||
args: {
|
||||
team_name: tool.schema.string().describe("Team name"),
|
||||
},
|
||||
execute: async (args: Record<string, unknown>, context: TeamToolContext): Promise<string> => {
|
||||
try {
|
||||
const input = TeamReadConfigInputSchema.parse(args)
|
||||
const config = readTeamConfig(input.team_name)
|
||||
if (!config) {
|
||||
return JSON.stringify({ error: "team_not_found" })
|
||||
}
|
||||
|
||||
const actor = resolveReaderFromContext(config, context)
|
||||
if (!actor) {
|
||||
return JSON.stringify({ error: "unauthorized_reader_session" })
|
||||
}
|
||||
|
||||
if (actor !== "team-lead") {
|
||||
return JSON.stringify(toPublicTeamConfig(config))
|
||||
}
|
||||
|
||||
return JSON.stringify(config)
|
||||
} catch (error) {
|
||||
return JSON.stringify({ error: error instanceof Error ? error.message : "team_read_config_failed" })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
94
src/tools/agent-teams/team-task-dependency.test.ts
Normal file
94
src/tools/agent-teams/team-task-dependency.test.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
/// <reference types="bun-types" />
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
addPendingEdge,
|
||||
createPendingEdgeMap,
|
||||
ensureDependenciesCompleted,
|
||||
ensureForwardStatusTransition,
|
||||
wouldCreateCycle,
|
||||
} from "./team-task-dependency"
|
||||
import type { TeamTask, TeamTaskStatus } from "./types"
|
||||
|
||||
function createTask(id: string, status: TeamTaskStatus, blockedBy: string[] = []): TeamTask {
|
||||
return {
|
||||
id,
|
||||
subject: `Task ${id}`,
|
||||
description: `Description ${id}`,
|
||||
status,
|
||||
blocks: [],
|
||||
blockedBy,
|
||||
}
|
||||
}
|
||||
|
||||
describe("agent-teams task dependency utilities", () => {
|
||||
test("detects cycle from existing blockedBy chain", () => {
|
||||
//#given
|
||||
const tasks = new Map<string, TeamTask>([
|
||||
["A", createTask("A", "pending", ["B"])],
|
||||
["B", createTask("B", "pending")],
|
||||
])
|
||||
const pending = createPendingEdgeMap()
|
||||
const readTask = (id: string) => tasks.get(id) ?? null
|
||||
|
||||
//#when
|
||||
const hasCycle = wouldCreateCycle("B", "A", pending, readTask)
|
||||
|
||||
//#then
|
||||
expect(hasCycle).toBe(true)
|
||||
})
|
||||
|
||||
test("detects cycle from pending edge map", () => {
|
||||
//#given
|
||||
const tasks = new Map<string, TeamTask>([["A", createTask("A", "pending")]])
|
||||
const pending = createPendingEdgeMap()
|
||||
addPendingEdge(pending, "A", "B")
|
||||
const readTask = (id: string) => tasks.get(id) ?? null
|
||||
|
||||
//#when
|
||||
const hasCycle = wouldCreateCycle("B", "A", pending, readTask)
|
||||
|
||||
//#then
|
||||
expect(hasCycle).toBe(true)
|
||||
})
|
||||
|
||||
test("returns false when dependency graph has no cycle", () => {
|
||||
//#given
|
||||
const tasks = new Map<string, TeamTask>([
|
||||
["A", createTask("A", "pending")],
|
||||
["B", createTask("B", "pending", ["A"])],
|
||||
])
|
||||
const pending = createPendingEdgeMap()
|
||||
const readTask = (id: string) => tasks.get(id) ?? null
|
||||
|
||||
//#when
|
||||
const hasCycle = wouldCreateCycle("C", "B", pending, readTask)
|
||||
|
||||
//#then
|
||||
expect(hasCycle).toBe(false)
|
||||
})
|
||||
|
||||
test("allows forward status transitions and blocks backward transitions", () => {
|
||||
//#then
|
||||
expect(() => ensureForwardStatusTransition("pending", "in_progress")).not.toThrow()
|
||||
expect(() => ensureForwardStatusTransition("in_progress", "completed")).not.toThrow()
|
||||
expect(() => ensureForwardStatusTransition("in_progress", "pending")).toThrow(
|
||||
"invalid_status_transition:in_progress->pending",
|
||||
)
|
||||
})
|
||||
|
||||
test("requires blockers to be completed for in_progress/completed", () => {
|
||||
//#given
|
||||
const tasks = new Map<string, TeamTask>([
|
||||
["done", createTask("done", "completed")],
|
||||
["wait", createTask("wait", "pending")],
|
||||
])
|
||||
const readTask = (id: string) => tasks.get(id) ?? null
|
||||
|
||||
//#then
|
||||
expect(() => ensureDependenciesCompleted("pending", ["wait"], readTask)).not.toThrow()
|
||||
expect(() => ensureDependenciesCompleted("in_progress", ["done"], readTask)).not.toThrow()
|
||||
expect(() => ensureDependenciesCompleted("completed", ["wait"], readTask)).toThrow(
|
||||
"blocked_by_incomplete:wait:pending",
|
||||
)
|
||||
})
|
||||
})
|
||||
93
src/tools/agent-teams/team-task-dependency.ts
Normal file
93
src/tools/agent-teams/team-task-dependency.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import type { TeamTask, TeamTaskStatus } from "./types"
|
||||
|
||||
type PendingEdges = Record<string, Set<string>>
|
||||
|
||||
export const TEAM_TASK_STATUS_ORDER: Record<TeamTaskStatus, number> = {
|
||||
pending: 0,
|
||||
in_progress: 1,
|
||||
completed: 2,
|
||||
deleted: 3,
|
||||
}
|
||||
|
||||
export type TaskReader = (taskId: string) => TeamTask | null
|
||||
|
||||
export function wouldCreateCycle(
|
||||
fromTaskId: string,
|
||||
toTaskId: string,
|
||||
pendingEdges: PendingEdges,
|
||||
readTask: TaskReader,
|
||||
): boolean {
|
||||
const visited = new Set<string>()
|
||||
const queue: string[] = [toTaskId]
|
||||
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift()
|
||||
if (!current) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (current === fromTaskId) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (visited.has(current)) {
|
||||
continue
|
||||
}
|
||||
visited.add(current)
|
||||
|
||||
const task = readTask(current)
|
||||
if (task) {
|
||||
for (const dep of task.blockedBy) {
|
||||
if (!visited.has(dep)) {
|
||||
queue.push(dep)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const pending = pendingEdges[current]
|
||||
if (pending) {
|
||||
for (const dep of pending) {
|
||||
if (!visited.has(dep)) {
|
||||
queue.push(dep)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export function ensureForwardStatusTransition(current: TeamTaskStatus, next: TeamTaskStatus): void {
|
||||
const currentOrder = TEAM_TASK_STATUS_ORDER[current]
|
||||
const nextOrder = TEAM_TASK_STATUS_ORDER[next]
|
||||
if (nextOrder < currentOrder) {
|
||||
throw new Error(`invalid_status_transition:${current}->${next}`)
|
||||
}
|
||||
}
|
||||
|
||||
export function ensureDependenciesCompleted(
|
||||
status: TeamTaskStatus,
|
||||
blockedBy: string[],
|
||||
readTask: TaskReader,
|
||||
): void {
|
||||
if (status !== "in_progress" && status !== "completed") {
|
||||
return
|
||||
}
|
||||
|
||||
for (const blockerId of blockedBy) {
|
||||
const blocker = readTask(blockerId)
|
||||
if (blocker && blocker.status !== "completed") {
|
||||
throw new Error(`blocked_by_incomplete:${blockerId}:${blocker.status}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createPendingEdgeMap(): PendingEdges {
|
||||
return {}
|
||||
}
|
||||
|
||||
export function addPendingEdge(pendingEdges: PendingEdges, from: string, to: string): void {
|
||||
const existing = pendingEdges[from] ?? new Set<string>()
|
||||
existing.add(to)
|
||||
pendingEdges[from] = existing
|
||||
}
|
||||
460
src/tools/agent-teams/team-task-store.test.ts
Normal file
460
src/tools/agent-teams/team-task-store.test.ts
Normal file
@@ -0,0 +1,460 @@
|
||||
/// <reference types="bun-types" />
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
||||
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { dirname } from "node:path"
|
||||
import { ensureDir } from "../../features/claude-tasks/storage"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import {
|
||||
getTeamTaskPath,
|
||||
readTeamTask,
|
||||
writeTeamTask,
|
||||
listTeamTasks,
|
||||
deleteTeamTask,
|
||||
} from "./team-task-store"
|
||||
import type { TeamTask } from "./types"
|
||||
|
||||
describe("getTeamTaskPath", () => {
|
||||
test("returns correct file path for team task", () => {
|
||||
//#given
|
||||
const teamName = "my-team"
|
||||
const taskId = "T-abc123"
|
||||
|
||||
//#when
|
||||
const result = getTeamTaskPath(teamName, taskId)
|
||||
|
||||
//#then
|
||||
expect(result).toContain("my-team")
|
||||
expect(result).toContain("T-abc123.json")
|
||||
})
|
||||
})
|
||||
|
||||
describe("readTeamTask", () => {
|
||||
let originalCwd: string
|
||||
let tempProjectDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
originalCwd = process.cwd()
|
||||
tempProjectDir = mkdtempSync(join(tmpdir(), "team-task-store-test-"))
|
||||
process.chdir(tempProjectDir)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(originalCwd)
|
||||
if (existsSync(tempProjectDir)) {
|
||||
rmSync(tempProjectDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("returns null when task file does not exist", () => {
|
||||
//#given
|
||||
const teamName = "nonexistent-team"
|
||||
const taskId = "T-does-not-exist"
|
||||
|
||||
//#when
|
||||
const result = readTeamTask(teamName, taskId)
|
||||
|
||||
//#then
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
test("returns task when valid task file exists", () => {
|
||||
//#given
|
||||
const task: TeamTask = {
|
||||
id: "T-existing-task",
|
||||
subject: "Test task",
|
||||
description: "Test description",
|
||||
status: "pending",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
threadID: "ses_test",
|
||||
}
|
||||
writeTeamTask("test-team", "T-existing-task", task)
|
||||
|
||||
//#when
|
||||
const result = readTeamTask("test-team", "T-existing-task")
|
||||
|
||||
//#then
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.id).toBe("T-existing-task")
|
||||
expect(result?.subject).toBe("Test task")
|
||||
})
|
||||
|
||||
test("returns null when task file contains invalid JSON", () => {
|
||||
//#given
|
||||
const taskPath = getTeamTaskPath("invalid-team", "T-invalid-json")
|
||||
const parentDir = dirname(taskPath)
|
||||
rmSync(parentDir, { recursive: true, force: true })
|
||||
ensureDir(parentDir)
|
||||
writeFileSync(taskPath, "{ invalid json }")
|
||||
|
||||
//#when
|
||||
const result = readTeamTask("invalid-team", "T-invalid-json")
|
||||
|
||||
//#then
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
test("returns null when task file does not match schema", () => {
|
||||
//#given
|
||||
const taskPath = getTeamTaskPath("invalid-schema-team", "T-bad-schema")
|
||||
const parentDir = dirname(taskPath)
|
||||
rmSync(parentDir, { recursive: true, force: true })
|
||||
ensureDir(parentDir)
|
||||
const invalidData = { id: "T-bad-schema" }
|
||||
writeFileSync(taskPath, JSON.stringify(invalidData))
|
||||
|
||||
//#when
|
||||
const result = readTeamTask("invalid-schema-team", "T-bad-schema")
|
||||
|
||||
//#then
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("writeTeamTask", () => {
|
||||
let originalCwd: string
|
||||
let tempProjectDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
originalCwd = process.cwd()
|
||||
tempProjectDir = mkdtempSync(join(tmpdir(), "team-task-store-test-"))
|
||||
process.chdir(tempProjectDir)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(originalCwd)
|
||||
if (existsSync(tempProjectDir)) {
|
||||
rmSync(tempProjectDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("creates task file in team namespace", () => {
|
||||
//#given
|
||||
const task: TeamTask = {
|
||||
id: "T-write-test",
|
||||
subject: "Write test task",
|
||||
description: "Test writing task",
|
||||
status: "in_progress",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
threadID: "ses_write",
|
||||
}
|
||||
|
||||
//#when
|
||||
writeTeamTask("write-team", "T-write-test", task)
|
||||
|
||||
//#then
|
||||
const taskPath = getTeamTaskPath("write-team", "T-write-test")
|
||||
expect(existsSync(taskPath)).toBe(true)
|
||||
})
|
||||
|
||||
test("overwrites existing task file", () => {
|
||||
//#given
|
||||
const task: TeamTask = {
|
||||
id: "T-overwrite-test",
|
||||
subject: "Original subject",
|
||||
description: "Original description",
|
||||
status: "pending",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
threadID: "ses_original",
|
||||
}
|
||||
writeTeamTask("overwrite-team", "T-overwrite-test", task)
|
||||
|
||||
const updatedTask: TeamTask = {
|
||||
...task,
|
||||
subject: "Updated subject",
|
||||
status: "completed",
|
||||
}
|
||||
|
||||
//#when
|
||||
writeTeamTask("overwrite-team", "T-overwrite-test", updatedTask)
|
||||
|
||||
//#then
|
||||
const result = readTeamTask("overwrite-team", "T-overwrite-test")
|
||||
expect(result?.subject).toBe("Updated subject")
|
||||
expect(result?.status).toBe("completed")
|
||||
})
|
||||
|
||||
test("creates team directory if it does not exist", () => {
|
||||
//#given
|
||||
const task: TeamTask = {
|
||||
id: "T-new-dir",
|
||||
subject: "New directory test",
|
||||
description: "Test",
|
||||
status: "pending",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
threadID: "ses_newdir",
|
||||
}
|
||||
|
||||
//#when
|
||||
writeTeamTask("new-team-directory", "T-new-dir", task)
|
||||
|
||||
//#then
|
||||
const taskPath = getTeamTaskPath("new-team-directory", "T-new-dir")
|
||||
expect(existsSync(taskPath)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("listTeamTasks", () => {
|
||||
let originalCwd: string
|
||||
let tempProjectDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
originalCwd = process.cwd()
|
||||
tempProjectDir = mkdtempSync(join(tmpdir(), "team-task-store-test-"))
|
||||
process.chdir(tempProjectDir)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(originalCwd)
|
||||
if (existsSync(tempProjectDir)) {
|
||||
rmSync(tempProjectDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("returns empty array when team has no tasks", () => {
|
||||
//#given
|
||||
// No tasks written
|
||||
|
||||
//#when
|
||||
const result = listTeamTasks("empty-team")
|
||||
|
||||
//#then
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
test("returns all tasks for a team", () => {
|
||||
//#given
|
||||
const task1: TeamTask = {
|
||||
id: "T-task-1",
|
||||
subject: "Task 1",
|
||||
description: "First task",
|
||||
status: "pending",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
threadID: "ses_1",
|
||||
}
|
||||
const task2: TeamTask = {
|
||||
id: "T-task-2",
|
||||
subject: "Task 2",
|
||||
description: "Second task",
|
||||
status: "in_progress",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
threadID: "ses_2",
|
||||
}
|
||||
writeTeamTask("list-test-team", "T-task-1", task1)
|
||||
writeTeamTask("list-test-team", "T-task-2", task2)
|
||||
|
||||
//#when
|
||||
const result = listTeamTasks("list-test-team")
|
||||
|
||||
//#then
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result.some((t) => t.id === "T-task-1")).toBe(true)
|
||||
expect(result.some((t) => t.id === "T-task-2")).toBe(true)
|
||||
})
|
||||
|
||||
test("includes tasks with all statuses", () => {
|
||||
//#given
|
||||
const pendingTask: TeamTask = {
|
||||
id: "T-pending",
|
||||
subject: "Pending task",
|
||||
description: "Test",
|
||||
status: "pending",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
threadID: "ses_pending",
|
||||
}
|
||||
const inProgressTask: TeamTask = {
|
||||
id: "T-in-progress",
|
||||
subject: "In progress task",
|
||||
description: "Test",
|
||||
status: "in_progress",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
threadID: "ses_inprogress",
|
||||
}
|
||||
const completedTask: TeamTask = {
|
||||
id: "T-completed",
|
||||
subject: "Completed task",
|
||||
description: "Test",
|
||||
status: "completed",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
threadID: "ses_completed",
|
||||
}
|
||||
const deletedTask: TeamTask = {
|
||||
id: "T-deleted",
|
||||
subject: "Deleted task",
|
||||
description: "Test",
|
||||
status: "deleted",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
threadID: "ses_deleted",
|
||||
}
|
||||
writeTeamTask("status-test-team", "T-pending", pendingTask)
|
||||
writeTeamTask("status-test-team", "T-in-progress", inProgressTask)
|
||||
writeTeamTask("status-test-team", "T-completed", completedTask)
|
||||
writeTeamTask("status-test-team", "T-deleted", deletedTask)
|
||||
|
||||
//#when
|
||||
const result = listTeamTasks("status-test-team")
|
||||
|
||||
//#then
|
||||
expect(result).toHaveLength(4)
|
||||
const statuses = result.map((t) => t.status)
|
||||
expect(statuses).toContain("pending")
|
||||
expect(statuses).toContain("in_progress")
|
||||
expect(statuses).toContain("completed")
|
||||
expect(statuses).toContain("deleted")
|
||||
})
|
||||
|
||||
test("does not include tasks from other teams", () => {
|
||||
//#given
|
||||
const taskTeam1: TeamTask = {
|
||||
id: "T-team1-task",
|
||||
subject: "Team 1 task",
|
||||
description: "Test",
|
||||
status: "pending",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
threadID: "ses_team1",
|
||||
}
|
||||
const taskTeam2: TeamTask = {
|
||||
id: "T-team2-task",
|
||||
subject: "Team 2 task",
|
||||
description: "Test",
|
||||
status: "pending",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
threadID: "ses_team2",
|
||||
}
|
||||
writeTeamTask("team-1", "T-team1-task", taskTeam1)
|
||||
writeTeamTask("team-2", "T-team2-task", taskTeam2)
|
||||
|
||||
//#when
|
||||
const result = listTeamTasks("team-1")
|
||||
|
||||
//#then
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].id).toBe("T-team1-task")
|
||||
})
|
||||
})
|
||||
|
||||
describe("deleteTeamTask", () => {
|
||||
let originalCwd: string
|
||||
let tempProjectDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
originalCwd = process.cwd()
|
||||
tempProjectDir = mkdtempSync(join(tmpdir(), "team-task-store-test-"))
|
||||
process.chdir(tempProjectDir)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(originalCwd)
|
||||
if (existsSync(tempProjectDir)) {
|
||||
rmSync(tempProjectDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("deletes existing task file", () => {
|
||||
//#given
|
||||
const task: TeamTask = {
|
||||
id: "T-delete-me",
|
||||
subject: "Delete this task",
|
||||
description: "Test",
|
||||
status: "pending",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
threadID: "ses_delete",
|
||||
}
|
||||
writeTeamTask("delete-test-team", "T-delete-me", task)
|
||||
const taskPath = getTeamTaskPath("delete-test-team", "T-delete-me")
|
||||
|
||||
//#when
|
||||
deleteTeamTask("delete-test-team", "T-delete-me")
|
||||
|
||||
//#then
|
||||
expect(existsSync(taskPath)).toBe(false)
|
||||
})
|
||||
|
||||
test("does not throw when task does not exist", () => {
|
||||
//#given
|
||||
// Task does not exist
|
||||
|
||||
//#when
|
||||
expect(() => deleteTeamTask("nonexistent-team", "T-does-not-exist")).not.toThrow()
|
||||
|
||||
//#then
|
||||
// No exception thrown
|
||||
})
|
||||
|
||||
test("does not affect other tasks in same team", () => {
|
||||
//#given
|
||||
const task1: TeamTask = {
|
||||
id: "T-keep-me",
|
||||
subject: "Keep this task",
|
||||
description: "Test",
|
||||
status: "pending",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
threadID: "ses_keep",
|
||||
}
|
||||
const task2: TeamTask = {
|
||||
id: "T-delete-me",
|
||||
subject: "Delete this task",
|
||||
description: "Test",
|
||||
status: "pending",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
threadID: "ses_delete",
|
||||
}
|
||||
writeTeamTask("mixed-test-team", "T-keep-me", task1)
|
||||
writeTeamTask("mixed-test-team", "T-delete-me", task2)
|
||||
|
||||
//#when
|
||||
deleteTeamTask("mixed-test-team", "T-delete-me")
|
||||
|
||||
//#then
|
||||
const remaining = listTeamTasks("mixed-test-team")
|
||||
expect(remaining).toHaveLength(1)
|
||||
expect(remaining[0].id).toBe("T-keep-me")
|
||||
})
|
||||
|
||||
test("does not affect tasks from other teams", () => {
|
||||
//#given
|
||||
const task1: TeamTask = {
|
||||
id: "T-task-1",
|
||||
subject: "Task 1",
|
||||
description: "Test",
|
||||
status: "pending",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
threadID: "ses_1",
|
||||
}
|
||||
const task2: TeamTask = {
|
||||
id: "T-task-2",
|
||||
subject: "Task 2",
|
||||
description: "Test",
|
||||
status: "pending",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
threadID: "ses_2",
|
||||
}
|
||||
writeTeamTask("team-a", "T-task-1", task1)
|
||||
writeTeamTask("team-b", "T-task-2", task2)
|
||||
|
||||
//#when
|
||||
deleteTeamTask("team-a", "T-task-1")
|
||||
|
||||
//#then
|
||||
const remainingInTeamB = listTeamTasks("team-b")
|
||||
expect(remainingInTeamB).toHaveLength(1)
|
||||
expect(remainingInTeamB[0].id).toBe("T-task-2")
|
||||
})
|
||||
})
|
||||
165
src/tools/agent-teams/team-task-store.ts
Normal file
165
src/tools/agent-teams/team-task-store.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { existsSync, readdirSync, unlinkSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import {
|
||||
acquireLock,
|
||||
ensureDir,
|
||||
generateTaskId,
|
||||
readJsonSafe,
|
||||
writeJsonAtomic,
|
||||
} from "../../features/claude-tasks/storage"
|
||||
import { getTeamTaskDir, getTeamTaskPath } from "./paths"
|
||||
import { TeamTask, TeamTaskSchema } from "./types"
|
||||
import { validateTaskId, validateTeamName } from "./name-validation"
|
||||
|
||||
function assertValidTeamName(teamName: string): void {
|
||||
const validationError = validateTeamName(teamName)
|
||||
if (validationError) {
|
||||
throw new Error(validationError)
|
||||
}
|
||||
}
|
||||
|
||||
function assertValidTaskId(taskId: string): void {
|
||||
const validationError = validateTaskId(taskId)
|
||||
if (validationError) {
|
||||
throw new Error(validationError)
|
||||
}
|
||||
}
|
||||
|
||||
function withTaskLock<T>(teamName: string, operation: () => T): T {
|
||||
assertValidTeamName(teamName)
|
||||
const taskDir = getTeamTaskDir(teamName)
|
||||
ensureDir(taskDir)
|
||||
const lock = acquireLock(taskDir)
|
||||
if (!lock.acquired) {
|
||||
throw new Error("team_task_lock_unavailable")
|
||||
}
|
||||
|
||||
try {
|
||||
return operation()
|
||||
} finally {
|
||||
lock.release()
|
||||
}
|
||||
}
|
||||
|
||||
export { getTeamTaskPath } from "./paths"
|
||||
|
||||
export function readTeamTask(teamName: string, taskId: string): TeamTask | null {
|
||||
assertValidTeamName(teamName)
|
||||
assertValidTaskId(taskId)
|
||||
return readJsonSafe(getTeamTaskPath(teamName, taskId), TeamTaskSchema)
|
||||
}
|
||||
|
||||
export function readTeamTaskOrThrow(teamName: string, taskId: string): TeamTask {
|
||||
const task = readTeamTask(teamName, taskId)
|
||||
if (!task) {
|
||||
throw new Error("team_task_not_found")
|
||||
}
|
||||
return task
|
||||
}
|
||||
|
||||
export function listTeamTasks(teamName: string): TeamTask[] {
|
||||
assertValidTeamName(teamName)
|
||||
const taskDir = getTeamTaskDir(teamName)
|
||||
if (!existsSync(taskDir)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const files = readdirSync(taskDir)
|
||||
.filter((file) => file.endsWith(".json") && file.startsWith("T-"))
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
|
||||
const tasks: TeamTask[] = []
|
||||
for (const file of files) {
|
||||
const taskId = file.replace(/\.json$/, "")
|
||||
if (validateTaskId(taskId)) {
|
||||
continue
|
||||
}
|
||||
const task = readTeamTask(teamName, taskId)
|
||||
if (task) {
|
||||
tasks.push(task)
|
||||
}
|
||||
}
|
||||
|
||||
return tasks
|
||||
}
|
||||
|
||||
export function createTeamTask(
|
||||
teamName: string,
|
||||
subject: string,
|
||||
description: string,
|
||||
activeForm?: string,
|
||||
metadata?: Record<string, unknown>,
|
||||
): TeamTask {
|
||||
assertValidTeamName(teamName)
|
||||
if (!subject.trim()) {
|
||||
throw new Error("team_task_subject_required")
|
||||
}
|
||||
|
||||
return withTaskLock(teamName, () => {
|
||||
const taskId = generateTaskId()
|
||||
const task: TeamTask = {
|
||||
id: taskId,
|
||||
subject,
|
||||
description,
|
||||
activeForm,
|
||||
status: "pending",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
threadID: `unknown_${taskId}`,
|
||||
...(metadata ? { metadata } : {}),
|
||||
}
|
||||
|
||||
const validated = TeamTaskSchema.parse(task)
|
||||
writeJsonAtomic(getTeamTaskPath(teamName, taskId), validated)
|
||||
return validated
|
||||
})
|
||||
}
|
||||
|
||||
export function writeTeamTask(teamName: string, taskId: string, task: TeamTask): void {
|
||||
assertValidTeamName(teamName)
|
||||
assertValidTaskId(taskId)
|
||||
const validated = TeamTaskSchema.parse(task)
|
||||
writeJsonAtomic(getTeamTaskPath(teamName, taskId), validated)
|
||||
}
|
||||
|
||||
export function deleteTeamTask(teamName: string, taskId: string): void {
|
||||
assertValidTeamName(teamName)
|
||||
assertValidTaskId(taskId)
|
||||
const taskPath = getTeamTaskPath(teamName, taskId)
|
||||
if (existsSync(taskPath)) {
|
||||
unlinkSync(taskPath)
|
||||
}
|
||||
}
|
||||
|
||||
// Backward compatibility alias
|
||||
export function deleteTeamTaskFile(teamName: string, taskId: string): void {
|
||||
deleteTeamTask(teamName, taskId)
|
||||
}
|
||||
|
||||
export function readTaskFromDirectory(taskDir: string, taskId: string): TeamTask | null {
|
||||
assertValidTaskId(taskId)
|
||||
return readJsonSafe(join(taskDir, `${taskId}.json`), TeamTaskSchema)
|
||||
}
|
||||
|
||||
export function resetOwnerTasks(teamName: string, ownerName: string): void {
|
||||
assertValidTeamName(teamName)
|
||||
withTaskLock(teamName, () => {
|
||||
const tasks = listTeamTasks(teamName)
|
||||
for (const task of tasks) {
|
||||
if (task.owner !== ownerName) {
|
||||
continue
|
||||
}
|
||||
const next: TeamTask = {
|
||||
...task,
|
||||
owner: undefined,
|
||||
status: task.status === "completed" ? "completed" : "pending",
|
||||
}
|
||||
writeTeamTask(teamName, next.id, next)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function withTeamTaskLock<T>(teamName: string, operation: () => T): T {
|
||||
assertValidTeamName(teamName)
|
||||
return withTaskLock(teamName, operation)
|
||||
}
|
||||
160
src/tools/agent-teams/team-task-tools.ts
Normal file
160
src/tools/agent-teams/team-task-tools.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
|
||||
import { sendStructuredInboxMessage } from "./inbox-store"
|
||||
import { readTeamConfigOrThrow } from "./team-config-store"
|
||||
import { validateAgentNameOrLead, validateTaskId, validateTeamName } from "./name-validation"
|
||||
import {
|
||||
TeamConfig,
|
||||
TeamTaskCreateInputSchema,
|
||||
TeamTaskGetInputSchema,
|
||||
TeamTaskListInputSchema,
|
||||
TeamTask,
|
||||
TeamToolContext,
|
||||
isTeammateMember,
|
||||
} from "./types"
|
||||
import { createTeamTask, listTeamTasks, readTeamTask } from "./team-task-store"
|
||||
|
||||
function buildTaskAssignmentPayload(task: TeamTask, assignedBy: string): Record<string, unknown> {
|
||||
return {
|
||||
type: "task_assignment",
|
||||
taskId: task.id,
|
||||
subject: task.subject,
|
||||
description: task.description,
|
||||
assignedBy,
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveTaskActorFromContext(config: TeamConfig, context: TeamToolContext): "team-lead" | string | null {
|
||||
if (context.sessionID === config.leadSessionId) {
|
||||
return "team-lead"
|
||||
}
|
||||
|
||||
const matchedMember = config.members.find((member) => isTeammateMember(member) && member.sessionID === context.sessionID)
|
||||
return matchedMember?.name ?? null
|
||||
}
|
||||
|
||||
export function createTeamTaskCreateTool(): ToolDefinition {
|
||||
return tool({
|
||||
description: "Create a task in team-scoped storage.",
|
||||
args: {
|
||||
team_name: tool.schema.string().describe("Team name"),
|
||||
subject: tool.schema.string().describe("Task subject"),
|
||||
description: tool.schema.string().describe("Task description"),
|
||||
active_form: tool.schema.string().optional().describe("Present-continuous form"),
|
||||
metadata: tool.schema.record(tool.schema.string(), tool.schema.unknown()).optional().describe("Task metadata"),
|
||||
},
|
||||
execute: async (args: Record<string, unknown>, context: TeamToolContext): Promise<string> => {
|
||||
try {
|
||||
const input = TeamTaskCreateInputSchema.parse(args)
|
||||
const teamError = validateTeamName(input.team_name)
|
||||
if (teamError) {
|
||||
return JSON.stringify({ error: teamError })
|
||||
}
|
||||
const config = readTeamConfigOrThrow(input.team_name)
|
||||
const actor = resolveTaskActorFromContext(config, context)
|
||||
if (!actor) {
|
||||
return JSON.stringify({ error: "unauthorized_task_session" })
|
||||
}
|
||||
|
||||
const task = createTeamTask(
|
||||
input.team_name,
|
||||
input.subject,
|
||||
input.description,
|
||||
input.active_form,
|
||||
input.metadata,
|
||||
)
|
||||
|
||||
return JSON.stringify(task)
|
||||
} catch (error) {
|
||||
return JSON.stringify({ error: error instanceof Error ? error.message : "team_task_create_failed" })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function createTeamTaskListTool(): ToolDefinition {
|
||||
return tool({
|
||||
description: "List tasks for one team.",
|
||||
args: {
|
||||
team_name: tool.schema.string().describe("Team name"),
|
||||
},
|
||||
execute: async (args: Record<string, unknown>, context: TeamToolContext): Promise<string> => {
|
||||
try {
|
||||
const input = TeamTaskListInputSchema.parse(args)
|
||||
const teamError = validateTeamName(input.team_name)
|
||||
if (teamError) {
|
||||
return JSON.stringify({ error: teamError })
|
||||
}
|
||||
const config = readTeamConfigOrThrow(input.team_name)
|
||||
const actor = resolveTaskActorFromContext(config, context)
|
||||
if (!actor) {
|
||||
return JSON.stringify({ error: "unauthorized_task_session" })
|
||||
}
|
||||
return JSON.stringify(listTeamTasks(input.team_name))
|
||||
} catch (error) {
|
||||
return JSON.stringify({ error: error instanceof Error ? error.message : "team_task_list_failed" })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function createTeamTaskGetTool(): ToolDefinition {
|
||||
return tool({
|
||||
description: "Get one task from team-scoped storage.",
|
||||
args: {
|
||||
team_name: tool.schema.string().describe("Team name"),
|
||||
task_id: tool.schema.string().describe("Task id"),
|
||||
},
|
||||
execute: async (args: Record<string, unknown>, context: TeamToolContext): Promise<string> => {
|
||||
try {
|
||||
const input = TeamTaskGetInputSchema.parse(args)
|
||||
const teamError = validateTeamName(input.team_name)
|
||||
if (teamError) {
|
||||
return JSON.stringify({ error: teamError })
|
||||
}
|
||||
const taskIdError = validateTaskId(input.task_id)
|
||||
if (taskIdError) {
|
||||
return JSON.stringify({ error: taskIdError })
|
||||
}
|
||||
const config = readTeamConfigOrThrow(input.team_name)
|
||||
const actor = resolveTaskActorFromContext(config, context)
|
||||
if (!actor) {
|
||||
return JSON.stringify({ error: "unauthorized_task_session" })
|
||||
}
|
||||
const task = readTeamTask(input.team_name, input.task_id)
|
||||
if (!task) {
|
||||
return JSON.stringify({ error: "team_task_not_found" })
|
||||
}
|
||||
return JSON.stringify(task)
|
||||
} catch (error) {
|
||||
return JSON.stringify({ error: error instanceof Error ? error.message : "team_task_get_failed" })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function notifyOwnerAssignment(teamName: string, task: TeamTask, assignedBy: string): void {
|
||||
if (!task.owner || task.status === "deleted") {
|
||||
return
|
||||
}
|
||||
|
||||
if (validateTeamName(teamName)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (validateAgentNameOrLead(task.owner)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (validateAgentNameOrLead(assignedBy)) {
|
||||
return
|
||||
}
|
||||
|
||||
sendStructuredInboxMessage(
|
||||
teamName,
|
||||
assignedBy,
|
||||
task.owner,
|
||||
buildTaskAssignmentPayload(task, assignedBy),
|
||||
"task_assignment",
|
||||
)
|
||||
}
|
||||
91
src/tools/agent-teams/team-task-update-tool.ts
Normal file
91
src/tools/agent-teams/team-task-update-tool.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
|
||||
import { readTeamConfigOrThrow } from "./team-config-store"
|
||||
import { validateAgentNameOrLead, validateTaskId, validateTeamName } from "./name-validation"
|
||||
import { TeamTaskUpdateInputSchema, TeamToolContext } from "./types"
|
||||
import { updateTeamTask } from "./team-task-update"
|
||||
import { notifyOwnerAssignment, resolveTaskActorFromContext } from "./team-task-tools"
|
||||
|
||||
export function createTeamTaskUpdateTool(): ToolDefinition {
|
||||
return tool({
|
||||
description: "Update task status, owner, dependencies, and metadata in a team task list.",
|
||||
args: {
|
||||
team_name: tool.schema.string().describe("Team name"),
|
||||
task_id: tool.schema.string().describe("Task id"),
|
||||
status: tool.schema.enum(["pending", "in_progress", "completed", "deleted"]).optional().describe("Task status"),
|
||||
owner: tool.schema.string().optional().describe("Task owner"),
|
||||
subject: tool.schema.string().optional().describe("Task subject"),
|
||||
description: tool.schema.string().optional().describe("Task description"),
|
||||
active_form: tool.schema.string().optional().describe("Present-continuous form"),
|
||||
add_blocks: tool.schema.array(tool.schema.string()).optional().describe("Add task ids this task blocks"),
|
||||
add_blocked_by: tool.schema.array(tool.schema.string()).optional().describe("Add blocker task ids"),
|
||||
metadata: tool.schema.record(tool.schema.string(), tool.schema.unknown()).optional().describe("Metadata patch (null removes key)"),
|
||||
},
|
||||
execute: async (args: Record<string, unknown>, context: TeamToolContext): Promise<string> => {
|
||||
try {
|
||||
const input = TeamTaskUpdateInputSchema.parse(args)
|
||||
const teamError = validateTeamName(input.team_name)
|
||||
if (teamError) {
|
||||
return JSON.stringify({ error: teamError })
|
||||
}
|
||||
const taskIdError = validateTaskId(input.task_id)
|
||||
if (taskIdError) {
|
||||
return JSON.stringify({ error: taskIdError })
|
||||
}
|
||||
|
||||
const config = readTeamConfigOrThrow(input.team_name)
|
||||
const actor = resolveTaskActorFromContext(config, context)
|
||||
if (!actor) {
|
||||
return JSON.stringify({ error: "unauthorized_task_session" })
|
||||
}
|
||||
|
||||
const memberNames = new Set(config.members.map((member) => member.name))
|
||||
if (input.owner !== undefined) {
|
||||
if (input.owner !== "") {
|
||||
const ownerError = validateAgentNameOrLead(input.owner)
|
||||
if (ownerError) {
|
||||
return JSON.stringify({ error: ownerError })
|
||||
}
|
||||
|
||||
if (!memberNames.has(input.owner)) {
|
||||
return JSON.stringify({ error: "owner_not_in_team" })
|
||||
}
|
||||
}
|
||||
}
|
||||
if (input.add_blocks) {
|
||||
for (const blockerId of input.add_blocks) {
|
||||
const blockerError = validateTaskId(blockerId)
|
||||
if (blockerError) {
|
||||
return JSON.stringify({ error: blockerError })
|
||||
}
|
||||
}
|
||||
}
|
||||
if (input.add_blocked_by) {
|
||||
for (const dependencyId of input.add_blocked_by) {
|
||||
const dependencyError = validateTaskId(dependencyId)
|
||||
if (dependencyError) {
|
||||
return JSON.stringify({ error: dependencyError })
|
||||
}
|
||||
}
|
||||
}
|
||||
const task = updateTeamTask(input.team_name, input.task_id, {
|
||||
status: input.status,
|
||||
owner: input.owner,
|
||||
subject: input.subject,
|
||||
description: input.description,
|
||||
activeForm: input.active_form,
|
||||
addBlocks: input.add_blocks,
|
||||
addBlockedBy: input.add_blocked_by,
|
||||
metadata: input.metadata,
|
||||
})
|
||||
|
||||
if (input.owner !== undefined) {
|
||||
notifyOwnerAssignment(input.team_name, task, actor)
|
||||
}
|
||||
|
||||
return JSON.stringify(task)
|
||||
} catch (error) {
|
||||
return JSON.stringify({ error: error instanceof Error ? error.message : "team_task_update_failed" })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
247
src/tools/agent-teams/team-task-update.ts
Normal file
247
src/tools/agent-teams/team-task-update.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import { existsSync, readdirSync, unlinkSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { readJsonSafe, writeJsonAtomic } from "../../features/claude-tasks/storage"
|
||||
import { validateTaskId, validateTeamName } from "./name-validation"
|
||||
import { getTeamTaskDir, getTeamTaskPath } from "./paths"
|
||||
import {
|
||||
addPendingEdge,
|
||||
createPendingEdgeMap,
|
||||
ensureDependenciesCompleted,
|
||||
ensureForwardStatusTransition,
|
||||
wouldCreateCycle,
|
||||
} from "./team-task-dependency"
|
||||
import { TeamTask, TeamTaskSchema, TeamTaskStatus } from "./types"
|
||||
import { withTeamTaskLock } from "./team-task-store"
|
||||
|
||||
export interface TeamTaskUpdatePatch {
|
||||
status?: TeamTaskStatus
|
||||
owner?: string
|
||||
subject?: string
|
||||
description?: string
|
||||
activeForm?: string
|
||||
addBlocks?: string[]
|
||||
addBlockedBy?: string[]
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
function assertValidTeamName(teamName: string): void {
|
||||
const validationError = validateTeamName(teamName)
|
||||
if (validationError) {
|
||||
throw new Error(validationError)
|
||||
}
|
||||
}
|
||||
|
||||
function assertValidTaskId(taskId: string): void {
|
||||
const validationError = validateTaskId(taskId)
|
||||
if (validationError) {
|
||||
throw new Error(validationError)
|
||||
}
|
||||
}
|
||||
|
||||
function writeTaskToPath(path: string, task: TeamTask): void {
|
||||
writeJsonAtomic(path, TeamTaskSchema.parse(task))
|
||||
}
|
||||
|
||||
export function updateTeamTask(teamName: string, taskId: string, patch: TeamTaskUpdatePatch): TeamTask {
|
||||
assertValidTeamName(teamName)
|
||||
assertValidTaskId(taskId)
|
||||
|
||||
if (patch.addBlocks) {
|
||||
for (const blockedTaskId of patch.addBlocks) {
|
||||
assertValidTaskId(blockedTaskId)
|
||||
}
|
||||
}
|
||||
|
||||
if (patch.addBlockedBy) {
|
||||
for (const blockerId of patch.addBlockedBy) {
|
||||
assertValidTaskId(blockerId)
|
||||
}
|
||||
}
|
||||
|
||||
return withTeamTaskLock(teamName, () => {
|
||||
const taskDir = getTeamTaskDir(teamName)
|
||||
const taskPath = getTeamTaskPath(teamName, taskId)
|
||||
const currentTask = readJsonSafe(taskPath, TeamTaskSchema)
|
||||
if (!currentTask) {
|
||||
throw new Error("team_task_not_found")
|
||||
}
|
||||
|
||||
const cache = new Map<string, TeamTask | null>()
|
||||
cache.set(taskId, currentTask)
|
||||
|
||||
const readTask = (id: string): TeamTask | null => {
|
||||
if (cache.has(id)) {
|
||||
return cache.get(id) ?? null
|
||||
}
|
||||
const loaded = readJsonSafe(join(taskDir, `${id}.json`), TeamTaskSchema)
|
||||
cache.set(id, loaded)
|
||||
return loaded
|
||||
}
|
||||
|
||||
const pendingEdges = createPendingEdgeMap()
|
||||
|
||||
if (patch.addBlocks) {
|
||||
for (const blockedTaskId of patch.addBlocks) {
|
||||
if (blockedTaskId === taskId) {
|
||||
throw new Error("team_task_self_block")
|
||||
}
|
||||
if (!readTask(blockedTaskId)) {
|
||||
throw new Error(`team_task_reference_not_found:${blockedTaskId}`)
|
||||
}
|
||||
addPendingEdge(pendingEdges, blockedTaskId, taskId)
|
||||
}
|
||||
|
||||
for (const blockedTaskId of patch.addBlocks) {
|
||||
if (wouldCreateCycle(blockedTaskId, taskId, pendingEdges, readTask)) {
|
||||
throw new Error(`team_task_cycle_detected:${taskId}->${blockedTaskId}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (patch.addBlockedBy) {
|
||||
for (const blockerId of patch.addBlockedBy) {
|
||||
if (blockerId === taskId) {
|
||||
throw new Error("team_task_self_dependency")
|
||||
}
|
||||
if (!readTask(blockerId)) {
|
||||
throw new Error(`team_task_reference_not_found:${blockerId}`)
|
||||
}
|
||||
addPendingEdge(pendingEdges, taskId, blockerId)
|
||||
}
|
||||
|
||||
for (const blockerId of patch.addBlockedBy) {
|
||||
if (wouldCreateCycle(taskId, blockerId, pendingEdges, readTask)) {
|
||||
throw new Error(`team_task_cycle_detected:${taskId}<-${blockerId}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (patch.status && patch.status !== "deleted") {
|
||||
ensureForwardStatusTransition(currentTask.status, patch.status)
|
||||
}
|
||||
|
||||
const effectiveStatus = patch.status ?? currentTask.status
|
||||
const effectiveBlockedBy = Array.from(new Set([...(currentTask.blockedBy ?? []), ...(patch.addBlockedBy ?? [])]))
|
||||
const shouldValidateDependencies =
|
||||
(patch.status !== undefined || (patch.addBlockedBy?.length ?? 0) > 0) && effectiveStatus !== "deleted"
|
||||
|
||||
if (shouldValidateDependencies) {
|
||||
ensureDependenciesCompleted(effectiveStatus, effectiveBlockedBy, readTask)
|
||||
}
|
||||
|
||||
let nextTask: TeamTask = { ...currentTask }
|
||||
|
||||
if (patch.subject !== undefined) {
|
||||
nextTask.subject = patch.subject
|
||||
}
|
||||
if (patch.description !== undefined) {
|
||||
nextTask.description = patch.description
|
||||
}
|
||||
if (patch.activeForm !== undefined) {
|
||||
nextTask.activeForm = patch.activeForm
|
||||
}
|
||||
if (patch.owner !== undefined) {
|
||||
nextTask.owner = patch.owner === "" ? undefined : patch.owner
|
||||
}
|
||||
|
||||
const pendingWrites = new Map<string, TeamTask>()
|
||||
|
||||
if (patch.addBlocks) {
|
||||
const existingBlocks = new Set(nextTask.blocks)
|
||||
for (const blockedTaskId of patch.addBlocks) {
|
||||
if (!existingBlocks.has(blockedTaskId)) {
|
||||
nextTask.blocks.push(blockedTaskId)
|
||||
existingBlocks.add(blockedTaskId)
|
||||
}
|
||||
|
||||
const otherPath = getTeamTaskPath(teamName, blockedTaskId)
|
||||
const other = pendingWrites.get(otherPath) ?? readTask(blockedTaskId)
|
||||
if (other && !other.blockedBy.includes(taskId)) {
|
||||
pendingWrites.set(otherPath, { ...other, blockedBy: [...other.blockedBy, taskId] })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (patch.addBlockedBy) {
|
||||
const existingBlockedBy = new Set(nextTask.blockedBy)
|
||||
for (const blockerId of patch.addBlockedBy) {
|
||||
if (!existingBlockedBy.has(blockerId)) {
|
||||
nextTask.blockedBy.push(blockerId)
|
||||
existingBlockedBy.add(blockerId)
|
||||
}
|
||||
|
||||
const otherPath = getTeamTaskPath(teamName, blockerId)
|
||||
const other = pendingWrites.get(otherPath) ?? readTask(blockerId)
|
||||
if (other && !other.blocks.includes(taskId)) {
|
||||
pendingWrites.set(otherPath, { ...other, blocks: [...other.blocks, taskId] })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (patch.metadata !== undefined) {
|
||||
const merged: Record<string, unknown> = { ...(nextTask.metadata ?? {}) }
|
||||
for (const [key, value] of Object.entries(patch.metadata)) {
|
||||
if (value === null) {
|
||||
delete merged[key]
|
||||
} else {
|
||||
merged[key] = value
|
||||
}
|
||||
}
|
||||
nextTask.metadata = Object.keys(merged).length > 0 ? merged : undefined
|
||||
}
|
||||
|
||||
if (patch.status !== undefined) {
|
||||
nextTask.status = patch.status
|
||||
}
|
||||
|
||||
const allTaskFiles = readdirSync(taskDir).filter((file) => file.endsWith(".json") && file.startsWith("T-"))
|
||||
|
||||
if (nextTask.status === "completed") {
|
||||
for (const file of allTaskFiles) {
|
||||
const otherId = file.replace(/\.json$/, "")
|
||||
if (otherId === taskId) continue
|
||||
|
||||
const otherPath = getTeamTaskPath(teamName, otherId)
|
||||
const other = pendingWrites.get(otherPath) ?? readTask(otherId)
|
||||
if (other?.blockedBy.includes(taskId)) {
|
||||
pendingWrites.set(otherPath, {
|
||||
...other,
|
||||
blockedBy: other.blockedBy.filter((id) => id !== taskId),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (patch.status === "deleted") {
|
||||
for (const file of allTaskFiles) {
|
||||
const otherId = file.replace(/\.json$/, "")
|
||||
if (otherId === taskId) continue
|
||||
|
||||
const otherPath = getTeamTaskPath(teamName, otherId)
|
||||
const other = pendingWrites.get(otherPath) ?? readTask(otherId)
|
||||
if (!other) continue
|
||||
|
||||
const nextOther = {
|
||||
...other,
|
||||
blockedBy: other.blockedBy.filter((id) => id !== taskId),
|
||||
blocks: other.blocks.filter((id) => id !== taskId),
|
||||
}
|
||||
pendingWrites.set(otherPath, nextOther)
|
||||
}
|
||||
}
|
||||
|
||||
for (const [path, task] of pendingWrites.entries()) {
|
||||
writeTaskToPath(path, task)
|
||||
}
|
||||
|
||||
if (patch.status === "deleted") {
|
||||
if (existsSync(taskPath)) {
|
||||
unlinkSync(taskPath)
|
||||
}
|
||||
return TeamTaskSchema.parse({ ...nextTask, status: "deleted" })
|
||||
}
|
||||
|
||||
writeTaskToPath(taskPath, nextTask)
|
||||
return TeamTaskSchema.parse(nextTask)
|
||||
})
|
||||
}
|
||||
243
src/tools/agent-teams/teammate-control-tools.test.ts
Normal file
243
src/tools/agent-teams/teammate-control-tools.test.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
/// <reference types="bun-types" />
|
||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test"
|
||||
import { existsSync, mkdtempSync, rmSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { createForceKillTeammateTool, createProcessShutdownApprovedTool } from "./teammate-control-tools"
|
||||
import { readTeamConfig } from "./team-config-store"
|
||||
import { upsertTeammate, writeTeamConfig } from "./team-config-store"
|
||||
import { ensureInbox } from "./inbox-store"
|
||||
|
||||
const TEST_SUFFIX = randomUUID().substring(0, 8)
|
||||
|
||||
interface TestToolContext {
|
||||
sessionID: string
|
||||
messageID: string
|
||||
agent: string
|
||||
abort: AbortSignal
|
||||
}
|
||||
|
||||
function createContext(sessionID = "ses-main"): TestToolContext {
|
||||
return {
|
||||
sessionID,
|
||||
messageID: "msg-main",
|
||||
agent: "sisyphus",
|
||||
abort: new AbortController().signal as AbortSignal,
|
||||
}
|
||||
}
|
||||
|
||||
async function executeJsonTool(
|
||||
tool: any,
|
||||
args: Record<string, unknown>,
|
||||
context: TestToolContext,
|
||||
): Promise<unknown> {
|
||||
const output = await tool.execute(args, context)
|
||||
return JSON.parse(output)
|
||||
}
|
||||
|
||||
describe("teammate-control-tools", () => {
|
||||
let originalCwd: string
|
||||
let tempProjectDir: string
|
||||
const teamName = `test-team-control-${TEST_SUFFIX}`
|
||||
|
||||
beforeEach(() => {
|
||||
originalCwd = process.cwd()
|
||||
tempProjectDir = mkdtempSync(join(tmpdir(), "agent-teams-control-"))
|
||||
process.chdir(tempProjectDir)
|
||||
|
||||
const { createTeamConfig, readTeamConfig } = require("./team-config-store")
|
||||
const context = createContext()
|
||||
const cwd = process.cwd()
|
||||
|
||||
if (!readTeamConfig(teamName)) {
|
||||
createTeamConfig(
|
||||
teamName,
|
||||
"Test team",
|
||||
context.sessionID,
|
||||
cwd,
|
||||
"native/team-lead",
|
||||
)
|
||||
}
|
||||
|
||||
ensureInbox(teamName, "team-lead")
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(originalCwd)
|
||||
if (existsSync(tempProjectDir)) {
|
||||
rmSync(tempProjectDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe("createForceKillTeammateTool", () => {
|
||||
it("returns error when team not found", async () => {
|
||||
const tool = createForceKillTeammateTool()
|
||||
const testContext = createContext()
|
||||
|
||||
const result = await executeJsonTool(
|
||||
tool,
|
||||
{ team_name: "nonexistent-team", teammate_name: "test-teammate" },
|
||||
testContext,
|
||||
)
|
||||
|
||||
expect(result).toHaveProperty("error")
|
||||
})
|
||||
|
||||
it("returns error when trying to remove team-lead", async () => {
|
||||
const tool = createForceKillTeammateTool()
|
||||
const testContext = createContext()
|
||||
|
||||
const result = await executeJsonTool(
|
||||
tool,
|
||||
{ team_name: teamName, teammate_name: "team-lead" },
|
||||
testContext,
|
||||
)
|
||||
|
||||
expect(result).toHaveProperty("error", "cannot_remove_team_lead")
|
||||
})
|
||||
|
||||
it("returns error when teammate does not exist", async () => {
|
||||
const tool = createForceKillTeammateTool()
|
||||
const testContext = createContext()
|
||||
|
||||
const result = await executeJsonTool(
|
||||
tool,
|
||||
{ team_name: teamName, teammate_name: "nonexistent-teammate" },
|
||||
testContext,
|
||||
)
|
||||
|
||||
expect(result).toHaveProperty("error", "teammate_not_found")
|
||||
})
|
||||
|
||||
it("removes teammate from config and deletes inbox", async () => {
|
||||
const config = readTeamConfig(teamName)!
|
||||
const currentCwd = process.cwd()
|
||||
const teammate = {
|
||||
agentId: `test-teammate-${TEST_SUFFIX}@${teamName}`,
|
||||
name: `test-teammate-${TEST_SUFFIX}`,
|
||||
agentType: "teammate" as const,
|
||||
category: "quick",
|
||||
model: "gpt-5-mini",
|
||||
prompt: "Test prompt",
|
||||
planModeRequired: false,
|
||||
joinedAt: new Date().toISOString(),
|
||||
cwd: currentCwd,
|
||||
subscriptions: [],
|
||||
backendType: "native" as const,
|
||||
isActive: true,
|
||||
sessionID: `ses_teammate-${TEST_SUFFIX}`,
|
||||
backgroundTaskID: undefined,
|
||||
color: "#FF6B6B",
|
||||
}
|
||||
const updatedConfig = upsertTeammate(config, teammate)
|
||||
writeTeamConfig(teamName, updatedConfig)
|
||||
|
||||
ensureInbox(teamName, `test-teammate-${TEST_SUFFIX}`)
|
||||
|
||||
const tool = createForceKillTeammateTool()
|
||||
const testContext = createContext()
|
||||
|
||||
const result = await executeJsonTool(
|
||||
tool,
|
||||
{ team_name: teamName, teammate_name: `test-teammate-${TEST_SUFFIX}` },
|
||||
testContext,
|
||||
)
|
||||
|
||||
expect(result).toHaveProperty("killed", true)
|
||||
expect(result).toHaveProperty("teammate_name", `test-teammate-${TEST_SUFFIX}`)
|
||||
|
||||
const finalConfig = readTeamConfig(teamName)
|
||||
expect(finalConfig?.members.some((m) => m.name === `test-teammate-${TEST_SUFFIX}`)).toBe(false)
|
||||
|
||||
const inboxPath = `.sisyphus/teams/${teamName}/inbox/test-teammate-${TEST_SUFFIX}.json`
|
||||
expect(existsSync(inboxPath)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("createProcessShutdownApprovedTool", () => {
|
||||
it("returns error when team not found", async () => {
|
||||
const tool = createProcessShutdownApprovedTool()
|
||||
const testContext = createContext()
|
||||
|
||||
const result = await executeJsonTool(
|
||||
tool,
|
||||
{ team_name: "nonexistent-team", teammate_name: "test-teammate" },
|
||||
testContext,
|
||||
)
|
||||
|
||||
expect(result).toHaveProperty("error")
|
||||
})
|
||||
|
||||
it("returns error when trying to remove team-lead", async () => {
|
||||
const tool = createProcessShutdownApprovedTool()
|
||||
const testContext = createContext()
|
||||
|
||||
const result = await executeJsonTool(
|
||||
tool,
|
||||
{ team_name: teamName, teammate_name: "team-lead" },
|
||||
testContext,
|
||||
)
|
||||
|
||||
expect(result).toHaveProperty("error", "cannot_remove_team_lead")
|
||||
})
|
||||
|
||||
it("returns error when teammate does not exist", async () => {
|
||||
const tool = createProcessShutdownApprovedTool()
|
||||
const testContext = createContext()
|
||||
|
||||
const result = await executeJsonTool(
|
||||
tool,
|
||||
{ team_name: teamName, teammate_name: "nonexistent-teammate" },
|
||||
testContext,
|
||||
)
|
||||
|
||||
expect(result).toHaveProperty("error", "teammate_not_found")
|
||||
})
|
||||
|
||||
it("removes teammate from config and deletes inbox gracefully", async () => {
|
||||
const config = readTeamConfig(teamName)!
|
||||
const currentCwd = process.cwd()
|
||||
const teammateName = `test-teammate2-${TEST_SUFFIX}`
|
||||
const teammate = {
|
||||
agentId: `${teammateName}@${teamName}`,
|
||||
name: teammateName,
|
||||
agentType: "teammate" as const,
|
||||
category: "quick",
|
||||
model: "gpt-5-mini",
|
||||
prompt: "Test prompt",
|
||||
planModeRequired: false,
|
||||
joinedAt: new Date().toISOString(),
|
||||
cwd: currentCwd,
|
||||
subscriptions: [],
|
||||
backendType: "native" as const,
|
||||
isActive: true,
|
||||
sessionID: `ses_${teammateName}`,
|
||||
backgroundTaskID: undefined,
|
||||
color: "#4ECDC4",
|
||||
}
|
||||
const updatedConfig = upsertTeammate(config, teammate)
|
||||
writeTeamConfig(teamName, updatedConfig)
|
||||
|
||||
ensureInbox(teamName, teammateName)
|
||||
|
||||
const tool = createProcessShutdownApprovedTool()
|
||||
const testContext = createContext()
|
||||
|
||||
const result = await executeJsonTool(
|
||||
tool,
|
||||
{ team_name: teamName, teammate_name: teammateName },
|
||||
testContext,
|
||||
)
|
||||
|
||||
expect(result).toHaveProperty("shutdown_processed", true)
|
||||
expect(result).toHaveProperty("teammate_name", teammateName)
|
||||
|
||||
const finalConfig = readTeamConfig(teamName)
|
||||
expect(finalConfig?.members.some((m) => m.name === teammateName)).toBe(false)
|
||||
|
||||
const inboxPath = `.sisyphus/teams/${teamName}/inbox/${teammateName}.json`
|
||||
expect(existsSync(inboxPath)).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
103
src/tools/agent-teams/teammate-control-tools.ts
Normal file
103
src/tools/agent-teams/teammate-control-tools.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { tool } from "@opencode-ai/plugin/tool"
|
||||
import { ForceKillTeammateInputSchema, ProcessShutdownApprovedInputSchema, isTeammateMember } from "./types"
|
||||
import { readTeamConfig, removeTeammate, updateTeamConfig, getTeamMember } from "./team-config-store"
|
||||
import { deleteInbox } from "./inbox-store"
|
||||
|
||||
export function createForceKillTeammateTool() {
|
||||
return tool({
|
||||
description: "Force kill a teammate - remove from team config and delete inbox without graceful shutdown.",
|
||||
args: {
|
||||
team_name: tool.schema.string().describe("Team name"),
|
||||
teammate_name: tool.schema.string().describe("Teammate name to kill"),
|
||||
},
|
||||
execute: async (args: Record<string, unknown>): Promise<string> => {
|
||||
try {
|
||||
const input = ForceKillTeammateInputSchema.parse(args)
|
||||
|
||||
const config = readTeamConfig(input.team_name)
|
||||
if (!config) {
|
||||
return JSON.stringify({ error: "team_not_found" })
|
||||
}
|
||||
|
||||
const teammate = getTeamMember(config, input.teammate_name)
|
||||
if (!teammate) {
|
||||
return JSON.stringify({ error: "teammate_not_found" })
|
||||
}
|
||||
|
||||
if (input.teammate_name === "team-lead") {
|
||||
return JSON.stringify({ error: "cannot_remove_team_lead" })
|
||||
}
|
||||
|
||||
if (!isTeammateMember(teammate)) {
|
||||
return JSON.stringify({ error: "not_a_teammate" })
|
||||
}
|
||||
|
||||
updateTeamConfig(input.team_name, (config) => removeTeammate(config, input.teammate_name))
|
||||
deleteInbox(input.team_name, input.teammate_name)
|
||||
|
||||
return JSON.stringify({
|
||||
killed: true,
|
||||
teammate_name: input.teammate_name,
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.message === "cannot_remove_team_lead") {
|
||||
return JSON.stringify({ error: "cannot_remove_team_lead" })
|
||||
}
|
||||
return JSON.stringify({ error: error.message })
|
||||
}
|
||||
return JSON.stringify({ error: "force_kill_failed" })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function createProcessShutdownApprovedTool() {
|
||||
return tool({
|
||||
description:
|
||||
"Process approved teammate shutdown - remove from team config and delete inbox gracefully.",
|
||||
args: {
|
||||
team_name: tool.schema.string().describe("Team name"),
|
||||
teammate_name: tool.schema.string().describe("Teammate name to shutdown"),
|
||||
},
|
||||
execute: async (args: Record<string, unknown>): Promise<string> => {
|
||||
try {
|
||||
const input = ProcessShutdownApprovedInputSchema.parse(args)
|
||||
|
||||
const config = readTeamConfig(input.team_name)
|
||||
if (!config) {
|
||||
return JSON.stringify({ error: "team_not_found" })
|
||||
}
|
||||
|
||||
const teammate = getTeamMember(config, input.teammate_name)
|
||||
if (!teammate) {
|
||||
return JSON.stringify({ error: "teammate_not_found" })
|
||||
}
|
||||
|
||||
if (input.teammate_name === "team-lead") {
|
||||
return JSON.stringify({ error: "cannot_remove_team_lead" })
|
||||
}
|
||||
|
||||
if (!isTeammateMember(teammate)) {
|
||||
return JSON.stringify({ error: "not_a_teammate" })
|
||||
}
|
||||
|
||||
updateTeamConfig(input.team_name, (config) => removeTeammate(config, input.teammate_name))
|
||||
deleteInbox(input.team_name, input.teammate_name)
|
||||
|
||||
return JSON.stringify({
|
||||
shutdown_processed: true,
|
||||
teammate_name: input.teammate_name,
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.message === "cannot_remove_team_lead") {
|
||||
return JSON.stringify({ error: "cannot_remove_team_lead" })
|
||||
}
|
||||
return JSON.stringify({ error: error.message })
|
||||
}
|
||||
return JSON.stringify({ error: "shutdown_processing_failed" })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
36
src/tools/agent-teams/teammate-parent-context.test.ts
Normal file
36
src/tools/agent-teams/teammate-parent-context.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/// <reference types="bun-types" />
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { buildTeamParentToolContext } from "./teammate-parent-context"
|
||||
|
||||
describe("agent-teams teammate parent context", () => {
|
||||
test("forwards incoming abort signal to parent context resolver", () => {
|
||||
//#given
|
||||
const abortSignal = new AbortController().signal
|
||||
|
||||
//#when
|
||||
const parentToolContext = buildTeamParentToolContext({
|
||||
sessionID: "ses-main",
|
||||
messageID: "msg-main",
|
||||
agent: "sisyphus",
|
||||
abort: abortSignal,
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(parentToolContext.abort).toBe(abortSignal)
|
||||
expect(parentToolContext.sessionID).toBe("ses-main")
|
||||
expect(parentToolContext.messageID).toBe("msg-main")
|
||||
expect(parentToolContext.agent).toBe("sisyphus")
|
||||
})
|
||||
|
||||
test("leaves agent undefined if missing in tool context", () => {
|
||||
//#when
|
||||
const parentToolContext = buildTeamParentToolContext({
|
||||
sessionID: "ses-main",
|
||||
messageID: "msg-main",
|
||||
abort: new AbortController().signal,
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(parentToolContext.agent).toBeUndefined()
|
||||
})
|
||||
})
|
||||
17
src/tools/agent-teams/teammate-parent-context.ts
Normal file
17
src/tools/agent-teams/teammate-parent-context.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { ParentContext } from "../delegate-task/executor"
|
||||
import { resolveParentContext } from "../delegate-task/executor"
|
||||
import type { ToolContextWithMetadata } from "../delegate-task/types"
|
||||
import type { TeamToolContext } from "./types"
|
||||
|
||||
export function buildTeamParentToolContext(context: TeamToolContext): ToolContextWithMetadata {
|
||||
return {
|
||||
sessionID: context.sessionID,
|
||||
messageID: context.messageID,
|
||||
agent: context.agent,
|
||||
abort: context.abort ?? new AbortController().signal,
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveTeamParentContext(context: TeamToolContext): ParentContext {
|
||||
return resolveParentContext(buildTeamParentToolContext(context))
|
||||
}
|
||||
28
src/tools/agent-teams/teammate-prompts.ts
Normal file
28
src/tools/agent-teams/teammate-prompts.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export function buildLaunchPrompt(
|
||||
teamName: string,
|
||||
teammateName: string,
|
||||
userPrompt: string,
|
||||
categoryPromptAppend?: string,
|
||||
): string {
|
||||
const sections = [
|
||||
`You are teammate "${teammateName}" in team "${teamName}".`,
|
||||
`When you need updates, call read_inbox with team_name="${teamName}" and agent_name="${teammateName}".`,
|
||||
"Initial assignment:",
|
||||
userPrompt,
|
||||
]
|
||||
|
||||
if (categoryPromptAppend) {
|
||||
sections.push("Category guidance:", categoryPromptAppend)
|
||||
}
|
||||
|
||||
return sections.join("\n\n")
|
||||
}
|
||||
|
||||
export function buildDeliveryPrompt(teamName: string, teammateName: string, summary: string, content: string): string {
|
||||
return [
|
||||
`New team message for "${teammateName}" in team "${teamName}".`,
|
||||
`Summary: ${summary}`,
|
||||
"Content:",
|
||||
content,
|
||||
].join("\n\n")
|
||||
}
|
||||
197
src/tools/agent-teams/teammate-runtime.ts
Normal file
197
src/tools/agent-teams/teammate-runtime.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import type { BackgroundManager } from "../../features/background-agent"
|
||||
import { clearInbox, ensureInbox, sendPlainInboxMessage } from "./inbox-store"
|
||||
import { assignNextColor, getTeamMember, removeTeammate, updateTeamConfig, upsertTeammate } from "./team-config-store"
|
||||
import type { TeamTeammateMember, TeamToolContext } from "./types"
|
||||
import { resolveTeamParentContext } from "./teammate-parent-context"
|
||||
import { buildDeliveryPrompt, buildLaunchPrompt } from "./teammate-prompts"
|
||||
import { resolveSpawnExecution, type TeamCategoryContext } from "./teammate-spawn-execution"
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function resolveLaunchFailureMessage(status: string | undefined, error: string | undefined): string {
|
||||
if (status === "error") {
|
||||
return error ? `teammate_launch_failed:${error}` : "teammate_launch_failed"
|
||||
}
|
||||
|
||||
if (status === "cancelled") {
|
||||
return "teammate_launch_cancelled"
|
||||
}
|
||||
|
||||
return "teammate_launch_timeout"
|
||||
}
|
||||
|
||||
export interface SpawnTeammateParams {
|
||||
teamName: string
|
||||
name: string
|
||||
prompt: string
|
||||
category: string
|
||||
subagentType: string
|
||||
model?: string
|
||||
planModeRequired: boolean
|
||||
context: TeamToolContext
|
||||
manager: BackgroundManager
|
||||
categoryContext?: TeamCategoryContext
|
||||
}
|
||||
|
||||
export async function spawnTeammate(params: SpawnTeammateParams): Promise<TeamTeammateMember> {
|
||||
const parentContext = resolveTeamParentContext(params.context)
|
||||
const execution = await resolveSpawnExecution(
|
||||
{
|
||||
teamName: params.teamName,
|
||||
name: params.name,
|
||||
prompt: params.prompt,
|
||||
category: params.category,
|
||||
subagentType: params.subagentType,
|
||||
model: params.model,
|
||||
manager: params.manager,
|
||||
categoryContext: params.categoryContext,
|
||||
},
|
||||
parentContext,
|
||||
)
|
||||
|
||||
let teammate: TeamTeammateMember | undefined
|
||||
let launchedTaskID: string | undefined
|
||||
|
||||
updateTeamConfig(params.teamName, (current) => {
|
||||
if (getTeamMember(current, params.name)) {
|
||||
throw new Error("teammate_already_exists")
|
||||
}
|
||||
|
||||
teammate = {
|
||||
agentId: `${params.name}@${params.teamName}`,
|
||||
name: params.name,
|
||||
agentType: "teammate",
|
||||
category: params.category,
|
||||
model: execution.teammateModel,
|
||||
prompt: params.prompt,
|
||||
color: assignNextColor(current),
|
||||
planModeRequired: params.planModeRequired,
|
||||
joinedAt: new Date().toISOString(),
|
||||
cwd: process.cwd(),
|
||||
subscriptions: [],
|
||||
backendType: "native",
|
||||
isActive: false,
|
||||
}
|
||||
|
||||
return upsertTeammate(current, teammate)
|
||||
})
|
||||
|
||||
if (!teammate) {
|
||||
throw new Error("teammate_create_failed")
|
||||
}
|
||||
|
||||
try {
|
||||
ensureInbox(params.teamName, params.name)
|
||||
sendPlainInboxMessage(params.teamName, "team-lead", params.name, params.prompt, "initial_prompt", teammate.color)
|
||||
|
||||
const launched = await params.manager.launch({
|
||||
description: `[team:${params.teamName}] ${params.name}`,
|
||||
prompt: buildLaunchPrompt(params.teamName, params.name, params.prompt, execution.categoryPromptAppend),
|
||||
agent: execution.agentType,
|
||||
parentSessionID: parentContext.sessionID,
|
||||
parentMessageID: parentContext.messageID,
|
||||
parentModel: parentContext.model,
|
||||
...(execution.launchModel ? { model: execution.launchModel } : {}),
|
||||
...(params.category ? { category: params.category } : {}),
|
||||
parentAgent: parentContext.agent,
|
||||
})
|
||||
launchedTaskID = launched.id
|
||||
|
||||
const start = Date.now()
|
||||
let sessionID = launched.sessionID
|
||||
let latestStatus: string | undefined
|
||||
let latestError: string | undefined
|
||||
while (!sessionID && Date.now() - start < 30_000) {
|
||||
await delay(50)
|
||||
const task = params.manager.getTask(launched.id)
|
||||
latestStatus = task?.status
|
||||
latestError = task?.error
|
||||
if (task?.status === "error" || task?.status === "cancelled") {
|
||||
throw new Error(resolveLaunchFailureMessage(task.status, task.error))
|
||||
}
|
||||
sessionID = task?.sessionID
|
||||
}
|
||||
|
||||
if (!sessionID) {
|
||||
throw new Error(resolveLaunchFailureMessage(latestStatus, latestError))
|
||||
}
|
||||
|
||||
const nextMember: TeamTeammateMember = {
|
||||
...teammate,
|
||||
isActive: true,
|
||||
backgroundTaskID: launched.id,
|
||||
sessionID,
|
||||
}
|
||||
|
||||
updateTeamConfig(params.teamName, (current) => upsertTeammate(current, nextMember))
|
||||
return nextMember
|
||||
} catch (error) {
|
||||
const originalError = error
|
||||
|
||||
if (launchedTaskID) {
|
||||
await params.manager
|
||||
.cancelTask(launchedTaskID, {
|
||||
source: "team_launch_failed",
|
||||
abortSession: true,
|
||||
skipNotification: true,
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
export async function resumeTeammateWithMessage(
|
||||
manager: BackgroundManager,
|
||||
context: TeamToolContext,
|
||||
teamName: string,
|
||||
teammate: TeamTeammateMember,
|
||||
summary: string,
|
||||
content: string,
|
||||
): Promise<void> {
|
||||
if (!teammate.sessionID) {
|
||||
return
|
||||
}
|
||||
|
||||
const parentContext = resolveTeamParentContext(context)
|
||||
|
||||
try {
|
||||
await manager.resume({
|
||||
sessionId: teammate.sessionID,
|
||||
prompt: buildDeliveryPrompt(teamName, teammate.name, summary, content),
|
||||
parentSessionID: parentContext.sessionID,
|
||||
parentMessageID: parentContext.messageID,
|
||||
parentModel: parentContext.model,
|
||||
parentAgent: parentContext.agent,
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
export async function cancelTeammateRun(manager: BackgroundManager, teammate: TeamTeammateMember): Promise<void> {
|
||||
if (!teammate.backgroundTaskID) {
|
||||
return
|
||||
}
|
||||
|
||||
await manager.cancelTask(teammate.backgroundTaskID, {
|
||||
source: "team_force_kill",
|
||||
abortSession: true,
|
||||
skipNotification: true,
|
||||
})
|
||||
}
|
||||
119
src/tools/agent-teams/teammate-spawn-execution.ts
Normal file
119
src/tools/agent-teams/teammate-spawn-execution.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { CategoriesConfig } from "../../config/schema"
|
||||
import type { BackgroundManager } from "../../features/background-agent"
|
||||
import type { ParentContext } from "../delegate-task/executor"
|
||||
import { resolveCategoryExecution } from "../delegate-task/executor"
|
||||
import type { DelegateTaskArgs } from "../delegate-task/types"
|
||||
|
||||
function parseModel(model: string | undefined): { providerID: string; modelID: string } | undefined {
|
||||
if (!model) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const separatorIndex = model.indexOf("/")
|
||||
if (separatorIndex <= 0 || separatorIndex >= model.length - 1) {
|
||||
throw new Error("invalid_model_override_format")
|
||||
}
|
||||
|
||||
return {
|
||||
providerID: model.slice(0, separatorIndex),
|
||||
modelID: model.slice(separatorIndex + 1),
|
||||
}
|
||||
}
|
||||
|
||||
async function getSystemDefaultModel(client: PluginInput["client"]): Promise<string | undefined> {
|
||||
try {
|
||||
const openCodeConfig = await client.config.get()
|
||||
return (openCodeConfig as { data?: { model?: string } })?.data?.model
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export interface TeamCategoryContext {
|
||||
client: PluginInput["client"]
|
||||
userCategories?: CategoriesConfig
|
||||
sisyphusJuniorModel?: string
|
||||
}
|
||||
|
||||
export interface SpawnExecutionRequest {
|
||||
teamName: string
|
||||
name: string
|
||||
prompt: string
|
||||
category: string
|
||||
subagentType: string
|
||||
model?: string
|
||||
manager: BackgroundManager
|
||||
categoryContext?: TeamCategoryContext
|
||||
}
|
||||
|
||||
export interface SpawnExecutionResult {
|
||||
agentType: string
|
||||
teammateModel: string
|
||||
launchModel?: { providerID: string; modelID: string; variant?: string }
|
||||
categoryPromptAppend?: string
|
||||
}
|
||||
|
||||
export async function resolveSpawnExecution(
|
||||
request: SpawnExecutionRequest,
|
||||
parentContext: ParentContext,
|
||||
): Promise<SpawnExecutionResult> {
|
||||
if (request.model) {
|
||||
const launchModel = parseModel(request.model)
|
||||
return {
|
||||
agentType: request.subagentType,
|
||||
teammateModel: request.model,
|
||||
...(launchModel ? { launchModel } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
if (!request.categoryContext?.client) {
|
||||
return {
|
||||
agentType: request.subagentType,
|
||||
teammateModel: "native",
|
||||
}
|
||||
}
|
||||
|
||||
const inheritedModel = parentContext.model
|
||||
? `${parentContext.model.providerID}/${parentContext.model.modelID}`
|
||||
: undefined
|
||||
|
||||
const systemDefaultModel = await getSystemDefaultModel(request.categoryContext.client)
|
||||
|
||||
const delegateArgs: DelegateTaskArgs = {
|
||||
description: `[team:${request.teamName}] ${request.name}`,
|
||||
prompt: request.prompt,
|
||||
category: request.category,
|
||||
subagent_type: "sisyphus-junior",
|
||||
run_in_background: true,
|
||||
load_skills: [],
|
||||
}
|
||||
|
||||
const resolution = await resolveCategoryExecution(
|
||||
delegateArgs,
|
||||
{
|
||||
manager: request.manager,
|
||||
client: request.categoryContext.client,
|
||||
directory: process.cwd(),
|
||||
userCategories: request.categoryContext.userCategories,
|
||||
sisyphusJuniorModel: request.categoryContext.sisyphusJuniorModel,
|
||||
},
|
||||
inheritedModel,
|
||||
systemDefaultModel,
|
||||
)
|
||||
|
||||
if (resolution.error) {
|
||||
throw new Error(resolution.error)
|
||||
}
|
||||
|
||||
if (!resolution.categoryModel) {
|
||||
throw new Error("category_model_not_resolved")
|
||||
}
|
||||
|
||||
return {
|
||||
agentType: resolution.agentToUse,
|
||||
teammateModel: `${resolution.categoryModel.providerID}/${resolution.categoryModel.modelID}`,
|
||||
launchModel: resolution.categoryModel,
|
||||
categoryPromptAppend: resolution.categoryPromptAppend,
|
||||
}
|
||||
}
|
||||
97
src/tools/agent-teams/teammate-tools.test.ts
Normal file
97
src/tools/agent-teams/teammate-tools.test.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/// <reference types="bun-types" />
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
||||
import { mkdtempSync, rmSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import type { BackgroundManager } from "../../features/background-agent"
|
||||
import { createAgentTeamsTools } from "./tools"
|
||||
|
||||
interface TestToolContext {
|
||||
sessionID: string
|
||||
messageID: string
|
||||
agent: string
|
||||
abort: AbortSignal
|
||||
}
|
||||
|
||||
interface MockManagerHandles {
|
||||
manager: BackgroundManager
|
||||
launchCalls: Array<Record<string, unknown>>
|
||||
}
|
||||
|
||||
function createMockManager(): MockManagerHandles {
|
||||
const launchCalls: Array<Record<string, unknown>> = []
|
||||
|
||||
const manager = {
|
||||
launch: async (args: Record<string, unknown>) => {
|
||||
launchCalls.push(args)
|
||||
return { id: `bg-${launchCalls.length}`, sessionID: `ses-worker-${launchCalls.length}` }
|
||||
},
|
||||
getTask: () => undefined,
|
||||
resume: async () => ({ id: "resume-1" }),
|
||||
cancelTask: async () => true,
|
||||
} as unknown as BackgroundManager
|
||||
|
||||
return { manager, launchCalls }
|
||||
}
|
||||
|
||||
function createContext(sessionID = "ses-main"): TestToolContext {
|
||||
return {
|
||||
sessionID,
|
||||
messageID: "msg-main",
|
||||
agent: "sisyphus",
|
||||
abort: new AbortController().signal,
|
||||
}
|
||||
}
|
||||
|
||||
async function executeJsonTool(
|
||||
tools: ReturnType<typeof createAgentTeamsTools>,
|
||||
toolName: keyof ReturnType<typeof createAgentTeamsTools>,
|
||||
args: Record<string, unknown>,
|
||||
context: TestToolContext,
|
||||
): Promise<unknown> {
|
||||
const output = await tools[toolName].execute(args, context)
|
||||
return JSON.parse(output)
|
||||
}
|
||||
|
||||
describe("agent-teams teammate tools", () => {
|
||||
let originalCwd: string
|
||||
let tempProjectDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
originalCwd = process.cwd()
|
||||
tempProjectDir = mkdtempSync(join(tmpdir(), "agent-teams-teammate-tools-"))
|
||||
process.chdir(tempProjectDir)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(originalCwd)
|
||||
rmSync(tempProjectDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test("spawn_teammate requires lead session authorization", async () => {
|
||||
//#given
|
||||
const { manager, launchCalls } = createMockManager()
|
||||
const tools = createAgentTeamsTools(manager)
|
||||
const leadContext = createContext("ses-lead")
|
||||
const teammateContext = createContext("ses-worker")
|
||||
|
||||
await executeJsonTool(tools, "team_create", { team_name: "core" }, leadContext)
|
||||
|
||||
//#when
|
||||
const unauthorized = await executeJsonTool(
|
||||
tools,
|
||||
"spawn_teammate",
|
||||
{
|
||||
team_name: "core",
|
||||
name: "worker_1",
|
||||
prompt: "Handle release prep",
|
||||
category: "quick",
|
||||
},
|
||||
teammateContext,
|
||||
) as { error?: string }
|
||||
|
||||
//#then
|
||||
expect(unauthorized.error).toBe("unauthorized_lead_session")
|
||||
expect(launchCalls).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
198
src/tools/agent-teams/teammate-tools.ts
Normal file
198
src/tools/agent-teams/teammate-tools.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { CategoriesConfig } from "../../config/schema"
|
||||
import type { BackgroundManager } from "../../features/background-agent"
|
||||
import { clearInbox } from "./inbox-store"
|
||||
import { validateAgentName, validateTeamName } from "./name-validation"
|
||||
import {
|
||||
TeamForceKillInputSchema,
|
||||
TeamProcessShutdownInputSchema,
|
||||
TeamSpawnInputSchema,
|
||||
TeamToolContext,
|
||||
isTeammateMember,
|
||||
} from "./types"
|
||||
import { getTeamMember, readTeamConfigOrThrow, removeTeammate, updateTeamConfig } from "./team-config-store"
|
||||
import { cancelTeammateRun, spawnTeammate } from "./teammate-runtime"
|
||||
import { resetOwnerTasks } from "./team-task-store"
|
||||
|
||||
export interface AgentTeamsSpawnOptions {
|
||||
client?: PluginInput["client"]
|
||||
userCategories?: CategoriesConfig
|
||||
sisyphusJuniorModel?: string
|
||||
}
|
||||
|
||||
async function shutdownTeammateWithCleanup(
|
||||
manager: BackgroundManager,
|
||||
context: TeamToolContext,
|
||||
teamName: string,
|
||||
agentName: string,
|
||||
): Promise<string | null> {
|
||||
const config = readTeamConfigOrThrow(teamName)
|
||||
if (context.sessionID !== config.leadSessionId) {
|
||||
return "unauthorized_lead_session"
|
||||
}
|
||||
|
||||
const member = getTeamMember(config, agentName)
|
||||
if (!member || !isTeammateMember(member)) {
|
||||
return "teammate_not_found"
|
||||
}
|
||||
|
||||
await cancelTeammateRun(manager, member)
|
||||
let removed = false
|
||||
|
||||
updateTeamConfig(teamName, (current) => {
|
||||
const refreshedMember = getTeamMember(current, agentName)
|
||||
if (!refreshedMember || !isTeammateMember(refreshedMember)) {
|
||||
return current
|
||||
}
|
||||
removed = true
|
||||
return removeTeammate(current, agentName)
|
||||
})
|
||||
|
||||
if (removed) {
|
||||
clearInbox(teamName, agentName)
|
||||
}
|
||||
|
||||
resetOwnerTasks(teamName, agentName)
|
||||
return null
|
||||
}
|
||||
|
||||
export function createSpawnTeammateTool(manager: BackgroundManager, options?: AgentTeamsSpawnOptions): ToolDefinition {
|
||||
return tool({
|
||||
description: "Spawn a teammate using native internal agent execution.",
|
||||
args: {
|
||||
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().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"),
|
||||
},
|
||||
execute: async (args: Record<string, unknown>, context: TeamToolContext): Promise<string> => {
|
||||
try {
|
||||
const input = TeamSpawnInputSchema.parse(args)
|
||||
const teamError = validateTeamName(input.team_name)
|
||||
if (teamError) {
|
||||
return JSON.stringify({ error: teamError })
|
||||
}
|
||||
|
||||
const agentError = validateAgentName(input.name)
|
||||
if (agentError) {
|
||||
return JSON.stringify({ error: agentError })
|
||||
}
|
||||
|
||||
if (!input.category.trim()) {
|
||||
return JSON.stringify({ error: "category_required" })
|
||||
}
|
||||
|
||||
if (input.subagent_type && input.subagent_type !== "sisyphus-junior") {
|
||||
return JSON.stringify({ error: "category_conflicts_with_subagent_type" })
|
||||
}
|
||||
|
||||
const config = readTeamConfigOrThrow(input.team_name)
|
||||
if (context.sessionID !== config.leadSessionId) {
|
||||
return JSON.stringify({ error: "unauthorized_lead_session" })
|
||||
}
|
||||
|
||||
const resolvedSubagentType = input.subagent_type ?? "sisyphus-junior"
|
||||
|
||||
const teammate = await spawnTeammate({
|
||||
teamName: input.team_name,
|
||||
name: input.name,
|
||||
prompt: input.prompt,
|
||||
category: input.category,
|
||||
subagentType: resolvedSubagentType,
|
||||
model: input.model,
|
||||
planModeRequired: input.plan_mode_required ?? false,
|
||||
context,
|
||||
manager,
|
||||
categoryContext: options?.client
|
||||
? {
|
||||
client: options.client,
|
||||
userCategories: options.userCategories,
|
||||
sisyphusJuniorModel: options.sisyphusJuniorModel,
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
|
||||
return JSON.stringify({
|
||||
agent_id: teammate.agentId,
|
||||
name: teammate.name,
|
||||
team_name: input.team_name,
|
||||
session_id: teammate.sessionID,
|
||||
task_id: teammate.backgroundTaskID,
|
||||
})
|
||||
} catch (error) {
|
||||
return JSON.stringify({ error: error instanceof Error ? error.message : "spawn_teammate_failed" })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function createForceKillTeammateTool(manager: BackgroundManager): ToolDefinition {
|
||||
return tool({
|
||||
description: "Force stop a teammate and clean up ownership state.",
|
||||
args: {
|
||||
team_name: tool.schema.string().describe("Team name"),
|
||||
teammate_name: tool.schema.string().describe("Teammate name"),
|
||||
},
|
||||
execute: async (args: Record<string, unknown>, context: TeamToolContext): Promise<string> => {
|
||||
try {
|
||||
const input = TeamForceKillInputSchema.parse(args)
|
||||
const teamError = validateTeamName(input.team_name)
|
||||
if (teamError) {
|
||||
return JSON.stringify({ error: teamError })
|
||||
}
|
||||
const agentError = validateAgentName(input.teammate_name)
|
||||
if (agentError) {
|
||||
return JSON.stringify({ error: agentError })
|
||||
}
|
||||
|
||||
const shutdownError = await shutdownTeammateWithCleanup(manager, context, input.team_name, input.teammate_name)
|
||||
if (shutdownError) {
|
||||
return JSON.stringify({ error: shutdownError })
|
||||
}
|
||||
|
||||
return JSON.stringify({ success: true, message: `${input.teammate_name} stopped` })
|
||||
} catch (error) {
|
||||
return JSON.stringify({ error: error instanceof Error ? error.message : "force_kill_teammate_failed" })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function createProcessShutdownTool(manager: BackgroundManager): ToolDefinition {
|
||||
return tool({
|
||||
description: "Finalize an approved shutdown by removing teammate and resetting owned tasks.",
|
||||
args: {
|
||||
team_name: tool.schema.string().describe("Team name"),
|
||||
teammate_name: tool.schema.string().describe("Teammate name"),
|
||||
},
|
||||
execute: async (args: Record<string, unknown>, context: TeamToolContext): Promise<string> => {
|
||||
try {
|
||||
const input = TeamProcessShutdownInputSchema.parse(args)
|
||||
const teamError = validateTeamName(input.team_name)
|
||||
if (teamError) {
|
||||
return JSON.stringify({ error: teamError })
|
||||
}
|
||||
if (input.teammate_name === "team-lead") {
|
||||
return JSON.stringify({ error: "cannot_shutdown_team_lead" })
|
||||
}
|
||||
const agentError = validateAgentName(input.teammate_name)
|
||||
if (agentError) {
|
||||
return JSON.stringify({ error: agentError })
|
||||
}
|
||||
|
||||
const shutdownError = await shutdownTeammateWithCleanup(manager, context, input.team_name, input.teammate_name)
|
||||
if (shutdownError) {
|
||||
return JSON.stringify({ error: shutdownError })
|
||||
}
|
||||
|
||||
return JSON.stringify({ success: true, message: `${input.teammate_name} removed` })
|
||||
} catch (error) {
|
||||
return JSON.stringify({ error: error instanceof Error ? error.message : "process_shutdown_failed" })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
1430
src/tools/agent-teams/tools.functional.test.ts
Normal file
1430
src/tools/agent-teams/tools.functional.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
28
src/tools/agent-teams/tools.ts
Normal file
28
src/tools/agent-teams/tools.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { ToolDefinition } from "@opencode-ai/plugin"
|
||||
import type { BackgroundManager } from "../../features/background-agent"
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { CategoriesConfig } from "../../config/schema"
|
||||
import { createReadInboxTool, createSendMessageTool } from "./messaging-tools"
|
||||
import { createTeamCreateTool, createTeamDeleteTool, createTeamReadConfigTool } from "./team-lifecycle-tools"
|
||||
import { createForceKillTeammateTool, createProcessShutdownApprovedTool } from "./teammate-control-tools"
|
||||
|
||||
export interface AgentTeamsToolOptions {
|
||||
client?: PluginInput["client"]
|
||||
userCategories?: CategoriesConfig
|
||||
sisyphusJuniorModel?: string
|
||||
}
|
||||
|
||||
export function createAgentTeamsTools(
|
||||
_manager: BackgroundManager,
|
||||
_options?: AgentTeamsToolOptions,
|
||||
): Record<string, ToolDefinition> {
|
||||
return {
|
||||
team_create: createTeamCreateTool(),
|
||||
team_delete: createTeamDeleteTool(),
|
||||
send_message: createSendMessageTool(_manager),
|
||||
read_inbox: createReadInboxTool(),
|
||||
read_config: createTeamReadConfigTool(),
|
||||
force_kill_teammate: createForceKillTeammateTool(),
|
||||
process_shutdown_approved: createProcessShutdownApprovedTool(),
|
||||
}
|
||||
}
|
||||
721
src/tools/agent-teams/types.test.ts
Normal file
721
src/tools/agent-teams/types.test.ts
Normal file
@@ -0,0 +1,721 @@
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import { z } from "zod"
|
||||
import {
|
||||
TeamConfigSchema,
|
||||
TeamMemberSchema,
|
||||
TeamTeammateMemberSchema,
|
||||
MessageTypeSchema,
|
||||
InboxMessageSchema,
|
||||
TeamTaskSchema,
|
||||
TeamCreateInputSchema,
|
||||
TeamDeleteInputSchema,
|
||||
SendMessageInputSchema,
|
||||
ReadInboxInputSchema,
|
||||
ReadConfigInputSchema,
|
||||
TeamSpawnInputSchema,
|
||||
ForceKillTeammateInputSchema,
|
||||
ProcessShutdownApprovedInputSchema,
|
||||
} from "./types"
|
||||
|
||||
describe("TeamConfigSchema", () => {
|
||||
it("validates a complete team config", () => {
|
||||
// given
|
||||
const validConfig = {
|
||||
name: "my-team",
|
||||
description: "A test team",
|
||||
createdAt: "2026-02-11T10:00:00Z",
|
||||
leadAgentId: "agent-123",
|
||||
leadSessionId: "ses-456",
|
||||
members: [
|
||||
{
|
||||
agentId: "agent-123",
|
||||
name: "Lead Agent",
|
||||
agentType: "lead",
|
||||
color: "blue",
|
||||
},
|
||||
{
|
||||
agentId: "agent-789",
|
||||
name: "Worker 1",
|
||||
agentType: "teammate",
|
||||
color: "green",
|
||||
category: "quick",
|
||||
model: "claude-sonnet-4-5",
|
||||
prompt: "You are a helpful assistant",
|
||||
planModeRequired: false,
|
||||
joinedAt: "2026-02-11T10:05:00Z",
|
||||
cwd: "/tmp",
|
||||
subscriptions: ["task-updates"],
|
||||
backendType: "native" as const,
|
||||
isActive: true,
|
||||
sessionID: "ses-789",
|
||||
backgroundTaskID: "task-123",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
// when
|
||||
const result = TeamConfigSchema.safeParse(validConfig)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it("rejects invalid team config", () => {
|
||||
// given
|
||||
const invalidConfig = {
|
||||
name: "",
|
||||
description: "A test team",
|
||||
createdAt: "invalid-date",
|
||||
leadAgentId: "",
|
||||
leadSessionId: "ses-456",
|
||||
members: [],
|
||||
}
|
||||
|
||||
// when
|
||||
const result = TeamConfigSchema.safeParse(invalidConfig)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("TeamMemberSchema", () => {
|
||||
it("validates a lead member", () => {
|
||||
// given
|
||||
const leadMember = {
|
||||
agentId: "agent-123",
|
||||
name: "Lead Agent",
|
||||
agentType: "lead",
|
||||
color: "blue",
|
||||
}
|
||||
|
||||
// when
|
||||
const result = TeamMemberSchema.safeParse(leadMember)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it("rejects invalid member", () => {
|
||||
// given
|
||||
const invalidMember = {
|
||||
agentId: "",
|
||||
name: "",
|
||||
agentType: "invalid",
|
||||
color: "invalid",
|
||||
}
|
||||
|
||||
// when
|
||||
const result = TeamMemberSchema.safeParse(invalidMember)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("TeamTeammateMemberSchema", () => {
|
||||
it("validates a complete teammate member", () => {
|
||||
// given
|
||||
const teammateMember = {
|
||||
agentId: "agent-789",
|
||||
name: "Worker 1",
|
||||
agentType: "teammate",
|
||||
color: "green",
|
||||
category: "quick",
|
||||
model: "claude-sonnet-4-5",
|
||||
prompt: "You are a helpful assistant",
|
||||
planModeRequired: false,
|
||||
joinedAt: "2026-02-11T10:05:00Z",
|
||||
cwd: "/tmp",
|
||||
subscriptions: ["task-updates"],
|
||||
backendType: "native" as const,
|
||||
isActive: true,
|
||||
sessionID: "ses-789",
|
||||
backgroundTaskID: "task-123",
|
||||
}
|
||||
|
||||
// when
|
||||
const result = TeamTeammateMemberSchema.safeParse(teammateMember)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it("validates teammate member with optional fields missing", () => {
|
||||
// given
|
||||
const minimalTeammate = {
|
||||
agentId: "agent-789",
|
||||
name: "Worker 1",
|
||||
agentType: "teammate",
|
||||
color: "green",
|
||||
category: "quick",
|
||||
model: "claude-sonnet-4-5",
|
||||
prompt: "You are a helpful assistant",
|
||||
planModeRequired: false,
|
||||
joinedAt: "2026-02-11T10:05:00Z",
|
||||
cwd: "/tmp",
|
||||
subscriptions: [],
|
||||
backendType: "native" as const,
|
||||
isActive: true,
|
||||
}
|
||||
|
||||
// when
|
||||
const result = TeamTeammateMemberSchema.safeParse(minimalTeammate)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it("rejects invalid teammate member", () => {
|
||||
// given
|
||||
const invalidTeammate = {
|
||||
agentId: "",
|
||||
name: "Worker 1",
|
||||
agentType: "teammate",
|
||||
color: "green",
|
||||
category: "quick",
|
||||
model: "claude-sonnet-4-5",
|
||||
prompt: "You are a helpful assistant",
|
||||
planModeRequired: false,
|
||||
joinedAt: "invalid-date",
|
||||
cwd: "/tmp",
|
||||
subscriptions: [],
|
||||
backendType: "invalid" as const,
|
||||
isActive: true,
|
||||
}
|
||||
|
||||
// when
|
||||
const result = TeamTeammateMemberSchema.safeParse(invalidTeammate)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it("rejects reserved agentType for teammate schema", () => {
|
||||
// given
|
||||
const invalidTeammate = {
|
||||
agentId: "worker@team",
|
||||
name: "worker",
|
||||
agentType: "team-lead",
|
||||
category: "quick",
|
||||
model: "native",
|
||||
prompt: "do work",
|
||||
color: "blue",
|
||||
planModeRequired: false,
|
||||
joinedAt: "2026-02-11T10:05:00Z",
|
||||
cwd: "/tmp",
|
||||
subscriptions: [],
|
||||
backendType: "native",
|
||||
isActive: false,
|
||||
}
|
||||
|
||||
// when
|
||||
const result = TeamTeammateMemberSchema.safeParse(invalidTeammate)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("MessageTypeSchema", () => {
|
||||
it("validates all 5 message types", () => {
|
||||
// given
|
||||
const types = ["message", "broadcast", "shutdown_request", "shutdown_response", "plan_approval_response"]
|
||||
|
||||
// when & then
|
||||
types.forEach(type => {
|
||||
const result = MessageTypeSchema.safeParse(type)
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.data).toBe(type)
|
||||
})
|
||||
})
|
||||
|
||||
it("rejects invalid message type", () => {
|
||||
// given
|
||||
const invalidType = "invalid_type"
|
||||
|
||||
// when
|
||||
const result = MessageTypeSchema.safeParse(invalidType)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("InboxMessageSchema", () => {
|
||||
it("validates a complete inbox message", () => {
|
||||
// given
|
||||
const message = {
|
||||
id: "msg-123",
|
||||
type: "message" as const,
|
||||
sender: "agent-123",
|
||||
recipient: "agent-456",
|
||||
content: "Hello world",
|
||||
summary: "Greeting",
|
||||
timestamp: "2026-02-11T10:00:00Z",
|
||||
read: false,
|
||||
requestId: "req-123",
|
||||
approve: true,
|
||||
}
|
||||
|
||||
// when
|
||||
const result = InboxMessageSchema.safeParse(message)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it("validates message with optional fields missing", () => {
|
||||
// given
|
||||
const minimalMessage = {
|
||||
id: "msg-123",
|
||||
type: "broadcast" as const,
|
||||
sender: "agent-123",
|
||||
recipient: "agent-456",
|
||||
content: "Hello world",
|
||||
summary: "Greeting",
|
||||
timestamp: "2026-02-11T10:00:00Z",
|
||||
read: false,
|
||||
}
|
||||
|
||||
// when
|
||||
const result = InboxMessageSchema.safeParse(minimalMessage)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it("rejects invalid inbox message", () => {
|
||||
// given
|
||||
const invalidMessage = {
|
||||
id: "",
|
||||
type: "invalid" as const,
|
||||
sender: "",
|
||||
recipient: "",
|
||||
content: "",
|
||||
summary: "",
|
||||
timestamp: "invalid-date",
|
||||
read: "not-boolean",
|
||||
}
|
||||
|
||||
// when
|
||||
const result = InboxMessageSchema.safeParse(invalidMessage)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("TeamTaskSchema", () => {
|
||||
it("validates a task object", () => {
|
||||
// given
|
||||
const task = {
|
||||
id: "T-12345678-1234-1234-1234-123456789012",
|
||||
subject: "Implement feature",
|
||||
description: "Add new functionality",
|
||||
status: "pending" as const,
|
||||
activeForm: "Implementing feature",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
owner: "agent-123",
|
||||
metadata: { priority: "high" },
|
||||
repoURL: "https://github.com/user/repo",
|
||||
parentID: "T-parent",
|
||||
threadID: "thread-123",
|
||||
}
|
||||
|
||||
// when
|
||||
const result = TeamTaskSchema.safeParse(task)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it("rejects invalid task", () => {
|
||||
// given
|
||||
const invalidTask = {
|
||||
id: "invalid-id",
|
||||
subject: "",
|
||||
description: "Add new functionality",
|
||||
status: "invalid" as const,
|
||||
activeForm: "Implementing feature",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
}
|
||||
|
||||
// when
|
||||
const result = TeamTaskSchema.safeParse(invalidTask)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("TeamCreateInputSchema", () => {
|
||||
it("validates create input with description", () => {
|
||||
// given
|
||||
const input = {
|
||||
team_name: "my-team",
|
||||
description: "A test team",
|
||||
}
|
||||
|
||||
// when
|
||||
const result = TeamCreateInputSchema.safeParse(input)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it("validates create input without description", () => {
|
||||
// given
|
||||
const input = {
|
||||
team_name: "my-team",
|
||||
}
|
||||
|
||||
// when
|
||||
const result = TeamCreateInputSchema.safeParse(input)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it("rejects invalid create input", () => {
|
||||
// given
|
||||
const input = {
|
||||
team_name: "invalid team name with spaces and special chars!",
|
||||
}
|
||||
|
||||
// when
|
||||
const result = TeamCreateInputSchema.safeParse(input)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("TeamDeleteInputSchema", () => {
|
||||
it("validates delete input", () => {
|
||||
// given
|
||||
const input = {
|
||||
team_name: "my-team",
|
||||
}
|
||||
|
||||
// when
|
||||
const result = TeamDeleteInputSchema.safeParse(input)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it("rejects invalid delete input", () => {
|
||||
// given
|
||||
const input = {
|
||||
team_name: "",
|
||||
}
|
||||
|
||||
// when
|
||||
const result = TeamDeleteInputSchema.safeParse(input)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("SendMessageInputSchema", () => {
|
||||
it("validates message type input", () => {
|
||||
// given
|
||||
const input = {
|
||||
team_name: "my-team",
|
||||
type: "message" as const,
|
||||
recipient: "agent-456",
|
||||
content: "Hello world",
|
||||
summary: "Greeting",
|
||||
}
|
||||
|
||||
// when
|
||||
const result = SendMessageInputSchema.safeParse(input)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it("validates broadcast type input", () => {
|
||||
// given
|
||||
const input = {
|
||||
team_name: "my-team",
|
||||
type: "broadcast" as const,
|
||||
content: "Team announcement",
|
||||
summary: "Announcement",
|
||||
}
|
||||
|
||||
// when
|
||||
const result = SendMessageInputSchema.safeParse(input)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it("validates shutdown_request type input", () => {
|
||||
// given
|
||||
const input = {
|
||||
team_name: "my-team",
|
||||
type: "shutdown_request" as const,
|
||||
recipient: "agent-456",
|
||||
content: "Please shutdown",
|
||||
summary: "Shutdown request",
|
||||
}
|
||||
|
||||
// when
|
||||
const result = SendMessageInputSchema.safeParse(input)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it("validates shutdown_response type input", () => {
|
||||
// given
|
||||
const input = {
|
||||
team_name: "my-team",
|
||||
type: "shutdown_response" as const,
|
||||
request_id: "req-123",
|
||||
approve: true,
|
||||
}
|
||||
|
||||
// when
|
||||
const result = SendMessageInputSchema.safeParse(input)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it("validates plan_approval_response type input", () => {
|
||||
// given
|
||||
const input = {
|
||||
team_name: "my-team",
|
||||
type: "plan_approval_response" as const,
|
||||
request_id: "req-456",
|
||||
approve: false,
|
||||
}
|
||||
|
||||
// when
|
||||
const result = SendMessageInputSchema.safeParse(input)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it("rejects message type without recipient", () => {
|
||||
// given
|
||||
const input = {
|
||||
team_name: "my-team",
|
||||
type: "message" as const,
|
||||
content: "Hello world",
|
||||
summary: "Greeting",
|
||||
}
|
||||
|
||||
// when
|
||||
const result = SendMessageInputSchema.safeParse(input)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it("rejects shutdown_response without request_id", () => {
|
||||
// given
|
||||
const input = {
|
||||
team_name: "my-team",
|
||||
type: "shutdown_response" as const,
|
||||
approve: true,
|
||||
}
|
||||
|
||||
// when
|
||||
const result = SendMessageInputSchema.safeParse(input)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it("rejects invalid team_name", () => {
|
||||
// given
|
||||
const input = {
|
||||
team_name: "invalid team name",
|
||||
type: "broadcast" as const,
|
||||
content: "Hello",
|
||||
summary: "Greeting",
|
||||
}
|
||||
|
||||
// when
|
||||
const result = SendMessageInputSchema.safeParse(input)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("ReadInboxInputSchema", () => {
|
||||
it("validates read inbox input", () => {
|
||||
// given
|
||||
const input = {
|
||||
team_name: "my-team",
|
||||
agent_name: "worker-1",
|
||||
unread_only: true,
|
||||
mark_as_read: false,
|
||||
}
|
||||
|
||||
// when
|
||||
const result = ReadInboxInputSchema.safeParse(input)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it("validates minimal read inbox input", () => {
|
||||
// given
|
||||
const input = {
|
||||
team_name: "my-team",
|
||||
agent_name: "worker-1",
|
||||
}
|
||||
|
||||
// when
|
||||
const result = ReadInboxInputSchema.safeParse(input)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it("rejects invalid read inbox input", () => {
|
||||
// given
|
||||
const input = {
|
||||
team_name: "",
|
||||
agent_name: "",
|
||||
}
|
||||
|
||||
// when
|
||||
const result = ReadInboxInputSchema.safeParse(input)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("ReadConfigInputSchema", () => {
|
||||
it("validates read config input", () => {
|
||||
// given
|
||||
const input = {
|
||||
team_name: "my-team",
|
||||
}
|
||||
|
||||
// when
|
||||
const result = ReadConfigInputSchema.safeParse(input)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it("rejects invalid read config input", () => {
|
||||
// given
|
||||
const input = {
|
||||
team_name: "",
|
||||
}
|
||||
|
||||
// when
|
||||
const result = ReadConfigInputSchema.safeParse(input)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("TeamSpawnInputSchema", () => {
|
||||
it("validates spawn input", () => {
|
||||
// given
|
||||
const input = {
|
||||
team_name: "my-team",
|
||||
name: "worker-1",
|
||||
category: "quick",
|
||||
prompt: "You are a helpful assistant",
|
||||
}
|
||||
|
||||
// when
|
||||
const result = TeamSpawnInputSchema.safeParse(input)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it("rejects invalid spawn input", () => {
|
||||
// given
|
||||
const input = {
|
||||
team_name: "invalid team",
|
||||
name: "",
|
||||
category: "quick",
|
||||
prompt: "You are a helpful assistant",
|
||||
}
|
||||
|
||||
// when
|
||||
const result = TeamSpawnInputSchema.safeParse(input)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("ForceKillTeammateInputSchema", () => {
|
||||
it("validates force kill input", () => {
|
||||
// given
|
||||
const input = {
|
||||
team_name: "my-team",
|
||||
teammate_name: "worker-1",
|
||||
}
|
||||
|
||||
// when
|
||||
const result = ForceKillTeammateInputSchema.safeParse(input)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it("rejects invalid force kill input", () => {
|
||||
// given
|
||||
const input = {
|
||||
team_name: "",
|
||||
teammate_name: "",
|
||||
}
|
||||
|
||||
// when
|
||||
const result = ForceKillTeammateInputSchema.safeParse(input)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("ProcessShutdownApprovedInputSchema", () => {
|
||||
it("validates shutdown approved input", () => {
|
||||
// given
|
||||
const input = {
|
||||
team_name: "my-team",
|
||||
teammate_name: "worker-1",
|
||||
}
|
||||
|
||||
// when
|
||||
const result = ProcessShutdownApprovedInputSchema.safeParse(input)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it("rejects invalid shutdown approved input", () => {
|
||||
// given
|
||||
const input = {
|
||||
team_name: "",
|
||||
teammate_name: "",
|
||||
}
|
||||
|
||||
// when
|
||||
const result = ProcessShutdownApprovedInputSchema.safeParse(input)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
272
src/tools/agent-teams/types.ts
Normal file
272
src/tools/agent-teams/types.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
import { z } from "zod"
|
||||
import { TaskObjectSchema } from "../task/types"
|
||||
|
||||
// Team member schemas
|
||||
export const TeamMemberSchema = z.object({
|
||||
agentId: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
agentType: z.enum(["team-lead", "lead", "teammate"]),
|
||||
color: z.string().min(1),
|
||||
})
|
||||
|
||||
export type TeamMember = z.infer<typeof TeamMemberSchema>
|
||||
|
||||
export type TeamLeadMember = TeamMember & {
|
||||
agentType: "team-lead"
|
||||
model: string
|
||||
joinedAt: string
|
||||
cwd: string
|
||||
subscriptions: string[]
|
||||
}
|
||||
|
||||
export function isTeammateMember(member: TeamMember): member is TeamTeammateMember {
|
||||
return member.agentType === "teammate"
|
||||
}
|
||||
|
||||
export const TEAM_COLOR_PALETTE = [
|
||||
"#FF6B6B", // Red
|
||||
"#4ECDC4", // Teal
|
||||
"#45B7D1", // Blue
|
||||
"#96CEB4", // Sage
|
||||
"#FFEEAD", // Yellow
|
||||
"#FF9F43", // Orange
|
||||
"#6C5CE7", // Purple
|
||||
"#00CEC9", // Cyan
|
||||
"#F368E0", // Pink
|
||||
"#FD7272", // Coral
|
||||
] as const
|
||||
|
||||
export const TeamTeammateMemberSchema = TeamMemberSchema.extend({
|
||||
category: z.string().min(1),
|
||||
model: z.string().min(1),
|
||||
prompt: z.string().min(1),
|
||||
planModeRequired: z.boolean(),
|
||||
joinedAt: z.string().datetime(),
|
||||
cwd: z.string().min(1),
|
||||
subscriptions: z.array(z.string()),
|
||||
backendType: z.literal("native"),
|
||||
isActive: z.boolean(),
|
||||
sessionID: z.string().optional(),
|
||||
backgroundTaskID: z.string().optional(),
|
||||
}).refine(
|
||||
(data) => data.agentType === "teammate",
|
||||
"TeamTeammateMemberSchema requires agentType to be 'teammate'"
|
||||
).refine(
|
||||
(data) => data.agentType !== "team-lead",
|
||||
"agentType 'team-lead' is reserved and not allowed"
|
||||
)
|
||||
|
||||
export type TeamTeammateMember = z.infer<typeof TeamTeammateMemberSchema>
|
||||
|
||||
// Team config schema
|
||||
export const TeamConfigSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
createdAt: z.string().datetime(),
|
||||
leadAgentId: z.string().min(1),
|
||||
leadSessionId: z.string().min(1),
|
||||
members: z.array(z.union([TeamMemberSchema, TeamTeammateMemberSchema])),
|
||||
})
|
||||
|
||||
export type TeamConfig = z.infer<typeof TeamConfigSchema>
|
||||
|
||||
// Message schemas
|
||||
export const MessageTypeSchema = z.enum([
|
||||
"message",
|
||||
"broadcast",
|
||||
"shutdown_request",
|
||||
"shutdown_response",
|
||||
"plan_approval_response",
|
||||
])
|
||||
|
||||
export type MessageType = z.infer<typeof MessageTypeSchema>
|
||||
|
||||
export const InboxMessageSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
type: MessageTypeSchema,
|
||||
sender: z.string().min(1),
|
||||
recipient: z.string().min(1),
|
||||
content: z.string().optional(),
|
||||
summary: z.string().optional(),
|
||||
timestamp: z.string().datetime(),
|
||||
read: z.boolean(),
|
||||
requestId: z.string().optional(),
|
||||
approve: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export type InboxMessage = z.infer<typeof InboxMessageSchema>
|
||||
|
||||
// Task schema (reuse from task/types.ts)
|
||||
export const TeamTaskSchema = TaskObjectSchema
|
||||
|
||||
export type TeamTask = z.infer<typeof TeamTaskSchema>
|
||||
|
||||
export type TeamTaskStatus = "pending" | "in_progress" | "completed" | "deleted"
|
||||
|
||||
// Input schemas for tools
|
||||
export const TeamCreateInputSchema = z.object({
|
||||
team_name: z.string().regex(/^[A-Za-z0-9_-]+$/, "Team name must contain only letters, numbers, hyphens, and underscores").max(64),
|
||||
description: z.string().optional(),
|
||||
})
|
||||
|
||||
export type TeamCreateInput = z.infer<typeof TeamCreateInputSchema>
|
||||
|
||||
export const TeamDeleteInputSchema = z.object({
|
||||
team_name: z.string().regex(/^[A-Za-z0-9_-]+$/, "Team name must contain only letters, numbers, hyphens, and underscores").max(64),
|
||||
})
|
||||
|
||||
export type TeamDeleteInput = z.infer<typeof TeamDeleteInputSchema>
|
||||
|
||||
const teamNameField = z.string().regex(/^[A-Za-z0-9_-]+$/, "Team name must contain only letters, numbers, hyphens, and underscores").max(64)
|
||||
const senderField = z.string().optional()
|
||||
|
||||
export const SendMessageInputSchema = z.discriminatedUnion("type", [
|
||||
z.object({
|
||||
team_name: teamNameField,
|
||||
type: z.literal("message"),
|
||||
recipient: z.string().min(1),
|
||||
content: z.string().optional(),
|
||||
summary: z.string().optional(),
|
||||
sender: senderField,
|
||||
}),
|
||||
z.object({
|
||||
team_name: teamNameField,
|
||||
type: z.literal("broadcast"),
|
||||
content: z.string().optional(),
|
||||
summary: z.string().optional(),
|
||||
sender: senderField,
|
||||
}),
|
||||
z.object({
|
||||
team_name: teamNameField,
|
||||
type: z.literal("shutdown_request"),
|
||||
recipient: z.string().min(1),
|
||||
content: z.string().optional(),
|
||||
summary: z.string().optional(),
|
||||
sender: senderField,
|
||||
}),
|
||||
z.object({
|
||||
team_name: teamNameField,
|
||||
type: z.literal("shutdown_response"),
|
||||
request_id: z.string().min(1),
|
||||
approve: z.boolean(),
|
||||
content: z.string().optional(),
|
||||
sender: senderField,
|
||||
}),
|
||||
z.object({
|
||||
team_name: teamNameField,
|
||||
type: z.literal("plan_approval_response"),
|
||||
request_id: z.string().min(1),
|
||||
approve: z.boolean(),
|
||||
recipient: z.string().optional(),
|
||||
content: z.string().optional(),
|
||||
sender: senderField,
|
||||
}),
|
||||
])
|
||||
|
||||
export type SendMessageInput = z.infer<typeof SendMessageInputSchema>
|
||||
|
||||
export const ReadInboxInputSchema = z.object({
|
||||
team_name: z.string().regex(/^[A-Za-z0-9_-]+$/, "Team name must contain only letters, numbers, hyphens, and underscores").max(64),
|
||||
agent_name: z.string().min(1),
|
||||
unread_only: z.boolean().optional(),
|
||||
mark_as_read: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export type ReadInboxInput = z.infer<typeof ReadInboxInputSchema>
|
||||
|
||||
export const ReadConfigInputSchema = z.object({
|
||||
team_name: z.string().regex(/^[A-Za-z0-9_-]+$/, "Team name must contain only letters, numbers, hyphens, and underscores").max(64),
|
||||
})
|
||||
|
||||
export type ReadConfigInput = z.infer<typeof ReadConfigInputSchema>
|
||||
|
||||
export const TeamSpawnInputSchema = z.object({
|
||||
team_name: z.string().regex(/^[A-Za-z0-9_-]+$/, "Team name must contain only letters, numbers, hyphens, and underscores").max(64),
|
||||
name: z.string().min(1),
|
||||
category: z.string().min(1),
|
||||
prompt: z.string().min(1),
|
||||
subagent_type: z.string().optional(),
|
||||
model: z.string().optional(),
|
||||
plan_mode_required: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export type TeamSpawnInput = z.infer<typeof TeamSpawnInputSchema>
|
||||
|
||||
export const ForceKillTeammateInputSchema = z.object({
|
||||
team_name: z.string().regex(/^[A-Za-z0-9_-]+$/, "Team name must contain only letters, numbers, hyphens, and underscores").max(64),
|
||||
teammate_name: z.string().min(1),
|
||||
})
|
||||
|
||||
export type ForceKillTeammateInput = z.infer<typeof ForceKillTeammateInputSchema>
|
||||
|
||||
export type TeamToolContext = {
|
||||
sessionID: string
|
||||
messageID: string
|
||||
agent: string
|
||||
abort?: AbortSignal
|
||||
}
|
||||
|
||||
export const TeamSendMessageInputSchema = SendMessageInputSchema
|
||||
|
||||
export type TeamSendMessageInput = z.infer<typeof TeamSendMessageInputSchema>
|
||||
|
||||
export const TeamReadInboxInputSchema = ReadInboxInputSchema
|
||||
|
||||
export type TeamReadInboxInput = z.infer<typeof TeamReadInboxInputSchema>
|
||||
|
||||
export const TeamReadConfigInputSchema = ReadConfigInputSchema
|
||||
|
||||
export type TeamReadConfigInput = z.infer<typeof TeamReadConfigInputSchema>
|
||||
|
||||
export const ProcessShutdownApprovedInputSchema = z.object({
|
||||
team_name: z.string().regex(/^[A-Za-z0-9_-]+$/, "Team name must contain only letters, numbers, hyphens, and underscores").max(64),
|
||||
teammate_name: z.string().min(1),
|
||||
})
|
||||
|
||||
export type ProcessShutdownApprovedInput = z.infer<typeof ProcessShutdownApprovedInputSchema>
|
||||
|
||||
export const TeamForceKillInputSchema = ForceKillTeammateInputSchema
|
||||
|
||||
export type TeamForceKillInput = z.infer<typeof TeamForceKillInputSchema>
|
||||
|
||||
export const TeamProcessShutdownInputSchema = ProcessShutdownApprovedInputSchema
|
||||
|
||||
export type TeamProcessShutdownInput = z.infer<typeof TeamProcessShutdownInputSchema>
|
||||
|
||||
export const TeamTaskCreateInputSchema = z.object({
|
||||
team_name: teamNameField,
|
||||
subject: z.string().min(1),
|
||||
description: z.string(),
|
||||
active_form: z.string().optional(),
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
})
|
||||
|
||||
export type TeamTaskCreateInput = z.infer<typeof TeamTaskCreateInputSchema>
|
||||
|
||||
export const TeamTaskListInputSchema = z.object({
|
||||
team_name: teamNameField,
|
||||
})
|
||||
|
||||
export type TeamTaskListInput = z.infer<typeof TeamTaskListInputSchema>
|
||||
|
||||
export const TeamTaskGetInputSchema = z.object({
|
||||
team_name: teamNameField,
|
||||
task_id: z.string().min(1),
|
||||
})
|
||||
|
||||
export type TeamTaskGetInput = z.infer<typeof TeamTaskGetInputSchema>
|
||||
|
||||
export const TeamTaskUpdateInputSchema = z.object({
|
||||
team_name: teamNameField,
|
||||
task_id: z.string().min(1),
|
||||
status: z.enum(["pending", "in_progress", "completed", "deleted"]).optional(),
|
||||
owner: z.string().optional(),
|
||||
subject: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
active_form: z.string().optional(),
|
||||
add_blocks: z.array(z.string()).optional(),
|
||||
add_blocked_by: z.array(z.string()).optional(),
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
})
|
||||
|
||||
export type TeamTaskUpdateInput = z.infer<typeof TeamTaskUpdateInputSchema>
|
||||
@@ -26,7 +26,7 @@ export interface DelegateTaskArgs {
|
||||
export interface ToolContextWithMetadata {
|
||||
sessionID: string
|
||||
messageID: string
|
||||
agent: string
|
||||
agent?: string
|
||||
abort: AbortSignal
|
||||
metadata?: (input: { title?: string; metadata?: Record<string, unknown> }) => void | Promise<void>
|
||||
/**
|
||||
|
||||
@@ -37,6 +37,7 @@ type OpencodeClient = PluginInput["client"]
|
||||
export { createCallOmoAgent } from "./call-omo-agent"
|
||||
export { createLookAt } from "./look-at"
|
||||
export { createDelegateTask } from "./delegate-task"
|
||||
export { createAgentTeamsTools } from "./agent-teams"
|
||||
export {
|
||||
createTaskCreateTool,
|
||||
createTaskGetTool,
|
||||
|
||||
@@ -10,6 +10,7 @@ const TEST_CONFIG = {
|
||||
sisyphus: {
|
||||
tasks: {
|
||||
storage_path: TEST_STORAGE,
|
||||
claude_code_compat: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -297,5 +298,41 @@ describe("task_create tool", () => {
|
||||
expect(taskContent.subject).toBe("Test task")
|
||||
expect(taskContent.description).toBe("Test description")
|
||||
})
|
||||
|
||||
test("creates task in team namespace when team_name provided", async () => {
|
||||
//#given
|
||||
const args = {
|
||||
subject: "Team task",
|
||||
team_name: "test-team",
|
||||
}
|
||||
|
||||
//#when
|
||||
const resultStr = await tool.execute(args, TEST_CONTEXT)
|
||||
const result = JSON.parse(resultStr)
|
||||
|
||||
//#then
|
||||
expect(result).toHaveProperty("task")
|
||||
expect(result.task).toHaveProperty("id")
|
||||
expect(result.task.subject).toBe("Team task")
|
||||
})
|
||||
|
||||
test("creates task in regular storage when no team_name", async () => {
|
||||
//#given
|
||||
const args = {
|
||||
subject: "Regular task",
|
||||
}
|
||||
|
||||
//#when
|
||||
const resultStr = await tool.execute(args, TEST_CONTEXT)
|
||||
const result = JSON.parse(resultStr)
|
||||
|
||||
//#then
|
||||
expect(result).toHaveProperty("task")
|
||||
expect(result.task.subject).toBe("Regular task")
|
||||
// Verify it's in regular storage
|
||||
const taskId = result.task.id
|
||||
const taskFile = join(TEST_DIR, `${taskId}.json`)
|
||||
expect(existsSync(taskFile)).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
generateTaskId,
|
||||
} from "../../features/claude-tasks/storage";
|
||||
import { syncTaskTodoUpdate } from "./todo-sync";
|
||||
import { writeTeamTask } from "../agent-teams/team-task-store";
|
||||
import { getTeamTaskDir } from "../agent-teams/paths";
|
||||
|
||||
export function createTaskCreateTool(
|
||||
config: Partial<OhMyOpenCodeConfig>,
|
||||
@@ -48,7 +50,8 @@ Calculate dependencies carefully to maximize parallel execution:
|
||||
.optional()
|
||||
.describe("Task IDs this task blocks"),
|
||||
repoURL: tool.schema.string().optional().describe("Repository URL"),
|
||||
parentID: tool.schema.string().optional().describe("Parent task ID"),
|
||||
parentID: tool.schema.string().optional().describe("Parent task ID"),
|
||||
team_name: tool.schema.string().optional().describe("Optional: team name for team-namespaced tasks"),
|
||||
},
|
||||
execute: async (args, context) => {
|
||||
return handleCreate(args, config, ctx, context);
|
||||
@@ -64,42 +67,83 @@ async function handleCreate(
|
||||
): Promise<string> {
|
||||
try {
|
||||
const validatedArgs = TaskCreateInputSchema.parse(args);
|
||||
const taskDir = getTaskDir(config);
|
||||
const lock = acquireLock(taskDir);
|
||||
if (validatedArgs.team_name) {
|
||||
const teamName = validatedArgs.team_name as string;
|
||||
const teamTaskDir = getTeamTaskDir(teamName);
|
||||
const lock = acquireLock(teamTaskDir);
|
||||
|
||||
if (!lock.acquired) {
|
||||
return JSON.stringify({ error: "task_lock_unavailable" });
|
||||
}
|
||||
if (!lock.acquired) {
|
||||
return JSON.stringify({ error: "team_task_lock_unavailable" });
|
||||
}
|
||||
|
||||
try {
|
||||
const taskId = generateTaskId();
|
||||
const task: TaskObject = {
|
||||
id: taskId,
|
||||
subject: validatedArgs.subject,
|
||||
description: validatedArgs.description ?? "",
|
||||
status: "pending",
|
||||
blocks: validatedArgs.blocks ?? [],
|
||||
blockedBy: validatedArgs.blockedBy ?? [],
|
||||
activeForm: validatedArgs.activeForm,
|
||||
metadata: validatedArgs.metadata,
|
||||
repoURL: validatedArgs.repoURL,
|
||||
parentID: validatedArgs.parentID,
|
||||
threadID: context.sessionID,
|
||||
};
|
||||
try {
|
||||
const taskId = generateTaskId();
|
||||
const task: TaskObject = {
|
||||
id: taskId,
|
||||
subject: validatedArgs.subject,
|
||||
description: validatedArgs.description ?? "",
|
||||
status: "pending",
|
||||
blocks: validatedArgs.blocks ?? [],
|
||||
blockedBy: validatedArgs.blockedBy ?? [],
|
||||
activeForm: validatedArgs.activeForm,
|
||||
metadata: validatedArgs.metadata,
|
||||
repoURL: validatedArgs.repoURL,
|
||||
parentID: validatedArgs.parentID,
|
||||
threadID: context.sessionID,
|
||||
};
|
||||
|
||||
const validatedTask = TaskObjectSchema.parse(task);
|
||||
writeJsonAtomic(join(taskDir, `${taskId}.json`), validatedTask);
|
||||
const validatedTask = TaskObjectSchema.parse(task);
|
||||
writeTeamTask(teamName, taskId, validatedTask);
|
||||
|
||||
await syncTaskTodoUpdate(ctx, validatedTask, context.sessionID);
|
||||
await syncTaskTodoUpdate(ctx, validatedTask, context.sessionID);
|
||||
|
||||
return JSON.stringify({
|
||||
task: {
|
||||
id: validatedTask.id,
|
||||
subject: validatedTask.subject,
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
lock.release();
|
||||
return JSON.stringify({
|
||||
task: {
|
||||
id: validatedTask.id,
|
||||
subject: validatedTask.subject,
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
} else {
|
||||
const taskDir = getTaskDir(config);
|
||||
const lock = acquireLock(taskDir);
|
||||
|
||||
if (!lock.acquired) {
|
||||
return JSON.stringify({ error: "task_lock_unavailable" });
|
||||
}
|
||||
|
||||
try {
|
||||
const taskId = generateTaskId();
|
||||
const task: TaskObject = {
|
||||
id: taskId,
|
||||
subject: validatedArgs.subject,
|
||||
description: validatedArgs.description ?? "",
|
||||
status: "pending",
|
||||
blocks: validatedArgs.blocks ?? [],
|
||||
blockedBy: validatedArgs.blockedBy ?? [],
|
||||
activeForm: validatedArgs.activeForm,
|
||||
metadata: validatedArgs.metadata,
|
||||
repoURL: validatedArgs.repoURL,
|
||||
parentID: validatedArgs.parentID,
|
||||
threadID: context.sessionID,
|
||||
};
|
||||
|
||||
const validatedTask = TaskObjectSchema.parse(task);
|
||||
writeJsonAtomic(join(taskDir, `${taskId}.json`), validatedTask);
|
||||
|
||||
await syncTaskTodoUpdate(ctx, validatedTask, context.sessionID);
|
||||
|
||||
return JSON.stringify({
|
||||
task: {
|
||||
id: validatedTask.id,
|
||||
subject: validatedTask.subject,
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes("Required")) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { existsSync, rmSync, mkdirSync, writeFileSync } from "fs"
|
||||
import { join } from "path"
|
||||
import type { TaskObject } from "./types"
|
||||
import { createTaskGetTool } from "./task-get"
|
||||
import { writeTeamTask } from "../agent-teams/team-task-store"
|
||||
|
||||
const TEST_STORAGE = ".test-task-get-tool"
|
||||
const TEST_DIR = join(process.cwd(), TEST_STORAGE)
|
||||
@@ -10,6 +11,7 @@ const TEST_CONFIG = {
|
||||
sisyphus: {
|
||||
tasks: {
|
||||
storage_path: TEST_STORAGE,
|
||||
claude_code_compat: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -218,5 +220,55 @@ describe("task_get tool", () => {
|
||||
expect(result.task.owner).toBeUndefined()
|
||||
expect(result.task.metadata).toBeUndefined()
|
||||
})
|
||||
|
||||
test("retrieves task from team namespace when team_name provided", async () => {
|
||||
//#given
|
||||
const teamName = "test-team"
|
||||
const taskId = "T-team-task-404"
|
||||
const taskData: TaskObject = {
|
||||
id: taskId,
|
||||
subject: "Team task",
|
||||
description: "Team description",
|
||||
status: "pending",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
threadID: TEST_SESSION_ID,
|
||||
}
|
||||
writeTeamTask(teamName, taskId, taskData)
|
||||
|
||||
//#when
|
||||
const resultStr = await tool.execute({ id: taskId, team_name: teamName }, TEST_CONTEXT)
|
||||
const result = JSON.parse(resultStr)
|
||||
|
||||
//#then
|
||||
expect(result).toHaveProperty("task")
|
||||
expect(result.task).not.toBeNull()
|
||||
expect(result.task.id).toBe(taskId)
|
||||
expect(result.task.subject).toBe("Team task")
|
||||
})
|
||||
|
||||
test("retrieves task from regular storage when no team_name", async () => {
|
||||
//#given
|
||||
const taskId = "T-regular-task-505"
|
||||
const taskData: TaskObject = {
|
||||
id: taskId,
|
||||
subject: "Regular task",
|
||||
description: "Regular",
|
||||
status: "pending",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
threadID: TEST_SESSION_ID,
|
||||
}
|
||||
const taskFile = join(TEST_DIR, `${taskId}.json`)
|
||||
writeFileSync(taskFile, JSON.stringify(taskData, null, 2))
|
||||
|
||||
//#when
|
||||
const resultStr = await tool.execute({ id: taskId }, TEST_CONTEXT)
|
||||
const result = JSON.parse(resultStr)
|
||||
|
||||
//#then
|
||||
expect(result.task).not.toBeNull()
|
||||
expect(result.task.subject).toBe("Regular task")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { OhMyOpenCodeConfig } from "../../config/schema"
|
||||
import type { TaskGetInput } from "./types"
|
||||
import { TaskGetInputSchema, TaskObjectSchema } from "./types"
|
||||
import { getTaskDir, readJsonSafe } from "../../features/claude-tasks/storage"
|
||||
import { readTeamTask } from "../agent-teams/team-task-store"
|
||||
|
||||
const TASK_ID_PATTERN = /^T-[A-Za-z0-9-]+$/
|
||||
|
||||
@@ -21,6 +22,7 @@ Returns the full task object including all fields: id, subject, description, sta
|
||||
Returns null if the task does not exist or the file is invalid.`,
|
||||
args: {
|
||||
id: tool.schema.string().describe("Task ID to retrieve (format: T-{uuid})"),
|
||||
team_name: tool.schema.string().optional().describe("Optional: team name for team-namespaced tasks"),
|
||||
},
|
||||
execute: async (args: Record<string, unknown>): Promise<string> => {
|
||||
try {
|
||||
@@ -31,12 +33,15 @@ Returns null if the task does not exist or the file is invalid.`,
|
||||
return JSON.stringify({ error: "invalid_task_id" })
|
||||
}
|
||||
|
||||
const taskDir = getTaskDir(config)
|
||||
const taskPath = join(taskDir, `${taskId}.json`)
|
||||
|
||||
const task = readJsonSafe(taskPath, TaskObjectSchema)
|
||||
|
||||
return JSON.stringify({ task: task ?? null })
|
||||
if (validatedArgs.team_name) {
|
||||
const task = readTeamTask(validatedArgs.team_name, taskId)
|
||||
return JSON.stringify({ task: task ?? null })
|
||||
} else {
|
||||
const taskDir = getTaskDir(config)
|
||||
const taskPath = join(taskDir, `${taskId}.json`)
|
||||
const task = readJsonSafe(taskPath, TaskObjectSchema)
|
||||
return JSON.stringify({ task: task ?? null })
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes("validation")) {
|
||||
return JSON.stringify({ error: "invalid_arguments" })
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "bun:test"
|
||||
import { createTaskList } from "./task-list"
|
||||
import { writeJsonAtomic } from "../../features/claude-tasks/storage"
|
||||
import { writeTeamTask } from "../agent-teams/team-task-store"
|
||||
import { getTeamTaskDir } from "../agent-teams/paths"
|
||||
import type { TaskObject } from "./types"
|
||||
import { join } from "path"
|
||||
import { existsSync, rmSync } from "fs"
|
||||
@@ -21,6 +23,11 @@ describe("createTaskList", () => {
|
||||
if (existsSync(taskDir)) {
|
||||
rmSync(taskDir, { recursive: true })
|
||||
}
|
||||
// Clean up team task directories
|
||||
const teamTaskDir = getTeamTaskDir("test-team")
|
||||
if (existsSync(teamTaskDir)) {
|
||||
rmSync(teamTaskDir, { recursive: true })
|
||||
}
|
||||
})
|
||||
|
||||
it("returns empty array when no tasks exist", async () => {
|
||||
@@ -330,6 +337,72 @@ describe("createTaskList", () => {
|
||||
|
||||
//#then
|
||||
const parsed = JSON.parse(result)
|
||||
expect(parsed.tasks[0].blockedBy).toEqual(["T-missing"])
|
||||
})
|
||||
expect(parsed.tasks[0].blockedBy).toEqual(["T-missing"])
|
||||
})
|
||||
|
||||
it("lists tasks from team namespace when team_name provided", async () => {
|
||||
//#given
|
||||
const teamTask = {
|
||||
id: "T-team-1",
|
||||
subject: "Team task",
|
||||
description: "",
|
||||
status: "pending" as const,
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
threadID: "test-session",
|
||||
}
|
||||
|
||||
writeTeamTask("test-team", "T-team-1", teamTask)
|
||||
|
||||
const config = {
|
||||
sisyphus: {
|
||||
tasks: {
|
||||
storage_path: join(testProjectDir, ".sisyphus/tasks"),
|
||||
claude_code_compat: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
const tool = createTaskList(config)
|
||||
|
||||
//#when
|
||||
const result = await tool.execute({ team_name: "test-team" }, { sessionID: "test-session" })
|
||||
|
||||
//#then
|
||||
const parsed = JSON.parse(result)
|
||||
expect(parsed.tasks).toHaveLength(1)
|
||||
expect(parsed.tasks[0].subject).toBe("Team task")
|
||||
})
|
||||
|
||||
it("lists tasks from regular storage when no team_name", async () => {
|
||||
//#given
|
||||
const task: TaskObject = {
|
||||
id: "T-1",
|
||||
subject: "Regular task",
|
||||
description: "",
|
||||
status: "pending",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
threadID: "test-session",
|
||||
}
|
||||
|
||||
writeJsonAtomic(join(testProjectDir, ".sisyphus/tasks", "T-1.json"), task)
|
||||
|
||||
const config = {
|
||||
sisyphus: {
|
||||
tasks: {
|
||||
storage_path: join(testProjectDir, ".sisyphus/tasks"),
|
||||
claude_code_compat: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
const tool = createTaskList(config)
|
||||
|
||||
//#when
|
||||
const result = await tool.execute({}, { sessionID: "test-session" })
|
||||
|
||||
//#then
|
||||
const parsed = JSON.parse(result)
|
||||
expect(parsed.tasks).toHaveLength(1)
|
||||
expect(parsed.tasks[0].subject).toBe("Regular task")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,8 +3,9 @@ import { join } from "path"
|
||||
import { existsSync, readdirSync } from "fs"
|
||||
import type { OhMyOpenCodeConfig } from "../../config/schema"
|
||||
import type { TaskObject, TaskStatus } from "./types"
|
||||
import { TaskObjectSchema } from "./types"
|
||||
import { TaskObjectSchema, TaskListInputSchema } from "./types"
|
||||
import { readJsonSafe, getTaskDir } from "../../features/claude-tasks/storage"
|
||||
import { listTeamTasks } from "../agent-teams/team-task-store"
|
||||
|
||||
interface TaskSummary {
|
||||
id: string
|
||||
@@ -21,57 +22,93 @@ export function createTaskList(config: Partial<OhMyOpenCodeConfig>): ToolDefinit
|
||||
Returns tasks excluding completed and deleted statuses by default.
|
||||
For each task's blockedBy field, filters to only include unresolved (non-completed) blockers.
|
||||
Returns summary format: id, subject, status, owner, blockedBy (not full description).`,
|
||||
args: {},
|
||||
execute: async (): Promise<string> => {
|
||||
const taskDir = getTaskDir(config)
|
||||
args: {
|
||||
team_name: tool.schema.string().optional().describe("Optional: team name for team-namespaced tasks"),
|
||||
},
|
||||
execute: async (args: Record<string, unknown>): Promise<string> => {
|
||||
const validatedArgs = TaskListInputSchema.parse(args)
|
||||
|
||||
if (!existsSync(taskDir)) {
|
||||
return JSON.stringify({ tasks: [] })
|
||||
}
|
||||
if (validatedArgs.team_name) {
|
||||
const allTasks = listTeamTasks(validatedArgs.team_name)
|
||||
|
||||
const files = readdirSync(taskDir)
|
||||
.filter((f) => f.endsWith(".json") && f.startsWith("T-"))
|
||||
.map((f) => f.replace(".json", ""))
|
||||
// Filter out completed and deleted tasks
|
||||
const activeTasks = allTasks.filter(
|
||||
(task) => task.status !== "completed" && task.status !== "deleted"
|
||||
)
|
||||
|
||||
if (files.length === 0) {
|
||||
return JSON.stringify({ tasks: [] })
|
||||
}
|
||||
// Build summary with filtered blockedBy
|
||||
const summaries: TaskSummary[] = activeTasks.map((task) => {
|
||||
// Filter blockedBy to only include unresolved (non-completed) blockers
|
||||
const unresolvedBlockers = task.blockedBy.filter((blockerId) => {
|
||||
const blockerTask = allTasks.find((t) => t.id === blockerId)
|
||||
// Include if blocker doesn't exist (missing) or if it's not completed
|
||||
return !blockerTask || blockerTask.status !== "completed"
|
||||
})
|
||||
|
||||
const allTasks: TaskObject[] = []
|
||||
for (const fileId of files) {
|
||||
const task = readJsonSafe(join(taskDir, `${fileId}.json`), TaskObjectSchema)
|
||||
if (task) {
|
||||
allTasks.push(task)
|
||||
}
|
||||
}
|
||||
|
||||
// Filter out completed and deleted tasks
|
||||
const activeTasks = allTasks.filter(
|
||||
(task) => task.status !== "completed" && task.status !== "deleted"
|
||||
)
|
||||
|
||||
// Build summary with filtered blockedBy
|
||||
const summaries: TaskSummary[] = activeTasks.map((task) => {
|
||||
// Filter blockedBy to only include unresolved (non-completed) blockers
|
||||
const unresolvedBlockers = task.blockedBy.filter((blockerId) => {
|
||||
const blockerTask = allTasks.find((t) => t.id === blockerId)
|
||||
// Include if blocker doesn't exist (missing) or if it's not completed
|
||||
return !blockerTask || blockerTask.status !== "completed"
|
||||
return {
|
||||
id: task.id,
|
||||
subject: task.subject,
|
||||
status: task.status,
|
||||
owner: task.owner,
|
||||
blockedBy: unresolvedBlockers,
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
id: task.id,
|
||||
subject: task.subject,
|
||||
status: task.status,
|
||||
owner: task.owner,
|
||||
blockedBy: unresolvedBlockers,
|
||||
}
|
||||
})
|
||||
return JSON.stringify({
|
||||
tasks: summaries,
|
||||
reminder: "1 task = 1 task. Maximize parallel execution by running independent tasks (tasks with empty blockedBy) concurrently."
|
||||
})
|
||||
} else {
|
||||
const taskDir = getTaskDir(config)
|
||||
|
||||
return JSON.stringify({
|
||||
tasks: summaries,
|
||||
reminder: "1 task = 1 task. Maximize parallel execution by running independent tasks (tasks with empty blockedBy) concurrently."
|
||||
})
|
||||
if (!existsSync(taskDir)) {
|
||||
return JSON.stringify({ tasks: [] })
|
||||
}
|
||||
|
||||
const files = readdirSync(taskDir)
|
||||
.filter((f) => f.endsWith(".json") && f.startsWith("T-"))
|
||||
.map((f) => f.replace(".json", ""))
|
||||
|
||||
if (files.length === 0) {
|
||||
return JSON.stringify({ tasks: [] })
|
||||
}
|
||||
|
||||
const allTasks: TaskObject[] = []
|
||||
for (const fileId of files) {
|
||||
const task = readJsonSafe(join(taskDir, `${fileId}.json`), TaskObjectSchema)
|
||||
if (task) {
|
||||
allTasks.push(task)
|
||||
}
|
||||
}
|
||||
|
||||
// Filter out completed and deleted tasks
|
||||
const activeTasks = allTasks.filter(
|
||||
(task) => task.status !== "completed" && task.status !== "deleted"
|
||||
)
|
||||
|
||||
// Build summary with filtered blockedBy
|
||||
const summaries: TaskSummary[] = activeTasks.map((task) => {
|
||||
// Filter blockedBy to only include unresolved (non-completed) blockers
|
||||
const unresolvedBlockers = task.blockedBy.filter((blockerId) => {
|
||||
const blockerTask = allTasks.find((t) => t.id === blockerId)
|
||||
// Include if blocker doesn't exist (missing) or if it's not completed
|
||||
return !blockerTask || blockerTask.status !== "completed"
|
||||
})
|
||||
|
||||
return {
|
||||
id: task.id,
|
||||
subject: task.subject,
|
||||
status: task.status,
|
||||
owner: task.owner,
|
||||
blockedBy: unresolvedBlockers,
|
||||
}
|
||||
})
|
||||
|
||||
return JSON.stringify({
|
||||
tasks: summaries,
|
||||
reminder: "1 task = 1 task. Maximize parallel execution by running independent tasks (tasks with empty blockedBy) concurrently."
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { existsSync, rmSync, mkdirSync } from "fs"
|
||||
import { join } from "path"
|
||||
import type { TaskObject } from "./types"
|
||||
import { createTaskUpdateTool } from "./task-update"
|
||||
import { writeTeamTask } from "../agent-teams/team-task-store"
|
||||
|
||||
const TEST_STORAGE = ".test-task-update-tool"
|
||||
const TEST_DIR = join(process.cwd(), TEST_STORAGE)
|
||||
@@ -428,5 +429,53 @@ describe("task_update tool", () => {
|
||||
expect(result.task.status).toBe("in_progress")
|
||||
expect(result.task.owner).toBe("alice")
|
||||
})
|
||||
|
||||
test("updates task in team namespace when team_name provided", async () => {
|
||||
//#given
|
||||
const taskId = "T-team-test-135"
|
||||
const teamName = "test-team"
|
||||
const initialTask: TaskObject = {
|
||||
id: taskId,
|
||||
subject: "Original team subject",
|
||||
description: "Team task description",
|
||||
status: "pending",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
threadID: TEST_SESSION_ID,
|
||||
}
|
||||
writeTeamTask(teamName, taskId, initialTask)
|
||||
|
||||
//#when
|
||||
const args = {
|
||||
id: taskId,
|
||||
team_name: teamName,
|
||||
subject: "Updated team subject",
|
||||
status: "in_progress" as const,
|
||||
}
|
||||
const resultStr = await tool.execute(args, TEST_CONTEXT)
|
||||
const result = JSON.parse(resultStr)
|
||||
|
||||
//#then
|
||||
expect(result).toHaveProperty("task")
|
||||
expect(result.task.subject).toBe("Updated team subject")
|
||||
expect(result.task.status).toBe("in_progress")
|
||||
expect(result.task.description).toBe("Team task description")
|
||||
})
|
||||
|
||||
test("returns error when team task not found", async () => {
|
||||
//#given
|
||||
const args = {
|
||||
id: "T-nonexistent-team-task",
|
||||
team_name: "test-team",
|
||||
}
|
||||
|
||||
//#when
|
||||
const resultStr = await tool.execute(args, TEST_CONTEXT)
|
||||
const result = JSON.parse(resultStr)
|
||||
|
||||
//#then
|
||||
expect(result).toHaveProperty("error")
|
||||
expect(result.error).toBe("task_not_found")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin";
|
||||
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool";
|
||||
import { join } from "path";
|
||||
import type { PluginInput } from "@opencode-ai/plugin";
|
||||
import type { OhMyOpenCodeConfig } from "../../config/schema";
|
||||
import type { TaskObject, TaskUpdateInput } from "./types";
|
||||
import { TaskObjectSchema, TaskUpdateInputSchema } from "./types";
|
||||
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool";
|
||||
import {
|
||||
getTaskDir,
|
||||
readJsonSafe,
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
acquireLock,
|
||||
} from "../../features/claude-tasks/storage";
|
||||
import { syncTaskTodoUpdate } from "./todo-sync";
|
||||
import { readTeamTask, writeTeamTask } from "../agent-teams/team-task-store";
|
||||
|
||||
const TASK_ID_PATTERN = /^T-[A-Za-z0-9-]+$/;
|
||||
|
||||
@@ -34,38 +35,39 @@ Syncs to OpenCode Todo API after update.
|
||||
**IMPORTANT - Dependency Management:**
|
||||
Use \`addBlockedBy\` to declare dependencies on other tasks.
|
||||
Properly managed dependencies enable maximum parallel execution.`,
|
||||
args: {
|
||||
id: tool.schema.string().describe("Task ID (required)"),
|
||||
subject: tool.schema.string().optional().describe("Task subject"),
|
||||
description: tool.schema.string().optional().describe("Task description"),
|
||||
status: tool.schema
|
||||
.enum(["pending", "in_progress", "completed", "deleted"])
|
||||
.optional()
|
||||
.describe("Task status"),
|
||||
activeForm: tool.schema
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Active form (present continuous)"),
|
||||
owner: tool.schema
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Task owner (agent name)"),
|
||||
addBlocks: tool.schema
|
||||
.array(tool.schema.string())
|
||||
.optional()
|
||||
.describe("Task IDs to add to blocks (additive, not replacement)"),
|
||||
addBlockedBy: tool.schema
|
||||
.array(tool.schema.string())
|
||||
.optional()
|
||||
.describe("Task IDs to add to blockedBy (additive, not replacement)"),
|
||||
metadata: tool.schema
|
||||
.record(tool.schema.string(), tool.schema.unknown())
|
||||
.optional()
|
||||
.describe("Task metadata to merge (set key to null to delete)"),
|
||||
},
|
||||
execute: async (args, context) => {
|
||||
return handleUpdate(args, config, ctx, context);
|
||||
},
|
||||
args: {
|
||||
id: tool.schema.string().describe("Task ID (required)"),
|
||||
subject: tool.schema.string().optional().describe("Task subject"),
|
||||
description: tool.schema.string().optional().describe("Task description"),
|
||||
status: tool.schema
|
||||
.enum(["pending", "in_progress", "completed", "deleted"])
|
||||
.optional()
|
||||
.describe("Task status"),
|
||||
activeForm: tool.schema
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Active form (present continuous)"),
|
||||
owner: tool.schema
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Task owner (agent name)"),
|
||||
addBlocks: tool.schema
|
||||
.array(tool.schema.string())
|
||||
.optional()
|
||||
.describe("Task IDs to add to blocks (additive, not replacement)"),
|
||||
addBlockedBy: tool.schema
|
||||
.array(tool.schema.string())
|
||||
.optional()
|
||||
.describe("Task IDs to add to blockedBy (additive, not replacement)"),
|
||||
metadata: tool.schema
|
||||
.record(tool.schema.string(), tool.schema.unknown())
|
||||
.optional()
|
||||
.describe("Task metadata to merge (set key to null to delete)"),
|
||||
team_name: tool.schema.string().optional().describe("Team namespace for task storage"),
|
||||
},
|
||||
execute: async (args: Record<string, unknown>, context) => {
|
||||
return handleUpdate(args, config, ctx, context);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -82,17 +84,9 @@ async function handleUpdate(
|
||||
return JSON.stringify({ error: "invalid_task_id" });
|
||||
}
|
||||
|
||||
const taskDir = getTaskDir(config);
|
||||
const lock = acquireLock(taskDir);
|
||||
|
||||
if (!lock.acquired) {
|
||||
return JSON.stringify({ error: "task_lock_unavailable" });
|
||||
}
|
||||
|
||||
try {
|
||||
const taskPath = join(taskDir, `${taskId}.json`);
|
||||
const task = readJsonSafe(taskPath, TaskObjectSchema);
|
||||
|
||||
if (validatedArgs.team_name) {
|
||||
// Team namespace routing
|
||||
const task = readTeamTask(validatedArgs.team_name, taskId);
|
||||
if (!task) {
|
||||
return JSON.stringify({ error: "task_not_found" });
|
||||
}
|
||||
@@ -133,13 +127,72 @@ async function handleUpdate(
|
||||
}
|
||||
|
||||
const validatedTask = TaskObjectSchema.parse(task);
|
||||
writeJsonAtomic(taskPath, validatedTask);
|
||||
writeTeamTask(validatedArgs.team_name, taskId, validatedTask);
|
||||
|
||||
await syncTaskTodoUpdate(ctx, validatedTask, context.sessionID);
|
||||
|
||||
return JSON.stringify({ task: validatedTask });
|
||||
} finally {
|
||||
lock.release();
|
||||
} else {
|
||||
// Regular task storage
|
||||
const taskDir = getTaskDir(config);
|
||||
const lock = acquireLock(taskDir);
|
||||
|
||||
if (!lock.acquired) {
|
||||
return JSON.stringify({ error: "task_lock_unavailable" });
|
||||
}
|
||||
|
||||
try {
|
||||
const taskPath = join(taskDir, `${taskId}.json`);
|
||||
const task = readJsonSafe(taskPath, TaskObjectSchema);
|
||||
|
||||
if (!task) {
|
||||
return JSON.stringify({ error: "task_not_found" });
|
||||
}
|
||||
|
||||
if (validatedArgs.subject !== undefined) {
|
||||
task.subject = validatedArgs.subject;
|
||||
}
|
||||
if (validatedArgs.description !== undefined) {
|
||||
task.description = validatedArgs.description;
|
||||
}
|
||||
if (validatedArgs.status !== undefined) {
|
||||
task.status = validatedArgs.status;
|
||||
}
|
||||
if (validatedArgs.activeForm !== undefined) {
|
||||
task.activeForm = validatedArgs.activeForm;
|
||||
}
|
||||
if (validatedArgs.owner !== undefined) {
|
||||
task.owner = validatedArgs.owner;
|
||||
}
|
||||
|
||||
const addBlocks = args.addBlocks as string[] | undefined;
|
||||
if (addBlocks) {
|
||||
task.blocks = [...new Set([...task.blocks, ...addBlocks])];
|
||||
}
|
||||
|
||||
const addBlockedBy = args.addBlockedBy as string[] | undefined;
|
||||
if (addBlockedBy) {
|
||||
task.blockedBy = [...new Set([...task.blockedBy, ...addBlockedBy])];
|
||||
}
|
||||
|
||||
if (validatedArgs.metadata !== undefined) {
|
||||
task.metadata = { ...task.metadata, ...validatedArgs.metadata };
|
||||
Object.keys(task.metadata).forEach((key) => {
|
||||
if (task.metadata?.[key] === null) {
|
||||
delete task.metadata[key];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const validatedTask = TaskObjectSchema.parse(task);
|
||||
writeJsonAtomic(taskPath, validatedTask);
|
||||
|
||||
await syncTaskTodoUpdate(ctx, validatedTask, context.sessionID);
|
||||
|
||||
return JSON.stringify({ task: validatedTask });
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes("Required")) {
|
||||
|
||||
@@ -37,6 +37,7 @@ export const TaskCreateInputSchema = z.object({
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
repoURL: z.string().optional(),
|
||||
parentID: z.string().optional(),
|
||||
team_name: z.string().optional(),
|
||||
})
|
||||
|
||||
export type TaskCreateInput = z.infer<typeof TaskCreateInputSchema>
|
||||
@@ -44,12 +45,14 @@ export type TaskCreateInput = z.infer<typeof TaskCreateInputSchema>
|
||||
export const TaskListInputSchema = z.object({
|
||||
status: TaskStatusSchema.optional(),
|
||||
parentID: z.string().optional(),
|
||||
team_name: z.string().optional(),
|
||||
})
|
||||
|
||||
export type TaskListInput = z.infer<typeof TaskListInputSchema>
|
||||
|
||||
export const TaskGetInputSchema = z.object({
|
||||
id: z.string(),
|
||||
team_name: z.string().optional(),
|
||||
})
|
||||
|
||||
export type TaskGetInput = z.infer<typeof TaskGetInputSchema>
|
||||
@@ -66,6 +69,7 @@ export const TaskUpdateInputSchema = z.object({
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
repoURL: z.string().optional(),
|
||||
parentID: z.string().optional(),
|
||||
team_name: z.string().optional(),
|
||||
})
|
||||
|
||||
export type TaskUpdateInput = z.infer<typeof TaskUpdateInputSchema>
|
||||
|
||||
Reference in New Issue
Block a user