diff --git a/src/main/prompt-builder/ceo-modes/resolve-mode.ts b/src/main/prompt-builder/ceo-modes/resolve-mode.ts new file mode 100644 index 00000000..18dd1f78 --- /dev/null +++ b/src/main/prompt-builder/ceo-modes/resolve-mode.ts @@ -0,0 +1,114 @@ +/** + * Pure resolver for the four CEO operating modes. + * + * The runtime decision "what phase is this CEO in" used to live in two + * parallel code paths that each derived a mode from the agent+company + * state independently — `operating-instructions-ceo.resolveMode` chose + * the markdown template, while `orchestration/initial-user-turn` + * separately decided the first user turn. Inconsistent outputs between + * the two produced the failure mode where the system prompt announced + * "your team is empty" while the turn cue read "continue on your most + * actionable task", or vice versa. + * + * This module introduces the single deterministic function + * `resolveCeoMode(input)` that M7 picks up for both call sites in a + * later phase. Phase 1 (this change) only ships the function and its + * unit tests — neither `operating-instructions-ceo.ts` nor + * `initial-user-turn.ts` is rewired yet. That keeps the behavioral + * change surface exactly zero while giving M7 Phase 2 a shared + * implementation to converge onto. + * + * The function is pure: no DB, no IO, no side effects. Every input is + * a projection the caller already has in hand (the CEO agent row, the + * company row, the filtered list of direct reports, the pending + * approvals list). That makes it trivial to exercise via table-driven + * unit tests without standing up a runtime. + */ + +import type { AgentRecord, ApprovalRecord, CompanyRecord } from "@shared/types"; + +/** + * Operating mode for a CEO agent. + * + * - `team_building`: no active team yet and nothing in flight — the + * CEO's only legitimate action is filing hires. + * - `awaiting_hire`: hire approvals are pending but no active team + * exists yet. Check approval status, resubmit on revision, stop. + * - `awaiting_org`: an `approve_ceo_strategy` approval is pending. + * The board owes a decision before tasks can begin. + * - `operating`: team exists, strategy is approved, the CEO runs the + * business. + */ +export type CeoMode = "team_building" | "awaiting_hire" | "awaiting_org" | "operating"; + +export interface ResolveCeoModeInput { + /** The CEO agent. Function asserts `role === "ceo"` to catch mis-calls. */ + agent: AgentRecord; + /** The company the CEO owns. Only used to scope the assertion. */ + company: CompanyRecord; + /** + * Direct reports of the CEO. Callers pass the full set — the resolver + * filters out `pending_approval` (a report still blocked in a hire + * approval doesn't count as "active") and `terminated` here, so every + * call site uses the same predicate. + */ + reports: AgentRecord[]; + /** + * Pending approvals owned by this company. The resolver scans for + * `approve_ceo_strategy` to detect the awaiting_org gate. + */ + pendingApprovals: ApprovalRecord[]; +} + +/** + * Classify the CEO's current operating mode from its immediate state. + * + * Priority ordering (first match wins): + * + * 1. `awaiting_org` — if an `approve_ceo_strategy` approval is + * pending, the org shape itself is in board review. Do not advance + * to operating regardless of team size. + * 2. `team_building` — no active reports AND no pending hires. The + * CEO has nothing to manage and nothing in flight. + * 3. `awaiting_hire` — no active reports but hires are pending board + * approval. Same "can't manage anyone yet" constraint, different + * action (check status, don't file duplicates). + * 4. `operating` — default. At least one active report and no + * org-strategy approval outstanding. + * + * The order matches the "external dependency first" rule: org strategy + * review gates everything downstream, so it outranks team composition + * signals. Between `team_building` and `awaiting_hire` the distinction + * is whether hires exist to wait on; both share the "no team yet" + * constraint but lead to different CEO actions. + */ +export function resolveCeoMode(input: ResolveCeoModeInput): CeoMode { + if (input.agent.role !== "ceo") { + throw new Error( + `resolveCeoMode called with non-CEO agent (id=${input.agent.id}, role=${input.agent.role})`, + ); + } + if (input.agent.companyId !== input.company.id) { + throw new Error( + `resolveCeoMode called with mismatched company: agent.companyId=${input.agent.companyId}, company.id=${input.company.id}`, + ); + } + + const activeReports = input.reports.filter( + (report) => + report.status !== "pending_approval" && + report.status !== "terminated", + ); + const pendingHires = input.reports.filter( + (report) => report.status === "pending_approval", + ); + const hasPendingOrgApproval = input.pendingApprovals.some( + (approval) => + approval.type === "approve_ceo_strategy" && approval.state === "pending", + ); + + if (hasPendingOrgApproval) return "awaiting_org"; + if (activeReports.length === 0 && pendingHires.length === 0) return "team_building"; + if (activeReports.length === 0 && pendingHires.length > 0) return "awaiting_hire"; + return "operating"; +} diff --git a/tests/unit/resolve-ceo-mode.test.ts b/tests/unit/resolve-ceo-mode.test.ts new file mode 100644 index 00000000..986c6f61 --- /dev/null +++ b/tests/unit/resolve-ceo-mode.test.ts @@ -0,0 +1,281 @@ +import { describe, expect, it } from "vitest"; +import { + resolveCeoMode, + type ResolveCeoModeInput, +} from "../../src/main/prompt-builder/ceo-modes/resolve-mode"; +import type { + AgentRecord, + ApprovalRecord, + CompanyRecord, +} from "../../src/shared/types"; + +/** + * resolveCeoMode is a pure function over immediate agent+company state, + * so these tests exercise it as a pure classifier. No SQLite required — + * standing up a database here would only test record round-tripping, + * which belongs in other suites. The table of cases below covers all + * four output modes and the boundary transitions between them. + */ + +const COMPANY_ID = "00000000-0000-0000-0000-000000000001"; +const CEO_ID = "00000000-0000-0000-0000-000000000010"; + +function makeCeo(overrides: Partial = {}): AgentRecord { + return { + id: CEO_ID, + companyId: COMPANY_ID, + name: "Chief", + role: "ceo", + title: "CEO", + department: null, + status: "active", + reportsTo: null, + connectorId: "openai-codex", + workspaceId: null, + model: null, + capabilities: "Run the company", + budgetMonthlyUsd: 1000, + spentMonthlyUsd: 0, + metadataJson: "{}", + heartbeatEnabled: true, + heartbeatIntervalSec: 120, + lastHeartbeatAt: null, + createdAt: "2026-04-01T00:00:00.000Z", + updatedAt: "2026-04-01T00:00:00.000Z", + ...overrides, + }; +} + +function makeCompany(overrides: Partial = {}): CompanyRecord { + return { + id: COMPANY_ID, + name: "Test Co", + description: "", + status: "active", + autoApproveHires: false, + reviewDeliverables: false, + executionMode: "ai_ceo", + createdAt: "2026-04-01T00:00:00.000Z", + updatedAt: "2026-04-01T00:00:00.000Z", + ...overrides, + }; +} + +function makeReport( + id: string, + status: AgentRecord["status"], + overrides: Partial = {}, +): AgentRecord { + return makeCeo({ + id, + role: "ic", + title: "Engineer", + name: `Report ${id}`, + reportsTo: CEO_ID, + status, + ...overrides, + }); +} + +function makeApproval( + type: ApprovalRecord["type"], + state: ApprovalRecord["state"], + overrides: Partial = {}, +): ApprovalRecord { + return { + id: `approval-${type}-${state}`, + companyId: COMPANY_ID, + relatedTaskId: null, + relatedAgentId: null, + requestedByAgentId: CEO_ID, + type, + payloadSummary: "", + impactSummary: "", + payloadJson: null, + decisionNote: "", + state, + createdAt: "2026-04-01T00:00:00.000Z", + updatedAt: "2026-04-01T00:00:00.000Z", + ...overrides, + }; +} + +function baseInput(overrides: Partial = {}): ResolveCeoModeInput { + return { + agent: makeCeo(), + company: makeCompany(), + reports: [], + pendingApprovals: [], + ...overrides, + }; +} + +describe("resolveCeoMode", () => { + // ─── team_building ────────────────────────────────────────────────── + + it("returns team_building when the CEO has no reports and no approvals", () => { + expect(resolveCeoMode(baseInput())).toBe("team_building"); + }); + + it("treats a terminated report as non-active: still team_building", () => { + expect( + resolveCeoMode( + baseInput({ + reports: [makeReport("ex", "terminated")], + }), + ), + ).toBe("team_building"); + }); + + // ─── awaiting_hire ────────────────────────────────────────────────── + + it("returns awaiting_hire when only pending-approval reports exist", () => { + expect( + resolveCeoMode( + baseInput({ + reports: [makeReport("hire-1", "pending_approval")], + }), + ), + ).toBe("awaiting_hire"); + }); + + it("awaiting_hire regardless of how many pending hires are queued", () => { + expect( + resolveCeoMode( + baseInput({ + reports: [ + makeReport("hire-1", "pending_approval"), + makeReport("hire-2", "pending_approval"), + makeReport("hire-3", "pending_approval"), + ], + }), + ), + ).toBe("awaiting_hire"); + }); + + // ─── awaiting_org ─────────────────────────────────────────────────── + + it("returns awaiting_org when an approve_ceo_strategy is pending, even with active reports", () => { + expect( + resolveCeoMode( + baseInput({ + reports: [makeReport("eng-1", "active")], + pendingApprovals: [makeApproval("approve_ceo_strategy", "pending")], + }), + ), + ).toBe("awaiting_org"); + }); + + it("awaiting_org outranks team_building when strategy is pending and the org is still empty", () => { + expect( + resolveCeoMode( + baseInput({ + pendingApprovals: [makeApproval("approve_ceo_strategy", "pending")], + }), + ), + ).toBe("awaiting_org"); + }); + + it("ignores a resolved strategy approval (approved state does not gate)", () => { + expect( + resolveCeoMode( + baseInput({ + reports: [makeReport("eng-1", "active")], + pendingApprovals: [makeApproval("approve_ceo_strategy", "approved")], + }), + ), + ).toBe("operating"); + }); + + // ─── operating ────────────────────────────────────────────────────── + + it("returns operating when at least one active report exists and no org approval is pending", () => { + expect( + resolveCeoMode( + baseInput({ + reports: [makeReport("eng-1", "active")], + }), + ), + ).toBe("operating"); + }); + + it("idle reports count as active for mode purposes (only pending/terminated exclude)", () => { + expect( + resolveCeoMode( + baseInput({ + reports: [makeReport("eng-1", "idle")], + }), + ), + ).toBe("operating"); + }); + + it("mixes pending and active: one active report is enough for operating", () => { + expect( + resolveCeoMode( + baseInput({ + reports: [ + makeReport("eng-1", "active"), + makeReport("hire-1", "pending_approval"), + ], + }), + ), + ).toBe("operating"); + }); + + // ─── guards ───────────────────────────────────────────────────────── + + it("throws when called with a non-CEO agent", () => { + expect(() => + resolveCeoMode( + baseInput({ + agent: makeCeo({ role: "ic" }), + }), + ), + ).toThrow(/non-CEO/); + }); + + it("throws when agent and company ids do not match", () => { + expect(() => + resolveCeoMode( + baseInput({ + company: makeCompany({ id: "00000000-0000-0000-0000-000000000999" }), + }), + ), + ).toThrow(/mismatched company/); + }); + + // ─── transitions ──────────────────────────────────────────────────── + + it("transition team_building → awaiting_hire: filing a hire flips the mode", () => { + const before = baseInput(); + const after = baseInput({ + reports: [makeReport("hire-1", "pending_approval")], + }); + expect(resolveCeoMode(before)).toBe("team_building"); + expect(resolveCeoMode(after)).toBe("awaiting_hire"); + }); + + it("transition awaiting_hire → operating: hire activation flips to operating", () => { + const before = baseInput({ + reports: [makeReport("hire-1", "pending_approval")], + }); + const after = baseInput({ + reports: [makeReport("hire-1", "active")], + }); + expect(resolveCeoMode(before)).toBe("awaiting_hire"); + expect(resolveCeoMode(after)).toBe("operating"); + }); + + it("transition operating → awaiting_org: filing a strategy approval gates further work", () => { + const reports = [makeReport("eng-1", "active")]; + expect(resolveCeoMode(baseInput({ reports }))).toBe("operating"); + expect( + resolveCeoMode( + baseInput({ + reports, + pendingApprovals: [makeApproval("approve_ceo_strategy", "pending")], + }), + ), + ).toBe("awaiting_org"); + }); +});