From 783d70558b68d809bf34271e1e97738ff7739214 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 28 Apr 2026 10:45:33 +0900 Subject: [PATCH] feat(team-mode): add core types, dependencies and type-level tests --- src/features/team-mode/deps.ts | 29 ++++ src/features/team-mode/types.test.ts | 198 +++++++++++++++++++++++++++ src/features/team-mode/types.ts | 87 +++++++++++- 3 files changed, 311 insertions(+), 3 deletions(-) create mode 100644 src/features/team-mode/deps.ts diff --git a/src/features/team-mode/deps.ts b/src/features/team-mode/deps.ts new file mode 100644 index 000000000..25db5f966 --- /dev/null +++ b/src/features/team-mode/deps.ts @@ -0,0 +1,29 @@ +import type { TeamModeConfig } from "../../config/schema/team-mode" + +export interface TeamModeDependencyReport { + tmuxAvailable: boolean + gitAvailable: boolean +} + +export async function checkTeamModeDependencies( + config: TeamModeConfig, +): Promise { + const tmuxAvailable = Boolean(process.env["TMUX"]) || (await probeBinary("tmux", ["-V"])) + const gitAvailable = await probeBinary("git", ["--version"]) + if (config.tmux_visualization && !tmuxAvailable) { + console.warn( + "[team-mode] tmux_visualization=true but tmux not available; layout will be skipped at runtime", + ) + } + return { tmuxAvailable, gitAvailable } +} + +async function probeBinary(cmd: string, args: string[]): Promise { + try { + const proc = Bun.spawn({ cmd: [cmd, ...args], stdout: "pipe", stderr: "pipe" }) + const code = await proc.exited + return code === 0 + } catch { + return false + } +} diff --git a/src/features/team-mode/types.test.ts b/src/features/team-mode/types.test.ts index 042f220e5..0a69c0491 100644 --- a/src/features/team-mode/types.test.ts +++ b/src/features/team-mode/types.test.ts @@ -3,7 +3,9 @@ import { AGENT_ELIGIBILITY_REGISTRY, CategoryMemberSchema, MemberSchema, + parseMember, SubagentMemberSchema, + TeamSpecSchema, } from "./types" describe("team-mode types", () => { @@ -39,6 +41,150 @@ describe("team-mode types", () => { expect(result.success).toBe(false) }) + test("parseMember emits exact both kinds error", () => { + // given + const member = { + name: "m1", + kind: "category", + category: "deep", + subagent_type: "sisyphus", + prompt: "impl X", + } + + // when + try { + parseMember(member) + } catch (error) { + // then + expect(error instanceof Error ? error.message : String(error)).toBe( + "Member 'm1' specifies both 'category' and 'subagent_type'. Must specify exactly one via 'kind' discriminator.", + ) + } + }) + + test("parseMember emits exact missing kind error", () => { + // given + const member = { name: "m1" } + + // when + try { + parseMember(member) + } catch (error) { + // then + expect(error instanceof Error ? error.message : String(error)).toBe( + "Member 'm1' missing 'kind' discriminator. Specify either {kind:'category', category, prompt} or {kind:'subagent_type', subagent_type}.", + ) + } + }) + + test("parseMember emits exact category missing prompt error", () => { + // given + const member = { name: "m1", kind: "category", category: "deep" } + + // when + try { + parseMember(member) + } catch (error) { + // then + expect(error instanceof Error ? error.message : String(error)).toBe( + "Member 'm1' uses category 'deep' but is missing required 'prompt' field. Category members must supply a task prompt.", + ) + } + }) + + test("parseMember emits exact unknown subagent error", () => { + // given + const member = { name: "m1", kind: "subagent_type", subagent_type: "foobar" } + + // when + try { + parseMember(member) + } catch (error) { + // then + expect(error instanceof Error ? error.message : String(error)).toBe( + "Unknown subagent_type 'foobar'. Available ELIGIBLE agents: sisyphus, atlas, sisyphus-junior, hephaestus (if D-36 applied). Use delegate-task for read-only agents like oracle, librarian, explore, metis, momus, multimodal-looker.", + ) + } + }) + + test("parseMember rejects hard-reject subagent types with exact messages", () => { + // given + const cases = [ + [ + "oracle", + "Agent 'oracle' is read-only (cannot write files). Team members must write to mailbox inbox files. Use delegate-task with subagent_type: 'oracle' for read-only analysis instead.", + ], + [ + "librarian", + "Agent 'librarian' is read-only (write/edit denied). Cannot write to mailbox as team member. Use delegate-task for research queries instead.", + ], + [ + "explore", + "Agent 'explore' is read-only (write/edit denied). Cannot write to mailbox as team member. Use delegate-task for codebase exploration instead.", + ], + [ + "multimodal-looker", + "Agent 'multimodal-looker' has read-only tool access (only 'read' allowed). Cannot write to mailbox as team member.", + ], + [ + "metis", + "Agent 'metis' is read-only (pre-planning consultant). Cannot write to mailbox as team member. Use delegate-task for pre-planning analysis instead.", + ], + [ + "momus", + "Agent 'momus' is read-only (plan reviewer). Cannot write to mailbox as team member. Use delegate-task for plan review instead.", + ], + [ + "prometheus", + "Agent 'prometheus' is plan-mode-only; can only write to .sisyphus/*.md (enforced by prometheusMdOnly hook). Cannot write to team mailbox. Use category: 'plan' instead.", + ], + ] as const + + // when + for (const [subagentType, expectedMessage] of cases) { + // then + expect(() => + parseMember({ kind: "subagent_type", name: "x", subagent_type: subagentType }), + ).toThrow(expectedMessage) + } + }) + + test("parseMember returns valid category member", () => { + // given + const member = { name: "m1", kind: "category", category: "deep", prompt: "impl X" } + + // when + const result = parseMember(member) + + // then + expect(result).toMatchObject(member) + }) + + test("parseMember returns valid subagent member", () => { + // given + const member = { name: "m1", kind: "subagent_type", subagent_type: "sisyphus" } + + // when + const result = parseMember(member) + + // then + expect(result).toMatchObject(member) + }) + + test("parseMember returns parsed hephaestus and atlas subagent members", () => { + // given + const hephaestusMember = { name: "m1", kind: "subagent_type", subagent_type: "hephaestus" } + const atlasMember = { name: "m1", kind: "subagent_type", subagent_type: "atlas" } + + // when + const hephaestusResult = parseMember(hephaestusMember) + const atlasResult = parseMember(atlasMember) + + // then + expect(hephaestusResult).toMatchObject(hephaestusMember) + expect(atlasResult).toMatchObject(atlasMember) + }) + test("category requires prompt", () => { // given const member = { kind: "category", name: "m1", category: "deep" } @@ -50,6 +196,58 @@ describe("team-mode types", () => { expect(result.success).toBe(false) }) + test("team spec defaults version when omitted", () => { + // given + const teamSpec = { name: "solo-team", members: [{ kind: "category", name: "solo", category: "deep", prompt: "implement the assigned work" }] } + + // when + const result = TeamSpecSchema.parse(teamSpec) + + // then + expect(result.version).toBe(1) + expect(result.leadAgentId).toBe("solo") + }) + + test("team spec defaults createdAt from Date.now when omitted", () => { + // given + const originalDateNow = Date.now + Date.now = () => 123_456_789 + const teamSpec = { name: "solo-team", members: [{ kind: "category", name: "solo", category: "deep", prompt: "implement the assigned work" }] } + + try { + // when + const result = TeamSpecSchema.parse(teamSpec) + + // then + expect(result.createdAt).toBe(123_456_789) + } finally { + Date.now = originalDateNow + } + }) + + test("team spec rejects multi-member configs without a lead hint", () => { + // given + const teamSpec = { + name: "pair-team", + members: [ + { kind: "category", name: "m1", category: "deep", prompt: "implement the assigned work" }, + { kind: "category", name: "m2", category: "quick", prompt: "review the assigned work" }, + ], + } + + // when + const result = TeamSpecSchema.safeParse(teamSpec) + + // then + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues).toContainEqual(expect.objectContaining({ + path: ["leadAgentId"], + message: "leadAgentId required (or write a `lead: {...}` field, or mark one member with `isLead: true`)", + })) + } + }) + test("eligibility registry shape", () => { // given const entries = Object.entries(AGENT_ELIGIBILITY_REGISTRY) diff --git a/src/features/team-mode/types.ts b/src/features/team-mode/types.ts index 2b2da3f2a..ba37c03e8 100644 --- a/src/features/team-mode/types.ts +++ b/src/features/team-mode/types.ts @@ -1,4 +1,5 @@ import { z } from "zod" +import { createParseMember } from "./member-parser" export const MESSAGE_KINDS = [ "message", @@ -51,15 +52,39 @@ const TeamReferenceSchema = z.object({ description: z.string().optional(), }).strict() +const MISSING_TEAM_LEAD_MESSAGE = "leadAgentId required (or write a `lead: {...}` field, or mark one member with `isLead: true`)" + export const TeamSpecSchema = z.object({ - version: z.literal(1), + version: z.literal(1).default(1), name: z.string().min(1).regex(/^[a-z0-9-]+$/), description: z.string().optional(), - createdAt: z.number().int().positive(), - leadAgentId: z.string(), + createdAt: z.number().int().positive().default(() => Date.now()), + leadAgentId: z.string().optional(), teamAllowedPaths: z.array(z.string()).optional(), sessionPermission: z.string().optional(), members: z.array(MemberSchema).min(1).max(8), +}).superRefine((teamSpec, ctx) => { + if (teamSpec.leadAgentId === undefined && teamSpec.members.length > 1) { + ctx.addIssue({ + code: "custom", + message: MISSING_TEAM_LEAD_MESSAGE, + path: ["leadAgentId"], + }) + } +}).transform((teamSpec) => { + if (teamSpec.leadAgentId !== undefined) { + return teamSpec + } + + const firstMember = teamSpec.members[0] + if (!firstMember) { + throw new Error(MISSING_TEAM_LEAD_MESSAGE) + } + + return { + ...teamSpec, + leadAgentId: firstMember.name, + } }) export const MessageSchema = z.object({ @@ -92,11 +117,29 @@ export const TaskSchema = z.object({ claimedAt: z.number().int().positive().optional(), }) +const RuntimeStateMemberModelSchema = z.object({ + providerID: z.string(), + modelID: z.string(), + variant: z.string().optional(), + reasoningEffort: z.string().optional(), + temperature: z.number().optional(), + top_p: z.number().optional(), + maxTokens: z.number().optional(), + thinking: z.object({ + type: z.enum(["enabled", "disabled"]), + budgetTokens: z.number().int().positive().optional(), + }).optional(), +}).strict() + const RuntimeStateMemberSchema = z.object({ name: z.string(), sessionId: z.string().optional(), tmuxPaneId: z.string().optional(), + tmuxGridPaneId: z.string().optional(), agentType: z.enum(["leader", "general-purpose"]), + subagent_type: z.string().optional(), + category: z.string().optional(), + model: RuntimeStateMemberModelSchema.optional(), status: z.enum(["pending", "running", "idle", "errored", "completed", "shutdown_approved"]), color: z.string().optional(), worktreePath: z.string().optional(), @@ -114,9 +157,18 @@ const RuntimeBoundsSchema = z.object({ const ShutdownRequestSchema = z.object({ memberId: z.string(), + requesterName: z.string(), requestedAt: z.number().int().positive(), approvedAt: z.number().int().positive().optional(), rejectedReason: z.string().optional(), + rejectedAt: z.number().int().positive().optional(), +}).strict() + +const RuntimeStateTmuxLayoutSchema = z.object({ + ownedSession: z.boolean(), + targetSessionId: z.string(), + focusWindowId: z.string().optional(), + gridWindowId: z.string().optional(), }).strict() export const RuntimeStateSchema = z.object({ @@ -127,6 +179,7 @@ export const RuntimeStateSchema = z.object({ createdAt: z.number().int().positive(), status: z.enum(RUNTIME_STATUSES), leadSessionId: z.string().optional(), + tmuxLayout: RuntimeStateTmuxLayoutSchema.optional(), members: z.array(RuntimeStateMemberSchema), shutdownRequests: z.array(ShutdownRequestSchema).default([]), bounds: RuntimeBoundsSchema, @@ -181,10 +234,38 @@ export const AGENT_ELIGIBILITY_REGISTRY: Readonly'. Available ELIGIBLE agents: sisyphus, atlas, sisyphus-junior, hephaestus (if D-36 applied). Use delegate-task for read-only agents like oracle, librarian, explore, metis, momus, multimodal-looker." + */ + +const parseMemberBase = createParseMember(MemberSchema, AGENT_ELIGIBILITY_REGISTRY) + +export function parseMember(input: unknown): Member { + if (input == null || typeof input !== "object") { + return parseMemberBase(input) + } + + const raw = input as Record + if (raw.subagent_type !== undefined) { + if (typeof raw.subagent_type !== "string" || !(raw.subagent_type in AGENT_ELIGIBILITY_REGISTRY)) { + return parseMemberBase(input) + } + + const entry = AGENT_ELIGIBILITY_REGISTRY[raw.subagent_type] + if (entry.verdict === "hard-reject") { + throw new Error(entry.rejectionMessage) + } + } + + return parseMemberBase(input) +} + export type TeamSpec = z.infer export type Member = z.infer export type CategoryMember = z.infer export type SubagentMember = z.infer export type Message = z.infer export type Task = z.infer +export type RuntimeStateMember = z.infer export type RuntimeState = z.infer