feat(team-mode): add core types, dependencies and type-level tests
This commit is contained in:
@@ -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<TeamModeDependencyReport> {
|
||||||
|
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<boolean> {
|
||||||
|
try {
|
||||||
|
const proc = Bun.spawn({ cmd: [cmd, ...args], stdout: "pipe", stderr: "pipe" })
|
||||||
|
const code = await proc.exited
|
||||||
|
return code === 0
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,9 @@ import {
|
|||||||
AGENT_ELIGIBILITY_REGISTRY,
|
AGENT_ELIGIBILITY_REGISTRY,
|
||||||
CategoryMemberSchema,
|
CategoryMemberSchema,
|
||||||
MemberSchema,
|
MemberSchema,
|
||||||
|
parseMember,
|
||||||
SubagentMemberSchema,
|
SubagentMemberSchema,
|
||||||
|
TeamSpecSchema,
|
||||||
} from "./types"
|
} from "./types"
|
||||||
|
|
||||||
describe("team-mode types", () => {
|
describe("team-mode types", () => {
|
||||||
@@ -39,6 +41,150 @@ describe("team-mode types", () => {
|
|||||||
expect(result.success).toBe(false)
|
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", () => {
|
test("category requires prompt", () => {
|
||||||
// given
|
// given
|
||||||
const member = { kind: "category", name: "m1", category: "deep" }
|
const member = { kind: "category", name: "m1", category: "deep" }
|
||||||
@@ -50,6 +196,58 @@ describe("team-mode types", () => {
|
|||||||
expect(result.success).toBe(false)
|
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", () => {
|
test("eligibility registry shape", () => {
|
||||||
// given
|
// given
|
||||||
const entries = Object.entries(AGENT_ELIGIBILITY_REGISTRY)
|
const entries = Object.entries(AGENT_ELIGIBILITY_REGISTRY)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
|
import { createParseMember } from "./member-parser"
|
||||||
|
|
||||||
export const MESSAGE_KINDS = [
|
export const MESSAGE_KINDS = [
|
||||||
"message",
|
"message",
|
||||||
@@ -51,15 +52,39 @@ const TeamReferenceSchema = z.object({
|
|||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
}).strict()
|
}).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({
|
export const TeamSpecSchema = z.object({
|
||||||
version: z.literal(1),
|
version: z.literal(1).default(1),
|
||||||
name: z.string().min(1).regex(/^[a-z0-9-]+$/),
|
name: z.string().min(1).regex(/^[a-z0-9-]+$/),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
createdAt: z.number().int().positive(),
|
createdAt: z.number().int().positive().default(() => Date.now()),
|
||||||
leadAgentId: z.string(),
|
leadAgentId: z.string().optional(),
|
||||||
teamAllowedPaths: z.array(z.string()).optional(),
|
teamAllowedPaths: z.array(z.string()).optional(),
|
||||||
sessionPermission: z.string().optional(),
|
sessionPermission: z.string().optional(),
|
||||||
members: z.array(MemberSchema).min(1).max(8),
|
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({
|
export const MessageSchema = z.object({
|
||||||
@@ -92,11 +117,29 @@ export const TaskSchema = z.object({
|
|||||||
claimedAt: z.number().int().positive().optional(),
|
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({
|
const RuntimeStateMemberSchema = z.object({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
sessionId: z.string().optional(),
|
sessionId: z.string().optional(),
|
||||||
tmuxPaneId: z.string().optional(),
|
tmuxPaneId: z.string().optional(),
|
||||||
|
tmuxGridPaneId: z.string().optional(),
|
||||||
agentType: z.enum(["leader", "general-purpose"]),
|
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"]),
|
status: z.enum(["pending", "running", "idle", "errored", "completed", "shutdown_approved"]),
|
||||||
color: z.string().optional(),
|
color: z.string().optional(),
|
||||||
worktreePath: z.string().optional(),
|
worktreePath: z.string().optional(),
|
||||||
@@ -114,9 +157,18 @@ const RuntimeBoundsSchema = z.object({
|
|||||||
|
|
||||||
const ShutdownRequestSchema = z.object({
|
const ShutdownRequestSchema = z.object({
|
||||||
memberId: z.string(),
|
memberId: z.string(),
|
||||||
|
requesterName: z.string(),
|
||||||
requestedAt: z.number().int().positive(),
|
requestedAt: z.number().int().positive(),
|
||||||
approvedAt: z.number().int().positive().optional(),
|
approvedAt: z.number().int().positive().optional(),
|
||||||
rejectedReason: z.string().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()
|
}).strict()
|
||||||
|
|
||||||
export const RuntimeStateSchema = z.object({
|
export const RuntimeStateSchema = z.object({
|
||||||
@@ -127,6 +179,7 @@ export const RuntimeStateSchema = z.object({
|
|||||||
createdAt: z.number().int().positive(),
|
createdAt: z.number().int().positive(),
|
||||||
status: z.enum(RUNTIME_STATUSES),
|
status: z.enum(RUNTIME_STATUSES),
|
||||||
leadSessionId: z.string().optional(),
|
leadSessionId: z.string().optional(),
|
||||||
|
tmuxLayout: RuntimeStateTmuxLayoutSchema.optional(),
|
||||||
members: z.array(RuntimeStateMemberSchema),
|
members: z.array(RuntimeStateMemberSchema),
|
||||||
shutdownRequests: z.array(ShutdownRequestSchema).default([]),
|
shutdownRequests: z.array(ShutdownRequestSchema).default([]),
|
||||||
bounds: RuntimeBoundsSchema,
|
bounds: RuntimeBoundsSchema,
|
||||||
@@ -181,10 +234,38 @@ export const AGENT_ELIGIBILITY_REGISTRY: Readonly<Record<string, {
|
|||||||
"sisyphus-junior": { verdict: "eligible" },
|
"sisyphus-junior": { verdict: "eligible" },
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* §V.3 member validation error messages live in member-parser.ts.
|
||||||
|
* Includes: "Unknown subagent_type '<name>'. 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<string, unknown>
|
||||||
|
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<typeof TeamSpecSchema>
|
export type TeamSpec = z.infer<typeof TeamSpecSchema>
|
||||||
export type Member = z.infer<typeof MemberSchema>
|
export type Member = z.infer<typeof MemberSchema>
|
||||||
export type CategoryMember = z.infer<typeof CategoryMemberSchema>
|
export type CategoryMember = z.infer<typeof CategoryMemberSchema>
|
||||||
export type SubagentMember = z.infer<typeof SubagentMemberSchema>
|
export type SubagentMember = z.infer<typeof SubagentMemberSchema>
|
||||||
export type Message = z.infer<typeof MessageSchema>
|
export type Message = z.infer<typeof MessageSchema>
|
||||||
export type Task = z.infer<typeof TaskSchema>
|
export type Task = z.infer<typeof TaskSchema>
|
||||||
|
export type RuntimeStateMember = z.infer<typeof RuntimeStateMemberSchema>
|
||||||
export type RuntimeState = z.infer<typeof RuntimeStateSchema>
|
export type RuntimeState = z.infer<typeof RuntimeStateSchema>
|
||||||
|
|||||||
Reference in New Issue
Block a user