Introduce pure resolveCeoMode function with unit tests (M7 phase 1)
This commit is contained in:
1 parent
da517b6e12
commit
92e6eff5da
2 files changed
+395
No files matched your search
@@ -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";
|
||||
}
|
||||
@@ -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> = {}): 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> = {}): 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> = {},
|
||||
): 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> = {},
|
||||
): 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> = {}): 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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user