Compare commits
41 Commits
v3.7.0
...
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
|
//#given
|
||||||
const config = {}
|
const config = {}
|
||||||
|
|
||||||
@@ -659,10 +673,34 @@ describe("ExperimentalConfigSchema feature flags", () => {
|
|||||||
//#then
|
//#then
|
||||||
expect(result.success).toBe(true)
|
expect(result.success).toBe(true)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
expect(result.data.plugin_load_timeout_ms).toBeUndefined()
|
expect(result.data.team_system).toBe(false)
|
||||||
expect(result.data.safe_hook_creation).toBeUndefined()
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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", () => {
|
describe("GitMasterConfigSchema", () => {
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ export const ExperimentalConfigSchema = z.object({
|
|||||||
plugin_load_timeout_ms: z.number().min(1000).optional(),
|
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) */
|
/** 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(),
|
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>
|
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,
|
createAstGrepTools,
|
||||||
createSessionManagerTools,
|
createSessionManagerTools,
|
||||||
createDelegateTask,
|
createDelegateTask,
|
||||||
|
createAgentTeamsTools,
|
||||||
discoverCommandsSync,
|
discoverCommandsSync,
|
||||||
interactive_bash,
|
interactive_bash,
|
||||||
createTaskCreateTool,
|
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> = {
|
const allTools: Record<string, ToolDefinition> = {
|
||||||
...builtinTools,
|
...builtinTools,
|
||||||
...createGrepTools(ctx),
|
...createGrepTools(ctx),
|
||||||
@@ -132,6 +142,7 @@ export function createToolRegistry(args: {
|
|||||||
slashcommand: slashcommandTool,
|
slashcommand: slashcommandTool,
|
||||||
interactive_bash,
|
interactive_bash,
|
||||||
...taskToolsRecord,
|
...taskToolsRecord,
|
||||||
|
...agentTeamsRecord,
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredTools = filterDisabledTools(allTools, pluginConfig.disabled_tools)
|
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 {
|
export interface ToolContextWithMetadata {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
messageID: string
|
messageID: string
|
||||||
agent: string
|
agent?: string
|
||||||
abort: AbortSignal
|
abort: AbortSignal
|
||||||
metadata?: (input: { title?: string; metadata?: Record<string, unknown> }) => void | Promise<void>
|
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 { createCallOmoAgent } from "./call-omo-agent"
|
||||||
export { createLookAt } from "./look-at"
|
export { createLookAt } from "./look-at"
|
||||||
export { createDelegateTask } from "./delegate-task"
|
export { createDelegateTask } from "./delegate-task"
|
||||||
|
export { createAgentTeamsTools } from "./agent-teams"
|
||||||
export {
|
export {
|
||||||
createTaskCreateTool,
|
createTaskCreateTool,
|
||||||
createTaskGetTool,
|
createTaskGetTool,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const TEST_CONFIG = {
|
|||||||
sisyphus: {
|
sisyphus: {
|
||||||
tasks: {
|
tasks: {
|
||||||
storage_path: TEST_STORAGE,
|
storage_path: TEST_STORAGE,
|
||||||
|
claude_code_compat: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -297,5 +298,41 @@ describe("task_create tool", () => {
|
|||||||
expect(taskContent.subject).toBe("Test task")
|
expect(taskContent.subject).toBe("Test task")
|
||||||
expect(taskContent.description).toBe("Test description")
|
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,
|
generateTaskId,
|
||||||
} from "../../features/claude-tasks/storage";
|
} from "../../features/claude-tasks/storage";
|
||||||
import { syncTaskTodoUpdate } from "./todo-sync";
|
import { syncTaskTodoUpdate } from "./todo-sync";
|
||||||
|
import { writeTeamTask } from "../agent-teams/team-task-store";
|
||||||
|
import { getTeamTaskDir } from "../agent-teams/paths";
|
||||||
|
|
||||||
export function createTaskCreateTool(
|
export function createTaskCreateTool(
|
||||||
config: Partial<OhMyOpenCodeConfig>,
|
config: Partial<OhMyOpenCodeConfig>,
|
||||||
@@ -48,7 +50,8 @@ Calculate dependencies carefully to maximize parallel execution:
|
|||||||
.optional()
|
.optional()
|
||||||
.describe("Task IDs this task blocks"),
|
.describe("Task IDs this task blocks"),
|
||||||
repoURL: tool.schema.string().optional().describe("Repository URL"),
|
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) => {
|
execute: async (args, context) => {
|
||||||
return handleCreate(args, config, ctx, context);
|
return handleCreate(args, config, ctx, context);
|
||||||
@@ -64,42 +67,83 @@ async function handleCreate(
|
|||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const validatedArgs = TaskCreateInputSchema.parse(args);
|
const validatedArgs = TaskCreateInputSchema.parse(args);
|
||||||
const taskDir = getTaskDir(config);
|
if (validatedArgs.team_name) {
|
||||||
const lock = acquireLock(taskDir);
|
const teamName = validatedArgs.team_name as string;
|
||||||
|
const teamTaskDir = getTeamTaskDir(teamName);
|
||||||
|
const lock = acquireLock(teamTaskDir);
|
||||||
|
|
||||||
if (!lock.acquired) {
|
if (!lock.acquired) {
|
||||||
return JSON.stringify({ error: "task_lock_unavailable" });
|
return JSON.stringify({ error: "team_task_lock_unavailable" });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const taskId = generateTaskId();
|
const taskId = generateTaskId();
|
||||||
const task: TaskObject = {
|
const task: TaskObject = {
|
||||||
id: taskId,
|
id: taskId,
|
||||||
subject: validatedArgs.subject,
|
subject: validatedArgs.subject,
|
||||||
description: validatedArgs.description ?? "",
|
description: validatedArgs.description ?? "",
|
||||||
status: "pending",
|
status: "pending",
|
||||||
blocks: validatedArgs.blocks ?? [],
|
blocks: validatedArgs.blocks ?? [],
|
||||||
blockedBy: validatedArgs.blockedBy ?? [],
|
blockedBy: validatedArgs.blockedBy ?? [],
|
||||||
activeForm: validatedArgs.activeForm,
|
activeForm: validatedArgs.activeForm,
|
||||||
metadata: validatedArgs.metadata,
|
metadata: validatedArgs.metadata,
|
||||||
repoURL: validatedArgs.repoURL,
|
repoURL: validatedArgs.repoURL,
|
||||||
parentID: validatedArgs.parentID,
|
parentID: validatedArgs.parentID,
|
||||||
threadID: context.sessionID,
|
threadID: context.sessionID,
|
||||||
};
|
};
|
||||||
|
|
||||||
const validatedTask = TaskObjectSchema.parse(task);
|
const validatedTask = TaskObjectSchema.parse(task);
|
||||||
writeJsonAtomic(join(taskDir, `${taskId}.json`), validatedTask);
|
writeTeamTask(teamName, taskId, validatedTask);
|
||||||
|
|
||||||
await syncTaskTodoUpdate(ctx, validatedTask, context.sessionID);
|
await syncTaskTodoUpdate(ctx, validatedTask, context.sessionID);
|
||||||
|
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
task: {
|
task: {
|
||||||
id: validatedTask.id,
|
id: validatedTask.id,
|
||||||
subject: validatedTask.subject,
|
subject: validatedTask.subject,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
lock.release();
|
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) {
|
} catch (error) {
|
||||||
if (error instanceof Error && error.message.includes("Required")) {
|
if (error instanceof Error && error.message.includes("Required")) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { existsSync, rmSync, mkdirSync, writeFileSync } from "fs"
|
|||||||
import { join } from "path"
|
import { join } from "path"
|
||||||
import type { TaskObject } from "./types"
|
import type { TaskObject } from "./types"
|
||||||
import { createTaskGetTool } from "./task-get"
|
import { createTaskGetTool } from "./task-get"
|
||||||
|
import { writeTeamTask } from "../agent-teams/team-task-store"
|
||||||
|
|
||||||
const TEST_STORAGE = ".test-task-get-tool"
|
const TEST_STORAGE = ".test-task-get-tool"
|
||||||
const TEST_DIR = join(process.cwd(), TEST_STORAGE)
|
const TEST_DIR = join(process.cwd(), TEST_STORAGE)
|
||||||
@@ -10,6 +11,7 @@ const TEST_CONFIG = {
|
|||||||
sisyphus: {
|
sisyphus: {
|
||||||
tasks: {
|
tasks: {
|
||||||
storage_path: TEST_STORAGE,
|
storage_path: TEST_STORAGE,
|
||||||
|
claude_code_compat: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -218,5 +220,55 @@ describe("task_get tool", () => {
|
|||||||
expect(result.task.owner).toBeUndefined()
|
expect(result.task.owner).toBeUndefined()
|
||||||
expect(result.task.metadata).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 type { TaskGetInput } from "./types"
|
||||||
import { TaskGetInputSchema, TaskObjectSchema } from "./types"
|
import { TaskGetInputSchema, TaskObjectSchema } from "./types"
|
||||||
import { getTaskDir, readJsonSafe } from "../../features/claude-tasks/storage"
|
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-]+$/
|
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.`,
|
Returns null if the task does not exist or the file is invalid.`,
|
||||||
args: {
|
args: {
|
||||||
id: tool.schema.string().describe("Task ID to retrieve (format: T-{uuid})"),
|
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> => {
|
execute: async (args: Record<string, unknown>): Promise<string> => {
|
||||||
try {
|
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" })
|
return JSON.stringify({ error: "invalid_task_id" })
|
||||||
}
|
}
|
||||||
|
|
||||||
const taskDir = getTaskDir(config)
|
if (validatedArgs.team_name) {
|
||||||
const taskPath = join(taskDir, `${taskId}.json`)
|
const task = readTeamTask(validatedArgs.team_name, taskId)
|
||||||
|
return JSON.stringify({ task: task ?? null })
|
||||||
const task = readJsonSafe(taskPath, TaskObjectSchema)
|
} else {
|
||||||
|
const taskDir = getTaskDir(config)
|
||||||
return JSON.stringify({ task: task ?? null })
|
const taskPath = join(taskDir, `${taskId}.json`)
|
||||||
|
const task = readJsonSafe(taskPath, TaskObjectSchema)
|
||||||
|
return JSON.stringify({ task: task ?? null })
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && error.message.includes("validation")) {
|
if (error instanceof Error && error.message.includes("validation")) {
|
||||||
return JSON.stringify({ error: "invalid_arguments" })
|
return JSON.stringify({ error: "invalid_arguments" })
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach } from "bun:test"
|
import { describe, it, expect, beforeEach, afterEach } from "bun:test"
|
||||||
import { createTaskList } from "./task-list"
|
import { createTaskList } from "./task-list"
|
||||||
import { writeJsonAtomic } from "../../features/claude-tasks/storage"
|
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 type { TaskObject } from "./types"
|
||||||
import { join } from "path"
|
import { join } from "path"
|
||||||
import { existsSync, rmSync } from "fs"
|
import { existsSync, rmSync } from "fs"
|
||||||
@@ -21,6 +23,11 @@ describe("createTaskList", () => {
|
|||||||
if (existsSync(taskDir)) {
|
if (existsSync(taskDir)) {
|
||||||
rmSync(taskDir, { recursive: true })
|
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 () => {
|
it("returns empty array when no tasks exist", async () => {
|
||||||
@@ -330,6 +337,72 @@ describe("createTaskList", () => {
|
|||||||
|
|
||||||
//#then
|
//#then
|
||||||
const parsed = JSON.parse(result)
|
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 { existsSync, readdirSync } from "fs"
|
||||||
import type { OhMyOpenCodeConfig } from "../../config/schema"
|
import type { OhMyOpenCodeConfig } from "../../config/schema"
|
||||||
import type { TaskObject, TaskStatus } from "./types"
|
import type { TaskObject, TaskStatus } from "./types"
|
||||||
import { TaskObjectSchema } from "./types"
|
import { TaskObjectSchema, TaskListInputSchema } from "./types"
|
||||||
import { readJsonSafe, getTaskDir } from "../../features/claude-tasks/storage"
|
import { readJsonSafe, getTaskDir } from "../../features/claude-tasks/storage"
|
||||||
|
import { listTeamTasks } from "../agent-teams/team-task-store"
|
||||||
|
|
||||||
interface TaskSummary {
|
interface TaskSummary {
|
||||||
id: string
|
id: string
|
||||||
@@ -21,57 +22,93 @@ export function createTaskList(config: Partial<OhMyOpenCodeConfig>): ToolDefinit
|
|||||||
Returns tasks excluding completed and deleted statuses by default.
|
Returns tasks excluding completed and deleted statuses by default.
|
||||||
For each task's blockedBy field, filters to only include unresolved (non-completed) blockers.
|
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).`,
|
Returns summary format: id, subject, status, owner, blockedBy (not full description).`,
|
||||||
args: {},
|
args: {
|
||||||
execute: async (): Promise<string> => {
|
team_name: tool.schema.string().optional().describe("Optional: team name for team-namespaced tasks"),
|
||||||
const taskDir = getTaskDir(config)
|
},
|
||||||
|
execute: async (args: Record<string, unknown>): Promise<string> => {
|
||||||
|
const validatedArgs = TaskListInputSchema.parse(args)
|
||||||
|
|
||||||
if (!existsSync(taskDir)) {
|
if (validatedArgs.team_name) {
|
||||||
return JSON.stringify({ tasks: [] })
|
const allTasks = listTeamTasks(validatedArgs.team_name)
|
||||||
}
|
|
||||||
|
|
||||||
const files = readdirSync(taskDir)
|
// Filter out completed and deleted tasks
|
||||||
.filter((f) => f.endsWith(".json") && f.startsWith("T-"))
|
const activeTasks = allTasks.filter(
|
||||||
.map((f) => f.replace(".json", ""))
|
(task) => task.status !== "completed" && task.status !== "deleted"
|
||||||
|
)
|
||||||
|
|
||||||
if (files.length === 0) {
|
// Build summary with filtered blockedBy
|
||||||
return JSON.stringify({ tasks: [] })
|
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[] = []
|
return {
|
||||||
for (const fileId of files) {
|
id: task.id,
|
||||||
const task = readJsonSafe(join(taskDir, `${fileId}.json`), TaskObjectSchema)
|
subject: task.subject,
|
||||||
if (task) {
|
status: task.status,
|
||||||
allTasks.push(task)
|
owner: task.owner,
|
||||||
}
|
blockedBy: unresolvedBlockers,
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 {
|
return JSON.stringify({
|
||||||
id: task.id,
|
tasks: summaries,
|
||||||
subject: task.subject,
|
reminder: "1 task = 1 task. Maximize parallel execution by running independent tasks (tasks with empty blockedBy) concurrently."
|
||||||
status: task.status,
|
})
|
||||||
owner: task.owner,
|
} else {
|
||||||
blockedBy: unresolvedBlockers,
|
const taskDir = getTaskDir(config)
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return JSON.stringify({
|
if (!existsSync(taskDir)) {
|
||||||
tasks: summaries,
|
return JSON.stringify({ tasks: [] })
|
||||||
reminder: "1 task = 1 task. Maximize parallel execution by running independent tasks (tasks with empty blockedBy) concurrently."
|
}
|
||||||
})
|
|
||||||
|
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 { join } from "path"
|
||||||
import type { TaskObject } from "./types"
|
import type { TaskObject } from "./types"
|
||||||
import { createTaskUpdateTool } from "./task-update"
|
import { createTaskUpdateTool } from "./task-update"
|
||||||
|
import { writeTeamTask } from "../agent-teams/team-task-store"
|
||||||
|
|
||||||
const TEST_STORAGE = ".test-task-update-tool"
|
const TEST_STORAGE = ".test-task-update-tool"
|
||||||
const TEST_DIR = join(process.cwd(), TEST_STORAGE)
|
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.status).toBe("in_progress")
|
||||||
expect(result.task.owner).toBe("alice")
|
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 { join } from "path";
|
||||||
|
import type { PluginInput } from "@opencode-ai/plugin";
|
||||||
import type { OhMyOpenCodeConfig } from "../../config/schema";
|
import type { OhMyOpenCodeConfig } from "../../config/schema";
|
||||||
import type { TaskObject, TaskUpdateInput } from "./types";
|
import type { TaskObject, TaskUpdateInput } from "./types";
|
||||||
import { TaskObjectSchema, TaskUpdateInputSchema } from "./types";
|
import { TaskObjectSchema, TaskUpdateInputSchema } from "./types";
|
||||||
|
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool";
|
||||||
import {
|
import {
|
||||||
getTaskDir,
|
getTaskDir,
|
||||||
readJsonSafe,
|
readJsonSafe,
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
acquireLock,
|
acquireLock,
|
||||||
} from "../../features/claude-tasks/storage";
|
} from "../../features/claude-tasks/storage";
|
||||||
import { syncTaskTodoUpdate } from "./todo-sync";
|
import { syncTaskTodoUpdate } from "./todo-sync";
|
||||||
|
import { readTeamTask, writeTeamTask } from "../agent-teams/team-task-store";
|
||||||
|
|
||||||
const TASK_ID_PATTERN = /^T-[A-Za-z0-9-]+$/;
|
const TASK_ID_PATTERN = /^T-[A-Za-z0-9-]+$/;
|
||||||
|
|
||||||
@@ -34,38 +35,39 @@ Syncs to OpenCode Todo API after update.
|
|||||||
**IMPORTANT - Dependency Management:**
|
**IMPORTANT - Dependency Management:**
|
||||||
Use \`addBlockedBy\` to declare dependencies on other tasks.
|
Use \`addBlockedBy\` to declare dependencies on other tasks.
|
||||||
Properly managed dependencies enable maximum parallel execution.`,
|
Properly managed dependencies enable maximum parallel execution.`,
|
||||||
args: {
|
args: {
|
||||||
id: tool.schema.string().describe("Task ID (required)"),
|
id: tool.schema.string().describe("Task ID (required)"),
|
||||||
subject: tool.schema.string().optional().describe("Task subject"),
|
subject: tool.schema.string().optional().describe("Task subject"),
|
||||||
description: tool.schema.string().optional().describe("Task description"),
|
description: tool.schema.string().optional().describe("Task description"),
|
||||||
status: tool.schema
|
status: tool.schema
|
||||||
.enum(["pending", "in_progress", "completed", "deleted"])
|
.enum(["pending", "in_progress", "completed", "deleted"])
|
||||||
.optional()
|
.optional()
|
||||||
.describe("Task status"),
|
.describe("Task status"),
|
||||||
activeForm: tool.schema
|
activeForm: tool.schema
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
.describe("Active form (present continuous)"),
|
.describe("Active form (present continuous)"),
|
||||||
owner: tool.schema
|
owner: tool.schema
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
.describe("Task owner (agent name)"),
|
.describe("Task owner (agent name)"),
|
||||||
addBlocks: tool.schema
|
addBlocks: tool.schema
|
||||||
.array(tool.schema.string())
|
.array(tool.schema.string())
|
||||||
.optional()
|
.optional()
|
||||||
.describe("Task IDs to add to blocks (additive, not replacement)"),
|
.describe("Task IDs to add to blocks (additive, not replacement)"),
|
||||||
addBlockedBy: tool.schema
|
addBlockedBy: tool.schema
|
||||||
.array(tool.schema.string())
|
.array(tool.schema.string())
|
||||||
.optional()
|
.optional()
|
||||||
.describe("Task IDs to add to blockedBy (additive, not replacement)"),
|
.describe("Task IDs to add to blockedBy (additive, not replacement)"),
|
||||||
metadata: tool.schema
|
metadata: tool.schema
|
||||||
.record(tool.schema.string(), tool.schema.unknown())
|
.record(tool.schema.string(), tool.schema.unknown())
|
||||||
.optional()
|
.optional()
|
||||||
.describe("Task metadata to merge (set key to null to delete)"),
|
.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, context) => {
|
},
|
||||||
return handleUpdate(args, config, ctx, context);
|
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" });
|
return JSON.stringify({ error: "invalid_task_id" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const taskDir = getTaskDir(config);
|
if (validatedArgs.team_name) {
|
||||||
const lock = acquireLock(taskDir);
|
// Team namespace routing
|
||||||
|
const task = readTeamTask(validatedArgs.team_name, taskId);
|
||||||
if (!lock.acquired) {
|
|
||||||
return JSON.stringify({ error: "task_lock_unavailable" });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const taskPath = join(taskDir, `${taskId}.json`);
|
|
||||||
const task = readJsonSafe(taskPath, TaskObjectSchema);
|
|
||||||
|
|
||||||
if (!task) {
|
if (!task) {
|
||||||
return JSON.stringify({ error: "task_not_found" });
|
return JSON.stringify({ error: "task_not_found" });
|
||||||
}
|
}
|
||||||
@@ -133,13 +127,72 @@ async function handleUpdate(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const validatedTask = TaskObjectSchema.parse(task);
|
const validatedTask = TaskObjectSchema.parse(task);
|
||||||
writeJsonAtomic(taskPath, validatedTask);
|
writeTeamTask(validatedArgs.team_name, taskId, validatedTask);
|
||||||
|
|
||||||
await syncTaskTodoUpdate(ctx, validatedTask, context.sessionID);
|
await syncTaskTodoUpdate(ctx, validatedTask, context.sessionID);
|
||||||
|
|
||||||
return JSON.stringify({ task: validatedTask });
|
return JSON.stringify({ task: validatedTask });
|
||||||
} finally {
|
} else {
|
||||||
lock.release();
|
// 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) {
|
} catch (error) {
|
||||||
if (error instanceof Error && error.message.includes("Required")) {
|
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(),
|
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||||
repoURL: z.string().optional(),
|
repoURL: z.string().optional(),
|
||||||
parentID: z.string().optional(),
|
parentID: z.string().optional(),
|
||||||
|
team_name: z.string().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export type TaskCreateInput = z.infer<typeof TaskCreateInputSchema>
|
export type TaskCreateInput = z.infer<typeof TaskCreateInputSchema>
|
||||||
@@ -44,12 +45,14 @@ export type TaskCreateInput = z.infer<typeof TaskCreateInputSchema>
|
|||||||
export const TaskListInputSchema = z.object({
|
export const TaskListInputSchema = z.object({
|
||||||
status: TaskStatusSchema.optional(),
|
status: TaskStatusSchema.optional(),
|
||||||
parentID: z.string().optional(),
|
parentID: z.string().optional(),
|
||||||
|
team_name: z.string().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export type TaskListInput = z.infer<typeof TaskListInputSchema>
|
export type TaskListInput = z.infer<typeof TaskListInputSchema>
|
||||||
|
|
||||||
export const TaskGetInputSchema = z.object({
|
export const TaskGetInputSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
|
team_name: z.string().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export type TaskGetInput = z.infer<typeof TaskGetInputSchema>
|
export type TaskGetInput = z.infer<typeof TaskGetInputSchema>
|
||||||
@@ -66,6 +69,7 @@ export const TaskUpdateInputSchema = z.object({
|
|||||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||||
repoURL: z.string().optional(),
|
repoURL: z.string().optional(),
|
||||||
parentID: z.string().optional(),
|
parentID: z.string().optional(),
|
||||||
|
team_name: z.string().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export type TaskUpdateInput = z.infer<typeof TaskUpdateInputSchema>
|
export type TaskUpdateInput = z.infer<typeof TaskUpdateInputSchema>
|
||||||
|
|||||||
Reference in New Issue
Block a user