feat(team-mode): add team query tools with tests

This commit is contained in:
YeonGyu-Kim
2026-04-28 10:47:03 +09:00
parent 379b3fde1f
commit b71c6e1905
2 changed files with 214 additions and 0 deletions
+118
View File
@@ -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 },
])
})
})
+96
View File
@@ -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)
},
})
}