From b71c6e1905ee399a64f5112c767a773c566c04db Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 28 Apr 2026 10:47:03 +0900 Subject: [PATCH] feat(team-mode): add team query tools with tests --- src/features/team-mode/tools/query.test.ts | 118 +++++++++++++++++++++ src/features/team-mode/tools/query.ts | 96 +++++++++++++++++ 2 files changed, 214 insertions(+) create mode 100644 src/features/team-mode/tools/query.test.ts create mode 100644 src/features/team-mode/tools/query.ts diff --git a/src/features/team-mode/tools/query.test.ts b/src/features/team-mode/tools/query.test.ts new file mode 100644 index 000000000..23ddc90b1 --- /dev/null +++ b/src/features/team-mode/tools/query.test.ts @@ -0,0 +1,118 @@ +/// + +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) => aggregateStatusImplementation(...args), +})) + +mock.module("../team-registry/paths", () => ({ + discoverTeamSpecs: (...args: Parameters) => discoverTeamSpecsImplementation(...args), +})) + +mock.module("../team-registry/loader", () => ({ + loadTeamSpec: (...args: Parameters) => loadTeamSpecImplementation(...args), +})) + +mock.module("../team-state-store/store", () => ({ + listActiveTeams: (...args: Parameters) => 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> + 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 }, + ]) + }) +}) diff --git a/src/features/team-mode/tools/query.ts b/src/features/team-mode/tools/query.ts new file mode 100644 index 000000000..7718a4231 --- /dev/null +++ b/src/features/team-mode/tools/query.ts @@ -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[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) + }, + }) +}