diff --git a/packages/protocol/src/schemas.ts b/packages/protocol/src/schemas.ts index ac2e078..a7f22cd 100644 --- a/packages/protocol/src/schemas.ts +++ b/packages/protocol/src/schemas.ts @@ -30,6 +30,27 @@ const nameField = z.string().min(1).max(LIMITS.NAME_MAX); const base = { id: idField }; +// ── Collaboration ───────────────────────────────────────────────────────────── + +/** + * A role→backend binding (`CollaborationRole`). `name` and `providerId` are + * bounded strings rather than enums: role taxonomy is data (§3), and an + * unknown provider must reach the daemon so it can name the registered ones + * back to the caller. + */ +export const collaborationRoleSchema = z.object({ + name: nameField, + providerId: z.string().min(1).max(64), + model: z.string().max(LIMITS.MODEL_MAX).optional(), + count: z.number().int().min(1).max(LIMITS.COLLABORATION_ROLE_COUNT_MAX).optional(), + purpose: z.string().max(500).optional(), +}); + +export const collaborationConfigSchema = z.object({ + goal: z.string().min(1).max(LIMITS.COLLABORATION_GOAL_MAX), + roles: z.array(collaborationRoleSchema).min(1).max(LIMITS.COLLABORATION_ROLES_MAX), +}); + // ── Attachments ─────────────────────────────────────────────────────────────── export const attachmentSchema = z @@ -76,6 +97,17 @@ export const sessionCreateSchema = z.object({ * schema opaquely rejecting the whole create. */ providerId: z.string().min(1).max(64).optional(), + /** + * Collaborative session config (docs/collaborative-session-design.md §9). + * + * Shape only, here. The SEMANTIC rules — provider registered, exactly one + * orchestrator, orchestrator on claude in v1, model belongs to its role's + * backend — live in the daemon, not the schema, for the same reason + * `providerId` is a bounded string rather than an enum: the frame must + * PARSE so the daemon can answer with a specific, actionable error + * instead of the schema opaquely rejecting the whole create. + */ + collaboration: collaborationConfigSchema.optional(), /** * Activate an installed SDLC pack on this session (ambient mode — * docs/pack-loading.md): its constitution is injected into the system diff --git a/packages/protocol/src/types.ts b/packages/protocol/src/types.ts index 176119e..2b938b4 100644 --- a/packages/protocol/src/types.ts +++ b/packages/protocol/src/types.ts @@ -116,6 +116,16 @@ export const LIMITS = { UI_TEXT_MAX: 65_536, /** Max number of options on a `session.ui_request` select. */ UI_OPTIONS_MAX: 64, + /** Max `CollaborationConfig.goal` length. A goal is a brief, not a spec. */ + COLLABORATION_GOAL_MAX: 8192, + /** Max distinct roles in one collaboration. */ + COLLABORATION_ROLES_MAX: 16, + /** + * Max children a single role may fan out to (`CollaborationRole.count`). + * A schema-level backstop only — the live-worker cap (P3) is what actually + * governs concurrency at run time. + */ + COLLABORATION_ROLE_COUNT_MAX: 8, } as const; // ============================================================================= @@ -230,6 +240,12 @@ export interface SessionInfo { * workdir with no git isolation. */ worktree?: SessionWorktree; + /** + * Collaboration this session orchestrates, when it was created with the + * Collaborative toggle. Absent = a normal session. Persisted, so it + * survives a daemon restart the way `role`/`providerId` already do. + */ + collaboration?: CollaborationConfig; } /** A git worktree backing a session's workdir (see SessionInfo.worktree). */ @@ -795,10 +811,69 @@ interface BaseClientMsg { id: string; } +/** + * One role in a collaborative session — a `{backend, model}` binding chosen + * per purpose (docs/collaborative-session-design.md §3). + * + * `name` is deliberately a free-form string, not an enum: "a role is data, + * not an enum". The five defaults (orchestrator / search / reasoning / + * architecture / review) are a starting profile, so adding + * "security-reviewer" or "test-author" stays a config change, never a code + * change. + */ +export interface CollaborationRole { + /** Role name, unique within the collaboration. */ + name: string; + /** + * Backend this role's children run on. Must be an id the daemon + * advertised in `AuthOkMsg.providers`; an unregistered id is rejected with + * `invalid_request` rather than silently falling back — the same + * fail-closed rule as `SessionCreateMsg.providerId`. + */ + providerId: string; + /** Model within that backend. Absent = that backend's own default. */ + model?: string; + /** + * How many children to fan out for this role. >1 is what makes a review + * panel a panel (§7). Absent = 1. + */ + count?: number; + /** What this role is for; surfaced in the child's brief. */ + purpose?: string; +} + +/** The role name that must be present exactly once in a collaboration, and + * which drives dispatch for the goal. */ +export const ORCHESTRATOR_ROLE = "orchestrator"; + +/** + * Collaborative-session config: one goal worked by several role-children on + * possibly different backends. Set on `session.create` behind the + * Collaborative toggle, which compiles it to an ephemeral one-goal pack + * (§9) — pack vocabulary stays hidden on this path. + */ +export interface CollaborationConfig { + /** The single goal this collaboration works. */ + goal: string; + /** + * Role→backend bindings. Exactly one role must be named "orchestrator"; + * in v1 it must sit on the claude backend, the only one that mounts the + * fleet MCP server (non-Claude orchestrators tracked in #245). + */ + roles: CollaborationRole[]; +} + export interface SessionCreateMsg extends BaseClientMsg { type: "session.create"; name: string; workdir: string; + /** + * Turn this into a collaborative session: one goal, several role-children + * on their own backends (docs/collaborative-session-design.md). Validated + * fail-closed — unknown provider, missing/duplicate orchestrator, or a + * model that doesn't belong to its role's backend all reject the create. + */ + collaboration?: CollaborationConfig; /** * Session role. "conductor" requests THE per-tenant conductor session — * the daemon chooses its name/workdir itself, creates it on first request, diff --git a/src/cli.ts b/src/cli.ts index edb7da1..da01da5 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -21,6 +21,8 @@ import { program } from "commander"; // string to drift). release-smoke asserts these two stay equal. import pkg from "../package.json" with { type: "json" }; import { DaemonServer } from "./daemon/server.js"; +import { parseRoleSpec } from "./daemon/collaboration.js"; +import type { CollaborationConfig } from "./protocol/types.js"; import { TerminalClient } from "./terminal/client.js"; import { getConfigDir, @@ -306,11 +308,29 @@ program "--pack-role ", "Run the session under a capability role the pack declares (e.g. reviewer = read-only). Requires --pack.", ) + .option( + "--collaborate ", + "Make this a collaborative session working with several role-children on their own backends. Requires at least one --role, including an orchestrator.", + ) + .option( + "--role ", + 'Role→backend binding, repeatable: "name:provider[:model][*count]" (e.g. orchestrator:claude, reasoning:openai:gpt-5-codex, review:gemini*3). Requires --collaborate.', + (value: string, previous: string[] = []) => [...previous, value], + [] as string[], + ) .action( async ( name: string, workdir: string | undefined, - opts: { worktree?: string; repo?: string; worktreeDir?: string; pack?: string; packRole?: string }, + opts: { + worktree?: string; + repo?: string; + worktreeDir?: string; + pack?: string; + packRole?: string; + collaborate?: string; + role: string[]; + }, ) => { const config = loadConfig(); let resolvedWorkdir = workdir; @@ -327,9 +347,41 @@ program console.error("workdir is required (pass as argument or use --worktree)."); process.exit(1); } + + // Collaborative session (docs/collaborative-session-design.md). Parsed + // here for a fast, local error; the daemon re-validates the semantics + // (provider registered, exactly one orchestrator, claude-only + // orchestrator in v1) so the CLI and the wire path fail identically. + let collaboration: CollaborationConfig | undefined; + const roleSpecs = opts.role ?? []; + if (opts.collaborate) { + if (roleSpecs.length === 0) { + console.error( + '--collaborate requires at least one --role (e.g. --role orchestrator:claude --role review:gemini*3).', + ); + process.exit(1); + } + try { + collaboration = { + goal: opts.collaborate, + roles: roleSpecs.map(parseRoleSpec), + }; + } catch (e) { + console.error(e instanceof Error ? e.message : String(e)); + process.exit(1); + } + } else if (roleSpecs.length > 0) { + console.error("--role requires --collaborate ."); + process.exit(1); + } + const client = new TerminalClient(config); await client.connect(); - await client.createSession(name, resolvedWorkdir, { pack: opts.pack, packRole: opts.packRole }); + await client.createSession(name, resolvedWorkdir, { + pack: opts.pack, + packRole: opts.packRole, + collaboration, + }); client.disconnect(); }, ); diff --git a/src/daemon/collaboration.ts b/src/daemon/collaboration.ts new file mode 100644 index 0000000..cb3c71c --- /dev/null +++ b/src/daemon/collaboration.ts @@ -0,0 +1,239 @@ +/** + * Collaborative-session config validation (docs/collaborative-session-design.md). + * + * A collaboration is one goal worked by several role-children, each bound to + * its own backend. This module owns the SEMANTIC rules — the wire schema + * (`collaborationConfigSchema`) only checks shape, deliberately, so that an + * unknown provider or a missing orchestrator reaches the daemon and gets a + * specific, actionable error instead of an opaque parse failure. + * + * Everything here is fail-closed. A collaboration that names a backend this + * daemon doesn't have must never quietly collapse onto the default — that is + * the same rule `#create` already applies to `providerId`, and it matters + * more here: the entire point of a collaboration is that roles sit on + * *different* vendors, so a silent fallback would produce a "multi-model" + * session that is secretly single-model. + */ + +import type { CollaborationConfig, CollaborationRole } from "../protocol/types.js"; +import { LIMITS, ORCHESTRATOR_ROLE } from "../protocol/types.js"; +import { CLAUDE_PROVIDER_ID, resolveModelIdForProvider } from "./models.js"; + +/** The provider-registry surface this module needs — kept narrow so tests + * can pass a stub instead of building a real registry. */ +export interface ProviderLookup { + has(id: string): boolean; + ids(): string[]; +} + +export type CollaborationValidation = + | { ok: true; config: CollaborationConfig } + | { ok: false; error: string }; + +/** + * Validate + normalize a collaboration config. + * + * On success the returned config is normalized: role names trimmed, `count` + * defaulted to 1, and each `model` resolved against its own role's backend + * (so downstream code never re-resolves, and never re-resolves against the + * wrong provider). + */ +export function validateCollaboration( + config: CollaborationConfig, + providers: ProviderLookup, +): CollaborationValidation { + const goal = config.goal?.trim(); + if (!goal) return { ok: false, error: "collaboration.goal must not be empty" }; + + // Re-check the published bounds here, not only in Zod. Embedded frontends + // hold the SessionManager directly and never cross `parseClientMessage`, so + // the wire schema is not the only door into this function — and the design's + // own rule is that an unenforced field is false security. + if (goal.length > LIMITS.COLLABORATION_GOAL_MAX) { + return { + ok: false, + error: `collaboration.goal is ${goal.length} chars — max ${LIMITS.COLLABORATION_GOAL_MAX}`, + }; + } + if (config.roles.length > LIMITS.COLLABORATION_ROLES_MAX) { + return { + ok: false, + error: `collaboration has ${config.roles.length} roles — max ${LIMITS.COLLABORATION_ROLES_MAX}`, + }; + } + + const roles: CollaborationRole[] = []; + const seen = new Set(); + + for (const raw of config.roles) { + const name = raw.name?.trim(); + if (!name) return { ok: false, error: "collaboration role name must not be empty" }; + + // Case-insensitive uniqueness: "Review" and "review" addressing different + // backends would make every downstream lookup ambiguous. + const key = name.toLowerCase(); + if (seen.has(key)) { + return { ok: false, error: `Duplicate collaboration role "${name}"` }; + } + seen.add(key); + + if (!providers.has(raw.providerId)) { + return { + ok: false, + error: `Unknown provider "${raw.providerId}" for role "${name}" — available: ${providers + .ids() + .join(", ")}`, + }; + } + + const count = raw.count ?? 1; + if (!Number.isInteger(count) || count < 1) { + return { ok: false, error: `Role "${name}" count must be a positive integer` }; + } + if (count > LIMITS.COLLABORATION_ROLE_COUNT_MAX) { + return { + ok: false, + error: `Role "${name}" count is ${count} — max ${LIMITS.COLLABORATION_ROLE_COUNT_MAX}`, + }; + } + + // Provider-aware model check. resolveModelIdForProvider returns null for + // a Claude-shaped value on a non-Claude backend, which is exactly the + // mistake worth catching at create time ("review on gemini with model + // opus") rather than at first turn. It does NOT catch a typo in a + // provider-native id — models.ts leaves the backend as the real validator + // (a cached catalog goes stale the moment a vendor ships a point release). + let model: string | undefined; + if (raw.model !== undefined && raw.model.trim() !== "") { + const resolved = resolveModelIdForProvider(raw.model, raw.providerId); + if (!resolved) { + return { + ok: false, + error: `Model "${raw.model}" is not valid for provider "${raw.providerId}" (role "${name}")`, + }; + } + model = resolved; + } + + roles.push({ + // Store the lowercased name. Matching is case-insensitive everywhere + // (uniqueness above, orchestratorRole below), so keeping the caller's + // casing would let `ORCHESTRATOR:claude` validate and then miss any + // exact-match lookup downstream. + name: key, + providerId: raw.providerId, + ...(model !== undefined ? { model } : {}), + count, + ...(raw.purpose !== undefined ? { purpose: raw.purpose } : {}), + }); + } + + const orchestrators = roles.filter( + (r) => r.name.toLowerCase() === ORCHESTRATOR_ROLE, + ); + if (orchestrators.length === 0) { + return { + ok: false, + error: `A collaboration needs exactly one "${ORCHESTRATOR_ROLE}" role — got none`, + }; + } + // Unreachable via the duplicate check above (two roles named "orchestrator" + // collide first), but kept so the invariant survives a future change to how + // names are keyed. + if (orchestrators.length > 1) { + return { + ok: false, + error: `A collaboration needs exactly one "${ORCHESTRATOR_ROLE}" role — got ${orchestrators.length}`, + }; + } + + const orchestrator = orchestrators[0]!; + if (orchestrator.providerId !== CLAUDE_PROVIDER_ID) { + return { + ok: false, + error: `The "${ORCHESTRATOR_ROLE}" role must run on "${CLAUDE_PROVIDER_ID}" in v1 (got "${orchestrator.providerId}") — it is the only backend that mounts the fleet MCP server. Non-Claude orchestrators are tracked in #245.`, + }; + } + // The orchestrator delegates; it is not itself a fan-out role. + if (orchestrator.count !== 1) { + return { + ok: false, + error: `The "${ORCHESTRATOR_ROLE}" role cannot fan out (count must be 1, got ${orchestrator.count})`, + }; + } + + return { ok: true, config: { goal, roles } }; +} + +/** + * The orchestrator binding of a validated config. + * + * Safe to assume present: `validateCollaboration` rejects a config without + * exactly one orchestrator, so every `CollaborationConfig` that reaches the + * rest of the daemon has one. Matching is case-insensitive to agree with the + * validator, even though it also lowercases the stored name. + */ +export function orchestratorRole( + config: CollaborationConfig, +): CollaborationRole | undefined { + return config.roles.find((r) => r.name.toLowerCase() === ORCHESTRATOR_ROLE); +} + +/** + * Parse one `--role` CLI spec into a `CollaborationRole`. + * + * Format: `name:provider[:model][*count]` — e.g. + * orchestrator:claude + * reasoning:openai:gpt-5-codex + * review:gemini*3 + * + * Shape only; the semantic rules stay in `validateCollaboration` so the CLI + * and the wire path fail identically. Throws with an actionable message — + * the caller is a CLI that exits on bad input. + */ +export function parseRoleSpec(spec: string): CollaborationRole { + const trimmed = spec.trim(); + if (!trimmed) throw new Error("--role must not be empty"); + + // Split the fan-out suffix off the RIGHT first, so a `*` can never be + // confused with part of a model id. + let body = trimmed; + let count: number | undefined; + const star = body.lastIndexOf("*"); + if (star !== -1) { + const raw = body.slice(star + 1).trim(); + if (!/^\d+$/.test(raw)) { + throw new Error(`Invalid count in --role "${spec}" — expected e.g. "review:gemini*3"`); + } + count = Number.parseInt(raw, 10); + if (count < 1) throw new Error(`Invalid count in --role "${spec}" — must be at least 1`); + // Bound it here so an over-large fan-out gets this message rather than a + // raw schema error from the daemon's wire validation. + if (count > LIMITS.COLLABORATION_ROLE_COUNT_MAX) { + throw new Error( + `Invalid count in --role "${spec}" — max ${LIMITS.COLLABORATION_ROLE_COUNT_MAX}`, + ); + } + body = body.slice(0, star); + } + + const parts = body.split(":").map((p) => p.trim()); + if (parts.length < 2 || parts.length > 3) { + throw new Error( + `Invalid --role "${spec}" — expected name:provider[:model][*count]`, + ); + } + const [name, providerId, model] = parts; + if (!name) throw new Error(`Invalid --role "${spec}" — role name is empty`); + if (!providerId) throw new Error(`Invalid --role "${spec}" — provider is empty`); + if (parts.length === 3 && !model) { + throw new Error(`Invalid --role "${spec}" — model is empty (drop the trailing ":")`); + } + + return { + name, + providerId, + ...(model ? { model } : {}), + ...(count !== undefined ? { count } : {}), + }; +} diff --git a/src/daemon/pipeline/pack.ts b/src/daemon/pipeline/pack.ts index 33d03b6..d4a4039 100644 --- a/src/daemon/pipeline/pack.ts +++ b/src/daemon/pipeline/pack.ts @@ -33,6 +33,20 @@ const idField = z export const roleSchema = z.object({ name: z.string().min(1).max(64), summary: z.string().max(500).optional(), + /** + * Backend this role runs on (docs/collaborative-session-design.md §8.1). + * A role has always been a capability envelope; this also makes it a + * role→backend binding, so one engine covers both topologies — a + * sequential pack phase and a collaborative fan-out. + * + * Absent = the session's own backend, which is what every existing pack + * relies on. Consumed only on the fleet/child path, where a provider picks + * the CHILD a role runs on; it is never a request to mutate a bound + * session's backend mid-run. + */ + provider: z.string().min(1).max(64).optional(), + /** Model within `provider`. Absent = that backend's default. */ + model: z.string().min(1).max(256).optional(), write: z.boolean(), network: z.union([z.boolean(), z.literal("read-only")]).default(false), envelope: z.union([z.literal("all"), z.array(z.string().max(32)).max(32)]), diff --git a/src/daemon/session-manager.ts b/src/daemon/session-manager.ts index 1e8b807..6c31429 100644 --- a/src/daemon/session-manager.ts +++ b/src/daemon/session-manager.ts @@ -37,6 +37,7 @@ import { resolveAgainstList, resolveModelIdForProvider, } from "./models.js"; +import { orchestratorRole, validateCollaboration } from "./collaboration.js"; import { packSession, unpackBundle, @@ -78,9 +79,11 @@ import type { McpRegistry } from "./mcp/registry.js"; import type { McpHub } from "./mcp/hub.js"; import { type CodeoidConfig, mutateConfigFile } from "../config.js"; import type { CompressionRegistry } from "./compress/index.js"; +import { ORCHESTRATOR_ROLE } from "../protocol/types.js"; import type { AuthContext, ClientMessage, + CollaborationConfig, DaemonMessage, McpServerStatus, ModelInfo, @@ -440,6 +443,10 @@ mcpHub: this.#mcpHub, providerId: meta.providerId, forkedFrom: meta.forkedFrom, worktree: meta.worktree, + // A collaboration is durable state, not turn state: the goal and + // its role→backend bindings must come back after a restart or the + // orchestrator resumes with no idea what it was coordinating. + collaboration: meta.collaboration, defaultModel: meta.role === "conductor" ? this.#config?.conductor?.model : undefined, fleet: @@ -1390,6 +1397,45 @@ mcpHub: this.#mcpHub, }; } + // Collaborative session (docs/collaborative-session-design.md §9): the + // role→backend bindings are validated fail-closed up front, for the same + // reason `providerId` is — a collaboration whose roles silently collapse + // onto the default backend would be "multi-model" in name only. + let collaboration: CollaborationConfig | undefined; + // The session created here IS the orchestrator (§9: the toggle compiles to + // a pack and creates the run). So the backend that must mount the fleet + // MCP server is THIS session's — which makes `providerId` the thing the + // claude-only orchestrator rule has to agree with. Validating the role + // entry alone would leave that rule guarding a config row while the + // session actually doing the orchestrating ran on anything at all. + let providerId = msg.providerId; + if (msg.collaboration) { + const checked = validateCollaboration(msg.collaboration, this.#providers); + if (!checked.ok) { + return { + type: "response.error", + requestId: msg.id, + error: checked.error, + code: "invalid_request", + }; + } + collaboration = checked.config; + const orchestrator = orchestratorRole(collaboration); + if (orchestrator) { + if (providerId && providerId !== orchestrator.providerId) { + return { + type: "response.error", + requestId: msg.id, + error: `providerId "${providerId}" conflicts with the "${ORCHESTRATOR_ROLE}" role's backend "${orchestrator.providerId}" — a collaborative session IS its orchestrator, so omit providerId or set it to "${orchestrator.providerId}".`, + code: "invalid_request", + }; + } + // Derive it, so the session can't land on a backend that cannot drive + // the fleet merely because the caller left providerId unset. + providerId = orchestrator.providerId; + } + } + // Ambient pack activation (docs/pack-loading.md): resolve the requested pack // (+ optional capability role) up front; fail-closed on an unknown pack/role. let pack: PackActivation | undefined; @@ -1411,8 +1457,10 @@ mcpHub: this.#mcpHub, transcriptStore: this.#transcriptStore, providers: this.#providers, hooks: this.#hooks, - providerId: msg.providerId, + // Derived above for a collaborative session; otherwise msg.providerId. + providerId, pack, + collaboration, identityManager: this.#identityManager, memory: this.#memory, memoryMcp: this.#memoryMcp, @@ -2592,23 +2640,31 @@ mcpHub: this.#mcpHub, }; } if (model === undefined) return { ok: true, provider }; - // Validate the model against THIS provider's catalog (live, persisted, - // or fallback) so a typo is caught before the worker spawns. Absent a - // catalog for the backend, resolveModelIdForProvider's passthrough - // applies and the backend stays the real validator. + // Canonicalize the model against THIS provider's catalog when we have + // one — that turns a display name ("Opus") into the value the backend + // expects, and stops a Claude alias riding onto another vendor. + // + // It does NOT reject unknown models, and deliberately so: models.ts + // sets the house policy ("the live backend is the real validator — + // refusing an unknown-but-valid value here is worse than letting the + // SDK reject a genuine typo"), because our cached catalog goes stale + // the moment a vendor ships a point release. So a typo reaches the + // backend and fails there with the vendor's own message. + // + // An earlier version chained `resolveAgainstList(...) ?? resolveModel- + // IdForProvider(...)`, which read as strict validation but could never + // reject anything — the fallback's last branch returns the input + // unchanged. The dead branch is gone; only the real rule remains. const providerId = provider ?? DEFAULT_PROVIDER_ID; const { models } = this.#currentModels(providerId); - const resolved = - models.length > 0 - ? (resolveAgainstList(model, models) ?? - resolveModelIdForProvider(model, providerId)) - : resolveModelIdForProvider(model, providerId); + const canonical = + models.length > 0 ? resolveAgainstList(model, models) : null; + const resolved = canonical ?? resolveModelIdForProvider(model, providerId); if (!resolved) { - const known = models.map((m) => m.value).join(", "); - const available = known ? ` — available: ${known}` : ""; + // Reachable only for a Claude-shaped model on a non-Claude backend. return { ok: false, - error: `Model "${model}" is not valid for provider "${providerId}"${available}. Omit \`model\` to use the provider's default.`, + error: `Model "${model}" is not valid for provider "${providerId}". Omit \`model\` to use the provider's default.`, }; } return { ok: true, provider, model: resolved }; diff --git a/src/daemon/session.ts b/src/daemon/session.ts index 8c4e6c8..04dd7ef 100644 --- a/src/daemon/session.ts +++ b/src/daemon/session.ts @@ -34,6 +34,7 @@ import type { HookSessionContext } from "./hooks/types.js"; import { randomUUID } from "node:crypto"; import type { AuthContext, + CollaborationConfig, SessionInfo, SessionMode, SessionStatus, @@ -233,6 +234,13 @@ export interface SessionCreateOptions { * Set by SessionManager#fork, persisted in meta, surfaced in SessionInfo. */ worktree?: SessionWorktree; + /** + * Collaboration this session orchestrates — goal + role→backend bindings, + * already validated and normalized by `validateCollaboration`. Persisted + * in meta and surfaced in SessionInfo, so it survives a daemon restart. + * Absent = a normal session. + */ + collaboration?: CollaborationConfig; /** * Pre-built codeoid_fleet MCP server (conductor sessions only). Built by * the SessionManager because its tools close over the manager's tenant- @@ -306,6 +314,13 @@ export class Session { readonly forkedFrom?: { sessionId: string; name: string; atTurn: number }; /** Git worktree backing workdir, when isolated (set from opts / meta). */ readonly worktree?: SessionWorktree; + /** + * Goal + role→backend bindings when this session was created with the + * Collaborative toggle (set from opts / restored from meta). Readonly: the + * bindings are fixed for the life of the goal (§2, per-goal child + * lifetime); changing backends mid-goal would orphan live children. + */ + readonly collaboration?: CollaborationConfig; readonly createdBy: string; readonly createdAt: string; /** @@ -595,6 +610,7 @@ export class Session { this.#pack = opts.pack; this.forkedFrom = opts.forkedFrom; this.worktree = opts.worktree; + this.collaboration = opts.collaboration; this.#onStatusChange = opts.onStatusChange; this.#workerShape = opts.workerShape; if (opts.initialMode) { @@ -738,6 +754,7 @@ export class Session { providerId: this.#provider.id, forkedFrom: this.forkedFrom, worktree: this.worktree, + collaboration: this.collaboration, // Fire-and-forget: saveMeta's write chain owns the failure log; an // unconsumed rejection here would be an unhandled-rejection crash. }).catch(() => {}); @@ -2203,6 +2220,7 @@ export class Session { fallbackModel: this.#fallbackModel ?? undefined, forkedFrom: this.forkedFrom, worktree: this.worktree, + collaboration: this.collaboration, // Ambient pack driving this session (docs/pack-loading.md) — id, or // "id (role)" when a capability role is active. ...(this.#pack @@ -4043,6 +4061,12 @@ export class Session { providerId: this.#provider.id, forkedFrom: this.forkedFrom, worktree: this.worktree, + // MUST be written here too, not only at create: #writeMetaAtomic + // serializes the whole object and renames over the file, so any field + // omitted from THIS write is erased from the meta the resume path + // reads. Leaving it out cost the collaboration on the first status + // transition — i.e. on every session that had taken a single turn. + collaboration: this.collaboration, }).catch(() => {}); } } diff --git a/src/daemon/store.ts b/src/daemon/store.ts index 9c42daf..4fb12c7 100644 --- a/src/daemon/store.ts +++ b/src/daemon/store.ts @@ -165,6 +165,12 @@ export class Store { // daemon restarts. NULL = normal session / claude (pre-upgrade rows). this.#addColumnIfMissing("sessions", "role", "TEXT"); this.#addColumnIfMissing("sessions", "provider", "TEXT"); + // Collaborative sessions (docs/collaborative-session-design.md §9): the + // goal + role→backend bindings, as a JSON blob. NULL = a normal session. + // The resume path reads transcript meta, not this column; it exists so a + // collaboration is visible to anything querying the sessions table + // directly (audit, future admin surfaces) rather than only via meta. + this.#addColumnIfMissing("sessions", "collaboration", "TEXT"); this.#db.exec(` @@ -345,8 +351,8 @@ export class Store { createSession(session: SessionInfo & { accountId: string; projectId: string }): void { this.#db .prepare( - `INSERT OR REPLACE INTO sessions (id, name, workdir, status, created_by, account_id, project_id, created_at, role, provider) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + `INSERT OR REPLACE INTO sessions (id, name, workdir, status, created_by, account_id, project_id, created_at, role, provider, collaboration) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) .run( session.id, @@ -359,6 +365,7 @@ export class Store { session.createdAt, session.role ?? null, session.providerId ?? null, + session.collaboration ? JSON.stringify(session.collaboration) : null, ); } diff --git a/src/daemon/transcript.ts b/src/daemon/transcript.ts index 9022a05..1011594 100644 --- a/src/daemon/transcript.ts +++ b/src/daemon/transcript.ts @@ -16,7 +16,13 @@ import { existsSync, mkdirSync } from "node:fs"; import { appendFile, rename, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; -import type { DaemonMessage, SessionMessage, SessionStatus, SessionWorktree } from "../protocol/types.js"; +import type { + CollaborationConfig, + DaemonMessage, + SessionMessage, + SessionStatus, + SessionWorktree, +} from "../protocol/types.js"; /** Persistent entry in the transcript. */ export interface TranscriptEntry { @@ -97,6 +103,12 @@ export interface TranscriptMeta { forkedFrom?: { sessionId: string; name: string; atTurn: number }; /** Git worktree backing workdir (fork isolation / bind). Absent = shared. */ worktree?: SessionWorktree; + /** + * Collaboration this session orchestrates (goal + role→backend bindings). + * Absent = a normal session. Stamped here, not just in the sessions table, + * because meta is what the resume path actually reads. + */ + collaboration?: CollaborationConfig; } /** Types we persist. Skip ephemeral events like heartbeats. */ diff --git a/src/terminal/client.ts b/src/terminal/client.ts index 2326215..37da4e9 100644 --- a/src/terminal/client.ts +++ b/src/terminal/client.ts @@ -7,7 +7,13 @@ import { randomUUID } from "node:crypto"; import { createInterface } from "node:readline"; import type { CodeoidConfig } from "../config.js"; -import type { ClientMessage, DaemonMessage, PipelineWire, SessionInfo } from "../protocol/types.js"; +import type { + ClientMessage, + CollaborationConfig, + DaemonMessage, + PipelineWire, + SessionInfo, +} from "../protocol/types.js"; import { ALL_SCOPES_STRING } from "../protocol/scopes.js"; import { sanitizeTerminalOutput } from "../tui/ansi/codes.js"; import { formatPackList, formatPackShow } from "./pack-format.js"; @@ -264,7 +270,11 @@ export class TerminalClient { for (const line of formatPackList(resp)) console.log(line); } - async createSession(name: string, workdir: string, opts: { pack?: string; packRole?: string } = {}): Promise { + async createSession( + name: string, + workdir: string, + opts: { pack?: string; packRole?: string; collaboration?: CollaborationConfig } = {}, + ): Promise { const resp = await this.#request({ type: "session.create", id: randomUUID(), @@ -272,12 +282,24 @@ export class TerminalClient { workdir, ...(opts.pack ? { pack: opts.pack } : {}), ...(opts.packRole ? { packRole: opts.packRole } : {}), + ...(opts.collaboration ? { collaboration: opts.collaboration } : {}), }); if (resp.type === "response.ok") { const data = resp.data as SessionInfo; const profile = data.profile ? ` [pack: ${data.profile}]` : ""; console.log(`Session created: ${data.name} (${data.id})${profile}`); + if (data.collaboration) { + // Echo the RESOLVED bindings, not the requested ones: the daemon + // normalizes (count defaults, model resolved against its own + // backend), so this is the user's confirmation of what they got. + console.log(` goal: ${data.collaboration.goal}`); + for (const r of data.collaboration.roles) { + const model = r.model ? `:${r.model}` : ""; + const fanout = (r.count ?? 1) > 1 ? ` ×${r.count}` : ""; + console.log(` role: ${r.name} → ${r.providerId}${model}${fanout}`); + } + } } else { this.#printError(resp); } diff --git a/src/tests/collaboration.test.ts b/src/tests/collaboration.test.ts new file mode 100644 index 0000000..5c3edfe --- /dev/null +++ b/src/tests/collaboration.test.ts @@ -0,0 +1,541 @@ +/** + * Collaborative session config — P1a (docs/collaborative-session-design.md §9). + * + * Three layers, deliberately separated: + * 1. `validateCollaboration` — the semantic rules, unit-tested against a + * stub provider lookup. + * 2. `parseRoleSpec` — the CLI `--role name:provider[:model][*count]` grammar. + * 3. The real `SessionManager.handle()` create path, driven with a genuine + * multi-backend `ProviderRegistry` (NOT `_testProviderFactory`, which + * injects one mock into every session and would hide whether the config + * actually survives create → SessionInfo → persistence). + * + * The rule these guard: a collaboration naming a backend this daemon doesn't + * have must FAIL, never silently collapse onto the default. A "multi-model" + * session that is secretly single-model is the failure mode worth catching. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { CodeoidConfig } from "../config.js"; +import { + orchestratorRole, + parseRoleSpec, + validateCollaboration, + type ProviderLookup, +} from "../daemon/collaboration.js"; +import { MockSessionProvider, mockResult } from "../daemon/providers/mock/session-provider.js"; +import { ProviderRegistry } from "../daemon/providers/registry.js"; +import type { ProviderEvent } from "../daemon/providers/interface.js"; +import { SessionManager } from "../daemon/session-manager.js"; +import { Store } from "../daemon/store.js"; +import { TranscriptStore } from "../daemon/transcript.js"; +import { ALL_SCOPES } from "../protocol/scopes.js"; +import { LIMITS } from "../protocol/types.js"; +import type { + AuthContext, + ClientMessage, + CollaborationConfig, + DaemonMessage, + SessionInfo, +} from "../protocol/types.js"; + +// ── 1. validateCollaboration ──────────────────────────────────────────────── + +/** Stub registry: claude + two other backends. */ +const LOOKUP: ProviderLookup = { + has: (id) => ["claude", "gemini", "openai"].includes(id), + ids: () => ["claude", "gemini", "openai"], +}; + +const ok = (goal: string, roles: CollaborationConfig["roles"]) => + validateCollaboration({ goal, roles }, LOOKUP); + +describe("validateCollaboration", () => { + test("accepts a well-formed collaboration and normalizes it", () => { + const r = ok(" Ship rate limiting ", [ + { name: " orchestrator ", providerId: "claude" }, + { name: "review", providerId: "gemini", count: 3 }, + ]); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(r.config.goal).toBe("Ship rate limiting"); // trimmed + expect(r.config.roles[0]!.name).toBe("orchestrator"); // trimmed + expect(r.config.roles[0]!.count).toBe(1); // defaulted + expect(r.config.roles[1]!.count).toBe(3); // preserved + }); + + test("rejects an empty goal", () => { + const r = ok(" ", [{ name: "orchestrator", providerId: "claude" }]); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toMatch(/goal must not be empty/); + }); + + test("rejects an unregistered provider and names the available ones", () => { + const r = ok("g", [ + { name: "orchestrator", providerId: "claude" }, + { name: "review", providerId: "ollama" }, + ]); + expect(r.ok).toBe(false); + if (!r.ok) { + expect(r.error).toMatch(/Unknown provider "ollama"/); + expect(r.error).toMatch(/claude, gemini, openai/); + } + }); + + test("requires an orchestrator", () => { + const r = ok("g", [{ name: "review", providerId: "gemini" }]); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toMatch(/needs exactly one "orchestrator" role — got none/); + }); + + test("rejects duplicate role names case-insensitively", () => { + const r = ok("g", [ + { name: "orchestrator", providerId: "claude" }, + { name: "Review", providerId: "gemini" }, + { name: "review", providerId: "openai" }, + ]); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toMatch(/Duplicate collaboration role "review"/); + }); + + // v1 constraint: only the claude backend mounts the fleet MCP server. + test("rejects a non-claude orchestrator and points at #245", () => { + const r = ok("g", [{ name: "orchestrator", providerId: "gemini" }]); + expect(r.ok).toBe(false); + if (!r.ok) { + expect(r.error).toMatch(/must run on "claude" in v1/); + expect(r.error).toMatch(/#245/); + } + }); + + test("rejects an orchestrator that tries to fan out", () => { + const r = ok("g", [{ name: "orchestrator", providerId: "claude", count: 2 }]); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toMatch(/cannot fan out/); + }); + + // Reuses P0's provider-aware resolver: a Claude alias is meaningless on a + // non-Claude backend, and catching it here beats failing on the first turn. + test("rejects a claude model on a non-claude role", () => { + const r = ok("g", [ + { name: "orchestrator", providerId: "claude" }, + { name: "review", providerId: "gemini", model: "opus" }, + ]); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toMatch(/Model "opus" is not valid for provider "gemini"/); + }); + + test("passes a provider-native model through, and resolves claude aliases", () => { + const r = ok("g", [ + { name: "orchestrator", providerId: "claude", model: "sonnet" }, + { name: "reasoning", providerId: "openai", model: "gpt-5-codex" }, + ]); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(r.config.roles[0]!.model).toMatch(/^claude-sonnet-/); // alias resolved + expect(r.config.roles[1]!.model).toBe("gpt-5-codex"); // untouched + }); + + test("an empty-string model is treated as absent, not as an error", () => { + const r = ok("g", [{ name: "orchestrator", providerId: "claude", model: " " }]); + expect(r.ok).toBe(true); + if (r.ok) expect(r.config.roles[0]!.model).toBeUndefined(); + }); + + // Stored lowercase so a downstream exact-match lookup can't miss a role + // that validated case-insensitively. + test("role names are normalized to lowercase", () => { + const r = ok("g", [ + { name: "ORCHESTRATOR", providerId: "claude" }, + { name: "Review", providerId: "gemini" }, + ]); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(r.config.roles.map((x) => x.name)).toEqual(["orchestrator", "review"]); + expect(orchestratorRole(r.config)?.providerId).toBe("claude"); + }); + + // The published LIMITS are enforced here too, not only in the wire schema: + // embedded frontends hold the SessionManager directly and never cross Zod. + test("enforces the published bounds independently of Zod", () => { + const many = ok( + "g", + Array.from({ length: LIMITS.COLLABORATION_ROLES_MAX + 1 }, (_, i) => ({ + name: `r${i}`, + providerId: "gemini", + })), + ); + expect(many.ok).toBe(false); + if (!many.ok) expect(many.error).toMatch(/roles — max/); + + const big = ok("x".repeat(LIMITS.COLLABORATION_GOAL_MAX + 1), [ + { name: "orchestrator", providerId: "claude" }, + ]); + expect(big.ok).toBe(false); + if (!big.ok) expect(big.error).toMatch(/goal is \d+ chars — max/); + + const fanout = ok("g", [ + { name: "orchestrator", providerId: "claude" }, + { name: "review", providerId: "gemini", count: LIMITS.COLLABORATION_ROLE_COUNT_MAX + 1 }, + ]); + expect(fanout.ok).toBe(false); + if (!fanout.ok) expect(fanout.error).toMatch(/count is \d+ — max/); + + const frac = ok("g", [ + { name: "orchestrator", providerId: "claude" }, + { name: "review", providerId: "gemini", count: 2.5 }, + ]); + expect(frac.ok).toBe(false); + if (!frac.ok) expect(frac.error).toMatch(/positive integer/); + }); +}); + +// ── 2. parseRoleSpec ──────────────────────────────────────────────────────── + +describe("parseRoleSpec", () => { + test("name:provider", () => { + expect(parseRoleSpec("orchestrator:claude")).toEqual({ + name: "orchestrator", + providerId: "claude", + }); + }); + + test("name:provider:model", () => { + expect(parseRoleSpec("reasoning:openai:gpt-5-codex")).toEqual({ + name: "reasoning", + providerId: "openai", + model: "gpt-5-codex", + }); + }); + + test("fan-out suffix", () => { + expect(parseRoleSpec("review:gemini*3")).toEqual({ + name: "review", + providerId: "gemini", + count: 3, + }); + }); + + test("model and fan-out together", () => { + expect(parseRoleSpec("review:gemini:gemini-2.5-pro*2")).toEqual({ + name: "review", + providerId: "gemini", + model: "gemini-2.5-pro", + count: 2, + }); + }); + + test("tolerates surrounding whitespace", () => { + expect(parseRoleSpec(" review : gemini * 2 ")).toEqual({ + name: "review", + providerId: "gemini", + count: 2, + }); + }); + + test.each([ + ["", /must not be empty/], + ["orchestrator", /expected name:provider/], + ["review:gemini*x", /Invalid count/], + ["review:gemini*0", /at least 1/], + ["review:gemini:", /model is empty/], + [":gemini", /role name is empty/], + ["review:", /provider is empty/], + ["a:b:c:d", /expected name:provider/], + ])("rejects %p", (spec, match) => { + expect(() => parseRoleSpec(spec as string)).toThrow(match as RegExp); + }); +}); + +// ── 3. The real create path ───────────────────────────────────────────────── + +const AUTH: AuthContext = { + sub: "user:collab", + scopes: [...ALL_SCOPES] as AuthContext["scopes"], + delegationDepth: 0, + accountId: "acc-collab", + projectId: "proj-collab", +}; +const CLIENT = { id: "client-collab", auth: AUTH, send: () => {} }; + +const textTurn = (text: string): ProviderEvent[] => [ + { type: "text_done", content: text } as ProviderEvent, + { type: "turn_done", result: mockResult() } as ProviderEvent, +]; + +function mkConfig(): CodeoidConfig { + return { + daemonUrl: "ws://127.0.0.1:7400", + dbPath: "/tmp/codeoid.db", + transcriptDir: "/tmp/transcripts", + auth: { baseUrl: "http://localhost:8899" }, + zeroidUrl: "http://localhost:8899", + workspaceIndex: { enabled: false, episodeThreshold: 5, timeThresholdMs: 60_000, debounceMs: 15_000 }, + compress: { enabled: false, excludeCommands: [], excludePatterns: [], compressPipes: false, minBytes: 1024 }, + labeling: {}, + telemetry: { osc8: "auto" }, + autoRotate: { enabled: false, warnPct: 0.6, rotatePct: 0.8, hardRotatePct: 0.9, minTurnsBeforeRotate: 3, strategy: "task-anchor" }, + session: {}, + conductor: { enabled: false, name: "conductor", provider: "claude" }, + dispatch: { enabled: false, tickMs: 999_999, leaseMs: 60_000, failureLimit: 2, maxConcurrentWorkers: 2, workerToolBudget: 7, retryBaseMs: 0 }, + }; +} + +/** claude (default) + gemini, both mocks — so a collaboration can name a real + * second backend without reaching a live vendor. */ +function makeRegistry(): ProviderRegistry { + const registry = new ProviderRegistry("claude"); + for (const id of ["claude", "gemini"] as const) { + registry.register({ + id, + displayName: id, + create: () => new MockSessionProvider(id, [textTurn(`${id} ok`)]), + }); + } + return registry; +} + +let tmp: string; +let workdir: string; +let store: Store; +let transcript: TranscriptStore; +let manager: SessionManager; + +function run(msg: ClientMessage): Promise { + return manager.handle(msg, AUTH, CLIENT); +} + +const VALID: CollaborationConfig = { + goal: "Add rate limiting to the public API", + roles: [ + { name: "orchestrator", providerId: "claude" }, + { name: "review", providerId: "gemini", count: 2 }, + ], +}; + +beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), "codeoid-collab-")); + workdir = join(tmp, "repo"); + mkdirSync(workdir, { recursive: true }); + store = new Store(join(tmp, "codeoid.db")); + transcript = new TranscriptStore(join(tmp, "transcripts")); + manager = new SessionManager(store, transcript, undefined, undefined, undefined, { + config: mkConfig(), + providers: makeRegistry(), + }); +}); + +afterEach(async () => { + try { + await manager.drain(3_000); + } catch {} + rmSync(tmp, { recursive: true, force: true }); +}); + +describe("session.create --collaborate", () => { + test("a valid collaboration is echoed back on SessionInfo, normalized", async () => { + const resp = await run({ + type: "session.create", + id: "1", + name: "collab1", + workdir, + collaboration: VALID, + }); + expect(resp.type).toBe("response.ok"); + if (resp.type !== "response.ok") return; + const info = resp.data as SessionInfo; + expect(info.collaboration?.goal).toBe(VALID.goal); + expect(info.collaboration?.roles).toHaveLength(2); + expect(info.collaboration?.roles[0]).toMatchObject({ + name: "orchestrator", + providerId: "claude", + count: 1, // defaulted by the daemon, not sent by the caller + }); + expect(info.collaboration?.roles[1]).toMatchObject({ + name: "review", + providerId: "gemini", + count: 2, + }); + }); + + test("the collaboration is persisted to the sessions row", async () => { + const resp = await run({ + type: "session.create", + id: "2", + name: "collab2", + workdir, + collaboration: VALID, + }); + expect(resp.type).toBe("response.ok"); + if (resp.type !== "response.ok") return; + const row = store.database + .prepare("SELECT collaboration FROM sessions WHERE id = ?") + .get((resp.data as SessionInfo).id) as { collaboration: string | null }; + expect(row.collaboration).not.toBeNull(); + const parsed = JSON.parse(row.collaboration!) as CollaborationConfig; + expect(parsed.goal).toBe(VALID.goal); + expect(parsed.roles.map((r) => r.providerId)).toEqual(["claude", "gemini"]); + }); + + // Regression: the status-persist saveMeta rewrites the WHOLE meta file + // (writeMetaAtomic serializes + renames, it does not merge), so a field + // written only at create time is erased by the first status transition — + // and the resume path reads exactly that file. Asserting the create-time + // write alone is not enough; this drives a real turn first. + test("collaboration survives a status-triggered meta rewrite (resume path)", async () => { + const resp = await run({ + type: "session.create", + id: "meta1", + name: "collab-meta", + workdir, + collaboration: VALID, + }); + expect(resp.type).toBe("response.ok"); + if (resp.type !== "response.ok") return; + const id = (resp.data as SessionInfo).id; + const metaPath = transcript.metaPath(id); + + await Bun.sleep(250); // the create-time meta write is fire-and-forget + const atCreate = JSON.parse(readFileSync(metaPath, "utf-8")) as { + collaboration?: CollaborationConfig; + }; + expect(atCreate.collaboration?.goal).toBe(VALID.goal); + + await run({ type: "session.send", id: "meta2", sessionId: id, text: "go" }); + await Bun.sleep(400); + + const afterTurn = JSON.parse(readFileSync(metaPath, "utf-8")) as { + collaboration?: CollaborationConfig; + }; + expect(afterTurn.collaboration?.goal).toBe(VALID.goal); + expect(afterTurn.collaboration?.roles).toHaveLength(2); + }); + + test("a normal create leaves collaboration absent and the column NULL", async () => { + const resp = await run({ type: "session.create", id: "3", name: "plain", workdir }); + expect(resp.type).toBe("response.ok"); + if (resp.type !== "response.ok") return; + const info = resp.data as SessionInfo; + expect(info.collaboration).toBeUndefined(); + const row = store.database + .prepare("SELECT collaboration FROM sessions WHERE id = ?") + .get(info.id) as { collaboration: string | null }; + expect(row.collaboration).toBeNull(); + }); +}); + +describe("session.create --collaborate fails closed", () => { + test("a role on an unregistered backend rejects the whole create", async () => { + const resp = await run({ + type: "session.create", + id: "4", + name: "bad1", + workdir, + collaboration: { + goal: "g", + roles: [ + { name: "orchestrator", providerId: "claude" }, + { name: "review", providerId: "ollama" }, + ], + }, + }); + expect(resp.type).toBe("response.error"); + if (resp.type === "response.error") { + expect(resp.code).toBe("invalid_request"); + expect(resp.error).toMatch(/Unknown provider "ollama"/); + } + // And nothing was half-created. + const list = await run({ type: "session.list", id: "4b" }); + expect((list as { sessions: SessionInfo[] }).sessions).toHaveLength(0); + }); + + test("a non-claude orchestrator rejects the create", async () => { + const resp = await run({ + type: "session.create", + id: "5", + name: "bad2", + workdir, + collaboration: { goal: "g", roles: [{ name: "orchestrator", providerId: "gemini" }] }, + }); + expect(resp.type).toBe("response.error"); + if (resp.type === "response.error") expect(resp.error).toMatch(/must run on "claude" in v1/); + }); + + test("a collaboration with no orchestrator rejects the create", async () => { + const resp = await run({ + type: "session.create", + id: "6", + name: "bad3", + workdir, + collaboration: { goal: "g", roles: [{ name: "review", providerId: "gemini" }] }, + }); + expect(resp.type).toBe("response.error"); + if (resp.type === "response.error") expect(resp.error).toMatch(/got none/); + }); +}); + +// A collaborative session IS its orchestrator, so the claude-only rule has to +// bind THIS session's backend — not just a config row that nothing runs on. +describe("the session is its orchestrator", () => { + test("providerId is derived from the orchestrator role when omitted", async () => { + const resp = await run({ + type: "session.create", + id: "o1", + name: "orch1", + workdir, + collaboration: VALID, + }); + expect(resp.type).toBe("response.ok"); + if (resp.type === "response.ok") { + expect((resp.data as SessionInfo).providerId).toBe("claude"); + } + }); + + test("a providerId that contradicts the orchestrator role is rejected", async () => { + const resp = await run({ + type: "session.create", + id: "o2", + name: "orch2", + workdir, + providerId: "gemini", // but the orchestrator role says claude + collaboration: VALID, + }); + expect(resp.type).toBe("response.error"); + if (resp.type === "response.error") { + expect(resp.code).toBe("invalid_request"); + expect(resp.error).toMatch(/conflicts with the "orchestrator" role's backend "claude"/); + } + }); + + test("a providerId that agrees with the orchestrator role is accepted", async () => { + const resp = await run({ + type: "session.create", + id: "o3", + name: "orch3", + workdir, + providerId: "claude", + collaboration: VALID, + }); + expect(resp.type).toBe("response.ok"); + if (resp.type === "response.ok") { + expect((resp.data as SessionInfo).providerId).toBe("claude"); + } + }); + + test("a non-collaborative session still honors an explicit providerId", async () => { + const resp = await run({ + type: "session.create", + id: "o4", + name: "orch4", + workdir, + providerId: "gemini", + }); + expect(resp.type).toBe("response.ok"); + if (resp.type === "response.ok") { + expect((resp.data as SessionInfo).providerId).toBe("gemini"); + } + }); +});