feat(team-mode): add core types (discriminatedUnion for members, D-41/D-42)
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
bun test v1.3.12 (700fc117)
|
||||||
|
|
||||||
|
1 pass
|
||||||
|
3 filtered out
|
||||||
|
0 fail
|
||||||
|
1 expect() calls
|
||||||
|
Ran 1 test across 1 file. [64.00ms]
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
bun test v1.3.12 (700fc117)
|
||||||
|
|
||||||
|
1 pass
|
||||||
|
3 filtered out
|
||||||
|
0 fail
|
||||||
|
1 expect() calls
|
||||||
|
Ran 1 test across 1 file. [65.00ms]
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
bun test v1.3.12 (700fc117)
|
||||||
|
|
||||||
|
1 pass
|
||||||
|
3 filtered out
|
||||||
|
0 fail
|
||||||
|
3 expect() calls
|
||||||
|
Ran 1 test across 1 file. [69.00ms]
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
bun test v1.3.12 (700fc117)
|
||||||
|
|
||||||
|
1 pass
|
||||||
|
3 filtered out
|
||||||
|
0 fail
|
||||||
|
12 expect() calls
|
||||||
|
Ran 1 test across 1 file. [61.00ms]
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
## 2026-04-18 Task 2: types module
|
||||||
|
|
||||||
|
- `MemberSchema` needs `.strict()` on the base shape so the discriminatedUnion rejects members that mix `category` and `subagent_type`.
|
||||||
|
- `backendType` and `isActive` defaults are part of the schema contract, so tests should use `toMatchObject` instead of exact object equality.
|
||||||
|
- The eligibility registry must preserve the plan strings verbatim, especially the hard-reject messages for Momus verification.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./types"
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import {
|
||||||
|
AGENT_ELIGIBILITY_REGISTRY,
|
||||||
|
CategoryMemberSchema,
|
||||||
|
MemberSchema,
|
||||||
|
SubagentMemberSchema,
|
||||||
|
} from "./types"
|
||||||
|
|
||||||
|
describe("team-mode types", () => {
|
||||||
|
test("member category branch parses and narrows", () => {
|
||||||
|
// given
|
||||||
|
const member = { kind: "category", name: "m1", category: "deep", prompt: "impl X" }
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = MemberSchema.safeParse(member)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.data).toMatchObject(member)
|
||||||
|
expect(result.data).toMatchObject({ kind: "category", category: "deep" })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("both kinds rejected", () => {
|
||||||
|
// given
|
||||||
|
const member = {
|
||||||
|
kind: "category",
|
||||||
|
name: "m1",
|
||||||
|
category: "deep",
|
||||||
|
subagent_type: "sisyphus",
|
||||||
|
prompt: "impl X",
|
||||||
|
}
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = MemberSchema.safeParse(member)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("category requires prompt", () => {
|
||||||
|
// given
|
||||||
|
const member = { kind: "category", name: "m1", category: "deep" }
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = CategoryMemberSchema.safeParse(member)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("eligibility registry shape", () => {
|
||||||
|
// given
|
||||||
|
const entries = Object.entries(AGENT_ELIGIBILITY_REGISTRY)
|
||||||
|
|
||||||
|
// when
|
||||||
|
const verdictCounts = entries.reduce(
|
||||||
|
(counts, [, value]) => {
|
||||||
|
counts[value.verdict] += 1
|
||||||
|
return counts
|
||||||
|
},
|
||||||
|
{ eligible: 0, conditional: 0, "hard-reject": 0 },
|
||||||
|
)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(entries).toHaveLength(11)
|
||||||
|
expect(verdictCounts).toEqual({ eligible: 3, conditional: 1, "hard-reject": 7 })
|
||||||
|
expect(AGENT_ELIGIBILITY_REGISTRY.hephaestus.rejectionMessage).toBe(
|
||||||
|
"Agent 'hephaestus' lacks teammate permission. Either apply D-36 (add teammate: \"allow\" in tool-config-handler.ts) or use subagent_type: \"sisyphus\" instead.",
|
||||||
|
)
|
||||||
|
expect(AGENT_ELIGIBILITY_REGISTRY.oracle.rejectionMessage).toBe(
|
||||||
|
"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.",
|
||||||
|
)
|
||||||
|
expect(AGENT_ELIGIBILITY_REGISTRY.librarian.rejectionMessage).toBe(
|
||||||
|
"Agent 'librarian' is read-only (write/edit denied). Cannot write to mailbox as team member. Use delegate-task for research queries instead.",
|
||||||
|
)
|
||||||
|
expect(AGENT_ELIGIBILITY_REGISTRY.explore.rejectionMessage).toBe(
|
||||||
|
"Agent 'explore' is read-only (write/edit denied). Cannot write to mailbox as team member. Use delegate-task for codebase exploration instead.",
|
||||||
|
)
|
||||||
|
expect(AGENT_ELIGIBILITY_REGISTRY["multimodal-looker"].rejectionMessage).toBe(
|
||||||
|
"Agent 'multimodal-looker' has read-only tool access (only 'read' allowed). Cannot write to mailbox as team member.",
|
||||||
|
)
|
||||||
|
expect(AGENT_ELIGIBILITY_REGISTRY.metis.rejectionMessage).toBe(
|
||||||
|
"Agent 'metis' is read-only (pre-planning consultant). Cannot write to mailbox as team member. Use delegate-task for pre-planning analysis instead.",
|
||||||
|
)
|
||||||
|
expect(AGENT_ELIGIBILITY_REGISTRY.momus.rejectionMessage).toBe(
|
||||||
|
"Agent 'momus' is read-only (plan reviewer). Cannot write to mailbox as team member. Use delegate-task for plan review instead.",
|
||||||
|
)
|
||||||
|
expect(AGENT_ELIGIBILITY_REGISTRY.prometheus.rejectionMessage).toBe(
|
||||||
|
"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.",
|
||||||
|
)
|
||||||
|
expect(CategoryMemberSchema).toBeDefined()
|
||||||
|
expect(SubagentMemberSchema).toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
export const MESSAGE_KINDS = [
|
||||||
|
"message",
|
||||||
|
"shutdown_request",
|
||||||
|
"shutdown_approved",
|
||||||
|
"shutdown_rejected",
|
||||||
|
"announcement",
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export const MEMBER_KINDS = ["category", "subagent_type"] as const
|
||||||
|
|
||||||
|
export const TASK_STATUSES = ["pending", "claimed", "in_progress", "completed", "deleted"] as const
|
||||||
|
|
||||||
|
export const RUNTIME_STATUSES = [
|
||||||
|
"creating",
|
||||||
|
"active",
|
||||||
|
"shutdown_requested",
|
||||||
|
"deleting",
|
||||||
|
"deleted",
|
||||||
|
"failed",
|
||||||
|
"orphaned",
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const MemberBaseSchema = z.object({
|
||||||
|
name: z.string().min(1).regex(/^[a-z0-9-]+$/),
|
||||||
|
cwd: z.string().optional(),
|
||||||
|
worktreePath: z.string().optional(),
|
||||||
|
subscriptions: z.array(z.string()).optional(),
|
||||||
|
backendType: z.enum(["in-process", "tmux"]).default("in-process"),
|
||||||
|
color: z.string().optional(),
|
||||||
|
isActive: z.boolean().default(true),
|
||||||
|
}).strict()
|
||||||
|
|
||||||
|
export const CategoryMemberSchema = MemberBaseSchema.extend({
|
||||||
|
kind: z.literal("category"),
|
||||||
|
category: z.string().min(1),
|
||||||
|
prompt: z.string().min(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const SubagentMemberSchema = MemberBaseSchema.extend({
|
||||||
|
kind: z.literal("subagent_type"),
|
||||||
|
subagent_type: z.string().min(1),
|
||||||
|
prompt: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const MemberSchema = z.discriminatedUnion("kind", [CategoryMemberSchema, SubagentMemberSchema])
|
||||||
|
|
||||||
|
const TeamReferenceSchema = z.object({
|
||||||
|
path: z.string(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
}).strict()
|
||||||
|
|
||||||
|
export const TeamSpecSchema = z.object({
|
||||||
|
version: z.literal(1),
|
||||||
|
name: z.string().min(1).regex(/^[a-z0-9-]+$/),
|
||||||
|
description: z.string().optional(),
|
||||||
|
createdAt: z.number().int().positive(),
|
||||||
|
leadAgentId: z.string(),
|
||||||
|
teamAllowedPaths: z.array(z.string()).optional(),
|
||||||
|
sessionPermission: z.string().optional(),
|
||||||
|
members: z.array(MemberSchema).min(1).max(8),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const MessageSchema = z.object({
|
||||||
|
version: z.literal(1),
|
||||||
|
messageId: z.string().uuid(),
|
||||||
|
from: z.string(),
|
||||||
|
to: z.string(),
|
||||||
|
kind: z.enum(MESSAGE_KINDS),
|
||||||
|
body: z.string().max(32 * 1024),
|
||||||
|
summary: z.string().optional(),
|
||||||
|
references: z.array(TeamReferenceSchema).optional(),
|
||||||
|
timestamp: z.number().int().positive(),
|
||||||
|
correlationId: z.string().uuid().optional(),
|
||||||
|
color: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const TaskSchema = z.object({
|
||||||
|
version: z.literal(1),
|
||||||
|
id: z.string(),
|
||||||
|
subject: z.string(),
|
||||||
|
description: z.string(),
|
||||||
|
activeForm: z.string().optional(),
|
||||||
|
status: z.enum(TASK_STATUSES),
|
||||||
|
owner: z.string().optional(),
|
||||||
|
blocks: z.array(z.string()).default([]),
|
||||||
|
blockedBy: z.array(z.string()).default([]),
|
||||||
|
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||||
|
createdAt: z.number().int().positive(),
|
||||||
|
updatedAt: z.number().int().positive(),
|
||||||
|
claimedAt: z.number().int().positive().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const RuntimeStateMemberSchema = z.object({
|
||||||
|
name: z.string(),
|
||||||
|
sessionId: z.string().optional(),
|
||||||
|
tmuxPaneId: z.string().optional(),
|
||||||
|
agentType: z.enum(["leader", "general-purpose"]),
|
||||||
|
status: z.enum(["pending", "running", "idle", "errored", "completed", "shutdown_approved"]),
|
||||||
|
color: z.string().optional(),
|
||||||
|
worktreePath: z.string().optional(),
|
||||||
|
lastInjectedTurnMarker: z.string().optional(),
|
||||||
|
pendingInjectedMessageIds: z.array(z.string()).default([]),
|
||||||
|
}).strict()
|
||||||
|
|
||||||
|
const RuntimeBoundsSchema = z.object({
|
||||||
|
maxMembers: z.number().int().default(8),
|
||||||
|
maxParallelMembers: z.number().int().default(4),
|
||||||
|
maxMessagesPerRun: z.number().int().default(10000),
|
||||||
|
maxWallClockMinutes: z.number().int().default(120),
|
||||||
|
maxMemberTurns: z.number().int().default(500),
|
||||||
|
}).strict()
|
||||||
|
|
||||||
|
const ShutdownRequestSchema = z.object({
|
||||||
|
memberId: z.string(),
|
||||||
|
requestedAt: z.number().int().positive(),
|
||||||
|
approvedAt: z.number().int().positive().optional(),
|
||||||
|
rejectedReason: z.string().optional(),
|
||||||
|
}).strict()
|
||||||
|
|
||||||
|
export const RuntimeStateSchema = z.object({
|
||||||
|
version: z.literal(1),
|
||||||
|
teamRunId: z.string().uuid(),
|
||||||
|
teamName: z.string(),
|
||||||
|
specSource: z.enum(["project", "user"]),
|
||||||
|
createdAt: z.number().int().positive(),
|
||||||
|
status: z.enum(RUNTIME_STATUSES),
|
||||||
|
leadSessionId: z.string().optional(),
|
||||||
|
members: z.array(RuntimeStateMemberSchema),
|
||||||
|
shutdownRequests: z.array(ShutdownRequestSchema).default([]),
|
||||||
|
bounds: RuntimeBoundsSchema,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const AGENT_ELIGIBILITY_REGISTRY: Readonly<Record<string, {
|
||||||
|
verdict: "eligible" | "conditional" | "hard-reject"
|
||||||
|
rejectionMessage?: string
|
||||||
|
}>> = {
|
||||||
|
sisyphus: { verdict: "eligible" },
|
||||||
|
hephaestus: {
|
||||||
|
verdict: "conditional",
|
||||||
|
rejectionMessage:
|
||||||
|
"Agent 'hephaestus' lacks teammate permission. Either apply D-36 (add teammate: \"allow\" in tool-config-handler.ts) or use subagent_type: \"sisyphus\" instead.",
|
||||||
|
},
|
||||||
|
oracle: {
|
||||||
|
verdict: "hard-reject",
|
||||||
|
rejectionMessage:
|
||||||
|
"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: {
|
||||||
|
verdict: "hard-reject",
|
||||||
|
rejectionMessage:
|
||||||
|
"Agent 'librarian' is read-only (write/edit denied). Cannot write to mailbox as team member. Use delegate-task for research queries instead.",
|
||||||
|
},
|
||||||
|
explore: {
|
||||||
|
verdict: "hard-reject",
|
||||||
|
rejectionMessage:
|
||||||
|
"Agent 'explore' is read-only (write/edit denied). Cannot write to mailbox as team member. Use delegate-task for codebase exploration instead.",
|
||||||
|
},
|
||||||
|
"multimodal-looker": {
|
||||||
|
verdict: "hard-reject",
|
||||||
|
rejectionMessage:
|
||||||
|
"Agent 'multimodal-looker' has read-only tool access (only 'read' allowed). Cannot write to mailbox as team member.",
|
||||||
|
},
|
||||||
|
metis: {
|
||||||
|
verdict: "hard-reject",
|
||||||
|
rejectionMessage:
|
||||||
|
"Agent 'metis' is read-only (pre-planning consultant). Cannot write to mailbox as team member. Use delegate-task for pre-planning analysis instead.",
|
||||||
|
},
|
||||||
|
momus: {
|
||||||
|
verdict: "hard-reject",
|
||||||
|
rejectionMessage:
|
||||||
|
"Agent 'momus' is read-only (plan reviewer). Cannot write to mailbox as team member. Use delegate-task for plan review instead.",
|
||||||
|
},
|
||||||
|
atlas: { verdict: "eligible" },
|
||||||
|
prometheus: {
|
||||||
|
verdict: "hard-reject",
|
||||||
|
rejectionMessage:
|
||||||
|
"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.",
|
||||||
|
},
|
||||||
|
"sisyphus-junior": { verdict: "eligible" },
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type TeamSpec = z.infer<typeof TeamSpecSchema>
|
||||||
|
export type Member = z.infer<typeof MemberSchema>
|
||||||
|
export type CategoryMember = z.infer<typeof CategoryMemberSchema>
|
||||||
|
export type SubagentMember = z.infer<typeof SubagentMemberSchema>
|
||||||
|
export type Message = z.infer<typeof MessageSchema>
|
||||||
|
export type Task = z.infer<typeof TaskSchema>
|
||||||
|
export type RuntimeState = z.infer<typeof RuntimeStateSchema>
|
||||||
Reference in New Issue
Block a user