feat(team-mode): add team query tools with tests
This commit is contained in:
@@ -0,0 +1,118 @@
|
|||||||
|
/// <reference types="bun-types" />
|
||||||
|
|
||||||
|
import { describe, expect, mock, test } from "bun:test"
|
||||||
|
|
||||||
|
import type { ToolContext } from "@opencode-ai/plugin/tool"
|
||||||
|
import { TeamModeConfigSchema } from "../../../config/schema/team-mode"
|
||||||
|
import type { OpencodeClient } from "../../../tools/delegate-task/types"
|
||||||
|
|
||||||
|
const mockClient = {} as OpencodeClient
|
||||||
|
|
||||||
|
let aggregateStatusImplementation: typeof import("../team-runtime/status").aggregateStatus = async () => {
|
||||||
|
throw new Error("aggregateStatusImplementation not set")
|
||||||
|
}
|
||||||
|
|
||||||
|
let discoverTeamSpecsImplementation: typeof import("../team-registry/paths").discoverTeamSpecs = async () => {
|
||||||
|
throw new Error("discoverTeamSpecsImplementation not set")
|
||||||
|
}
|
||||||
|
|
||||||
|
let loadTeamSpecImplementation: typeof import("../team-registry/loader").loadTeamSpec = async () => {
|
||||||
|
throw new Error("loadTeamSpecImplementation not set")
|
||||||
|
}
|
||||||
|
|
||||||
|
let listActiveTeamsImplementation: typeof import("../team-state-store/store").listActiveTeams = async () => {
|
||||||
|
throw new Error("listActiveTeamsImplementation not set")
|
||||||
|
}
|
||||||
|
|
||||||
|
mock.module("../team-runtime/status", () => ({
|
||||||
|
aggregateStatus: (...args: Parameters<typeof aggregateStatusImplementation>) => aggregateStatusImplementation(...args),
|
||||||
|
}))
|
||||||
|
|
||||||
|
mock.module("../team-registry/paths", () => ({
|
||||||
|
discoverTeamSpecs: (...args: Parameters<typeof discoverTeamSpecsImplementation>) => discoverTeamSpecsImplementation(...args),
|
||||||
|
}))
|
||||||
|
|
||||||
|
mock.module("../team-registry/loader", () => ({
|
||||||
|
loadTeamSpec: (...args: Parameters<typeof loadTeamSpecImplementation>) => loadTeamSpecImplementation(...args),
|
||||||
|
}))
|
||||||
|
|
||||||
|
mock.module("../team-state-store/store", () => ({
|
||||||
|
listActiveTeams: (...args: Parameters<typeof listActiveTeamsImplementation>) => listActiveTeamsImplementation(...args),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { createTeamListTool, createTeamStatusTool } from "./query"
|
||||||
|
|
||||||
|
function createMockContext(): ToolContext {
|
||||||
|
return {
|
||||||
|
sessionID: "session",
|
||||||
|
messageID: "message",
|
||||||
|
agent: "agent",
|
||||||
|
directory: "/tmp/team-mode",
|
||||||
|
worktree: "/tmp/team-mode",
|
||||||
|
abort: new AbortController().signal,
|
||||||
|
metadata: mock(() => {}),
|
||||||
|
ask: async () => undefined,
|
||||||
|
} satisfies ToolContext
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("query tools", () => {
|
||||||
|
test("team_status returns aggregated team status", async () => {
|
||||||
|
// given
|
||||||
|
const config = TeamModeConfigSchema.parse({ base_dir: "/tmp/team-mode" })
|
||||||
|
const expectedStatus = {
|
||||||
|
teamRunId: "team-run-1",
|
||||||
|
teamName: "team-alpha",
|
||||||
|
status: "active",
|
||||||
|
createdAt: 1,
|
||||||
|
members: [{ name: "worker", unreadMessages: 0 }],
|
||||||
|
tasks: { pending: 0, claimed: 0, in_progress: 0, completed: 0, deleted: 0, total: 0 },
|
||||||
|
shutdownRequests: [],
|
||||||
|
concurrency: { runningOnSameModel: 0, queuedOnSameModel: 0 },
|
||||||
|
bounds: { maxMembers: 8, maxParallelMembers: 4, maxMessagesPerRun: 10000, maxWallClockMinutes: 120, maxMemberTurns: 500 },
|
||||||
|
staleLocks: [],
|
||||||
|
} satisfies Awaited<ReturnType<typeof aggregateStatusImplementation>>
|
||||||
|
aggregateStatusImplementation = async (teamRunId, passedConfig) => {
|
||||||
|
expect(teamRunId).toBe("team-run-1")
|
||||||
|
expect(passedConfig).toBe(config)
|
||||||
|
return expectedStatus
|
||||||
|
}
|
||||||
|
const tool = createTeamStatusTool(config, mockClient)
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = JSON.parse(await tool.execute({ teamRunId: "team-run-1" }, createMockContext()))
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(result).toEqual(expectedStatus)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("team_list includes declared-only teams", async () => {
|
||||||
|
// given
|
||||||
|
const config = TeamModeConfigSchema.parse({ base_dir: "/tmp/team-mode" })
|
||||||
|
discoverTeamSpecsImplementation = async () => [
|
||||||
|
{ name: "foo", scope: "project", path: "/tmp/project/foo/config.json" },
|
||||||
|
]
|
||||||
|
loadTeamSpecImplementation = async (teamName) => {
|
||||||
|
expect(teamName).toBe("foo")
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
name: "foo",
|
||||||
|
createdAt: 1,
|
||||||
|
leadAgentId: "lead",
|
||||||
|
members: [{ kind: "category", name: "member-a", category: "agent", prompt: "do", backendType: "in-process", isActive: true }],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
listActiveTeamsImplementation = async () => [
|
||||||
|
{ teamRunId: "run-1", teamName: "bar", status: "active", memberCount: 3, scope: "user" },
|
||||||
|
]
|
||||||
|
const tool = createTeamListTool(config, mockClient)
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = JSON.parse(await tool.execute({}, createMockContext()))
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(result).toEqual([
|
||||||
|
{ name: "foo", scope: "project", status: "not-started", teamRunId: undefined, memberCount: 1 },
|
||||||
|
{ name: "bar", scope: "user", status: "active", teamRunId: "run-1", memberCount: 3 },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
|
||||||
|
|
||||||
|
import type { TeamModeConfig } from "../../../config/schema/team-mode"
|
||||||
|
import type { OpencodeClient } from "../../../tools/delegate-task/types"
|
||||||
|
import { loadTeamSpec } from "../team-registry/loader"
|
||||||
|
import { aggregateStatus } from "../team-runtime/status"
|
||||||
|
import { discoverTeamSpecs } from "../team-registry/paths"
|
||||||
|
import { listActiveTeams } from "../team-state-store/store"
|
||||||
|
|
||||||
|
type TeamListScope = "user" | "project" | "all"
|
||||||
|
|
||||||
|
type TeamListEntry = {
|
||||||
|
name: string
|
||||||
|
scope: "user" | "project"
|
||||||
|
status: string
|
||||||
|
teamRunId?: string
|
||||||
|
memberCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTeamStatusTool(
|
||||||
|
config: TeamModeConfig,
|
||||||
|
client: OpencodeClient,
|
||||||
|
backgroundManager?: Parameters<typeof aggregateStatus>[2],
|
||||||
|
): ToolDefinition {
|
||||||
|
void client
|
||||||
|
|
||||||
|
return tool({
|
||||||
|
description: "Return full status for a team run.",
|
||||||
|
args: {
|
||||||
|
teamRunId: tool.schema.string().describe("Team run ID"),
|
||||||
|
},
|
||||||
|
execute: async (args: { teamRunId: string }) => JSON.stringify(await aggregateStatus(args.teamRunId, config, backgroundManager)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTeamListTool(config: TeamModeConfig, client: OpencodeClient): ToolDefinition {
|
||||||
|
void client
|
||||||
|
|
||||||
|
return tool({
|
||||||
|
description: "List declared and active teams.",
|
||||||
|
args: {
|
||||||
|
scope: tool.schema.union([
|
||||||
|
tool.schema.literal("user"),
|
||||||
|
tool.schema.literal("project"),
|
||||||
|
tool.schema.literal("all"),
|
||||||
|
]).optional().describe("Team scope filter"),
|
||||||
|
},
|
||||||
|
execute: async (args: { scope?: TeamListScope }) => {
|
||||||
|
const scope = args.scope ?? "all"
|
||||||
|
const projectRoot = process.cwd()
|
||||||
|
const declaredTeamSpecs = await discoverTeamSpecs(config, projectRoot)
|
||||||
|
const activeTeams = await listActiveTeams(config)
|
||||||
|
|
||||||
|
const filteredDeclaredTeamSpecs = scope === "all"
|
||||||
|
? declaredTeamSpecs
|
||||||
|
: declaredTeamSpecs.filter((teamSpec) => teamSpec.scope === scope)
|
||||||
|
|
||||||
|
const declaredTeamSpecsByName = new Map(
|
||||||
|
await Promise.all(filteredDeclaredTeamSpecs.map(async (teamSpec) => {
|
||||||
|
const loadedTeamSpec = await loadTeamSpec(teamSpec.name, config, projectRoot)
|
||||||
|
return [teamSpec.name, loadedTeamSpec.members.length] as const
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
const activeTeamsByName = new Map(activeTeams.map((team) => [team.teamName, team]))
|
||||||
|
|
||||||
|
const teamEntries: TeamListEntry[] = []
|
||||||
|
|
||||||
|
for (const declaredTeamSpec of filteredDeclaredTeamSpecs) {
|
||||||
|
const activeTeam = activeTeamsByName.get(declaredTeamSpec.name)
|
||||||
|
const declaredTeamSpecMemberCount = declaredTeamSpecsByName.get(declaredTeamSpec.name)
|
||||||
|
teamEntries.push({
|
||||||
|
name: declaredTeamSpec.name,
|
||||||
|
scope: declaredTeamSpec.scope,
|
||||||
|
status: activeTeam?.status ?? "not-started",
|
||||||
|
teamRunId: activeTeam?.teamRunId,
|
||||||
|
memberCount: activeTeam?.memberCount ?? declaredTeamSpecMemberCount ?? 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const activeTeam of activeTeams) {
|
||||||
|
if (declaredTeamSpecsByName.has(activeTeam.teamName)) continue
|
||||||
|
|
||||||
|
teamEntries.push({
|
||||||
|
name: activeTeam.teamName,
|
||||||
|
scope: activeTeam.scope,
|
||||||
|
status: activeTeam.status,
|
||||||
|
teamRunId: activeTeam.teamRunId,
|
||||||
|
memberCount: activeTeam.memberCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify(teamEntries)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user