diff --git a/tests/integration/autonomous-chain.spec.ts b/tests/integration/autonomous-chain.spec.ts deleted file mode 100644 index 9344b697..00000000 --- a/tests/integration/autonomous-chain.spec.ts +++ /dev/null @@ -1,1409 +0,0 @@ -/** - * End-to-end integration tests for the autonomous operation chain. - * Tests all 8 steps from IMPLEMENTATION_PLAN.md v2: - * - * Step 1: CEO role detection fix - * Step 2: CEO phased prompts + pending approvals + credential escalation - * Step 3: Deferred wake queue - * Step 4: Hire approval wake - * Step 5: Task status + comment wake propagation - * Step 6: Run failure escalation - * Step 7: Worker crash recovery - * Step 8: Wake reason injection into prompt - */ - -import { mkdtemp, rm } from "node:fs/promises"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { AppDatabase } from "../../src/main/database"; -import { buildSystemPrompt, buildTaskPrompt } from "../../src/main/prompt-builder"; -import type { AgentRecord, ApprovalRecord, TaskRecord } from "../../src/shared/types"; - -const tempDirs: string[] = []; - -async function createTempDir(prefix: string) { - const dir = await mkdtemp(join(tmpdir(), prefix)); - tempDirs.push(dir); - return dir; -} - -let db: AppDatabase; -let companyId: string; -let workspaceId: string; - -beforeEach(async () => { - const profileDir = await createTempDir("agentcompany-chain-test-"); - db = new AppDatabase(profileDir); - db.init(); - - companyId = db.saveCompany({ - name: "Autonomous Test Corp", - description: "Test company for autonomous chain", - status: "active", - }); - - workspaceId = db.saveWorkspace({ - companyId, - name: "Primary", - localPath: profileDir, - repoUrl: "https://example.com/repo.git", - repoRef: "main", - isPrimary: true, - }); -}); - -afterEach(async () => { - db?.close(); - await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); -}); - -function createAgent(overrides: Partial[0]> = {}): string { - return db.saveAgent({ - companyId, - name: "TestAgent", - role: "CEO", - title: "Test", - status: "idle", - reportsTo: null, - connectorId: "codex_local", - workspaceId, - model: null, - capabilities: "Test capabilities", - budgetMonthlyUsd: 200, - ...overrides, - }); -} - -function buildCtx(overrides: Record = {}): Parameters[0] { - const agent = db.getAgent(overrides.agentId as string ?? createAgent()); - return { - agent, - apiUrl: "http://127.0.0.1:3000", - apiKey: "test-key", - runId: "test-run-id", - task: null, - companyName: "Autonomous Test Corp", - goals: [], - projects: [], - directReports: overrides.directReports as AgentRecord[] ?? [], - chainOfCommand: overrides.chainOfCommand as AgentRecord[] ?? [], - assignedTasks: [], - allAgents: [], - pendingApprovals: overrides.pendingApprovals as ApprovalRecord[] ?? [], - tasksAwaitingReview: overrides.tasksAwaitingReview as TaskRecord[] ?? [], - wakeReason: overrides.wakeReason as string | null ?? null, - allTasks: [], - recentComments: [], - companyDescription: "", - socialAccounts: [], - ...overrides, - } as Parameters[0]; -} - -// ─── Step 1: CEO Role Detection ───────────────────────────────────── - -describe("Step 1: CEO role detection", () => { - it("identifies CEO when chainOfCommand is empty, even with no direct reports", () => { - const ceoId = createAgent({ name: "CEO", role: "CEO" }); - const ctx = buildCtx({ - agentId: ceoId, - directReports: [], // no reports yet! - chainOfCommand: [], // top-level - }); - - const prompt = buildSystemPrompt(ctx); - - // CEO should NOT get IC instructions - expect(prompt).not.toContain("Individual Contributor"); - // CEO should get executive instructions (Phase 0: Team Building) - expect(prompt).toContain("CEO"); - expect(prompt).toContain("Team Building"); - }); - - it("identifies manager when has reports and has chain of command", () => { - const ceoId = createAgent({ name: "CEO", role: "CEO" }); - const managerId = createAgent({ name: "CTO", role: "CTO", reportsTo: ceoId }); - const ceo = db.getAgent(ceoId); - const icId = createAgent({ name: "IC", role: "Engineer", reportsTo: managerId }); - const ic = db.getAgent(icId); - - const ctx = buildCtx({ - agentId: managerId, - directReports: [ic], - chainOfCommand: [ceo], // reports to CEO - }); - - const prompt = buildSystemPrompt(ctx); - expect(prompt).toContain("Operating Instructions (Manager)"); - expect(prompt).not.toContain("Individual Contributor"); - expect(prompt).not.toContain("Executive / CEO"); - }); - - it("identifies IC when has chain of command and no direct reports", () => { - const ceoId = createAgent({ name: "CEO", role: "CEO" }); - const ceo = db.getAgent(ceoId); - const icId = createAgent({ name: "Dev", role: "Engineer", reportsTo: ceoId }); - - const ctx = buildCtx({ - agentId: icId, - directReports: [], - chainOfCommand: [ceo], - }); - - const prompt = buildSystemPrompt(ctx); - expect(prompt).toContain("Individual Contributor"); - expect(prompt).not.toContain("Operating Instructions (Manager)"); - }); -}); - -// ─── Step 2: CEO Phased Prompts ───────────────────────────────────── - -describe("Step 2: CEO phased prompts", () => { - it("Phase 0: shows team building instructions when no reports at all", () => { - const ceoId = createAgent({ name: "CEO", role: "CEO" }); - - const ctx = buildCtx({ - agentId: ceoId, - directReports: [], // truly no reports - chainOfCommand: [], - pendingApprovals: [], - }); - - const prompt = buildSystemPrompt(ctx); - expect(prompt).toContain("Team Building Phase"); - expect(prompt).toContain("Design the Organization Structure"); - expect(prompt).toContain("Submit Org Strategy for Approval"); - expect(prompt).not.toContain("Awaiting Org Approval"); - expect(prompt).not.toContain("Goal Decomposition"); - }); - - it("Phase 0.5: shows awaiting hire approvals when pending_approval hires exist but no org approval", () => { - const ceoId = createAgent({ name: "CEO", role: "CEO" }); - - const hireId = createAgent({ name: "CTO", role: "CTO", reportsTo: ceoId, status: "pending_approval" as "idle" }); - const hire = db.getAgent(hireId); - - const ctx = buildCtx({ - agentId: ceoId, - directReports: [hire], - chainOfCommand: [], - pendingApprovals: [], - }); - - const prompt = buildSystemPrompt(ctx); - expect(prompt).toContain("Awaiting Hire Approvals"); - expect(prompt).not.toContain("Team Building Phase"); - expect(prompt).not.toContain("Goal Decomposition"); - }); - - it("Phase 1: shows awaiting org approval when pending approve_ceo_strategy exists", () => { - const ceoId = createAgent({ name: "CEO", role: "CEO" }); - - const ctx = buildCtx({ - agentId: ceoId, - directReports: [], - chainOfCommand: [], - pendingApprovals: [ - { - id: "approval-1", - companyId, - type: "approve_ceo_strategy", - state: "pending", - payloadSummary: "Org structure: CEO + CTO + CMO", - impactSummary: "Team of 3", - requestedByAgentId: ceoId, - relatedTaskId: null, - decisionNote: "", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - } as ApprovalRecord, - ], - }); - - const prompt = buildSystemPrompt(ctx); - expect(prompt).toContain("Awaiting Org Approval"); - expect(prompt).not.toContain("Team Building Phase"); - expect(prompt).not.toContain("Goal Decomposition"); - }); - - it("Phase 2: shows normal executive instructions when active reports and no pending org approval", () => { - const ceoId = createAgent({ name: "CEO", role: "CEO" }); - const ctoId = createAgent({ name: "CTO", role: "CTO", reportsTo: ceoId, status: "idle" }); - const cto = db.getAgent(ctoId); - - const ctx = buildCtx({ - agentId: ceoId, - directReports: [cto], - chainOfCommand: [], - pendingApprovals: [], - }); - - const prompt = buildSystemPrompt(ctx); - expect(prompt).toContain("Executive / CEO"); - expect(prompt).toContain("Goal Decomposition"); - expect(prompt).toContain("Delegation"); - expect(prompt).toContain("Unblock & Coordinate"); - expect(prompt).not.toContain("Team Building Phase"); - expect(prompt).not.toContain("Awaiting Org Approval"); - }); - - it("includes pending approvals section in prompt", () => { - const ceoId = createAgent({ name: "CEO", role: "CEO" }); - const ctoId = createAgent({ name: "CTO", role: "CTO", reportsTo: ceoId, status: "idle" }); - const cto = db.getAgent(ctoId); - - const ctx = buildCtx({ - agentId: ceoId, - directReports: [cto], - chainOfCommand: [], - pendingApprovals: [ - { - id: "approval-x", - companyId, - type: "secret_access", - state: "pending", - payloadSummary: "Need Twitter API keys", - impactSummary: "", - requestedByAgentId: ceoId, - relatedTaskId: null, - decisionNote: "", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - } as ApprovalRecord, - ], - }); - - const prompt = buildSystemPrompt(ctx); - expect(prompt).toContain("Pending Approvals"); - expect(prompt).toContain("Need Twitter API keys"); - }); - - it("Manager prompt includes credential escalation guidance", () => { - const ceoId = createAgent({ name: "CEO", role: "CEO" }); - const ceo = db.getAgent(ceoId); - const managerId = createAgent({ name: "CTO", role: "CTO", reportsTo: ceoId }); - const icId = createAgent({ name: "Dev", role: "Engineer", reportsTo: managerId }); - const ic = db.getAgent(icId); - - const ctx = buildCtx({ - agentId: managerId, - directReports: [ic], - chainOfCommand: [ceo], - }); - - const prompt = buildSystemPrompt(ctx); - expect(prompt).toContain("Break down work and delegate it"); - }); - - it("IC prompt includes credential escalation guidance", () => { - const ceoId = createAgent({ name: "CEO", role: "CEO" }); - const ceo = db.getAgent(ceoId); - const icId = createAgent({ name: "Dev", role: "Engineer", reportsTo: ceoId }); - - const ctx = buildCtx({ - agentId: icId, - directReports: [], - chainOfCommand: [ceo], - }); - - const prompt = buildSystemPrompt(ctx); - expect(prompt).toContain("Your manager is auto-notified and will help"); - expect(prompt).toContain("explain what API key/account you need"); - }); -}); - -// ─── Step 3: Deferred Wake Queue ──────────────────────────────────── - -describe("Step 3: Wakeup request lifecycle (database)", () => { - it("enqueue and list wakeup requests with coalescing", () => { - const agentId = createAgent({ name: "TestAgent" }); - - // Enqueue two wakes for the same busy agent. Coalesced into one row - // with trigger_detail JSON array containing both triggers. - db.enqueueWakeupRequest(companyId, agentId, "subtask_completed", "subtask_completed"); - db.enqueueWakeupRequest(companyId, agentId, "comment", "comment"); - - // List returns the coalesced wake - const wakes = db.listWakeupRequests(companyId, { agentId, status: "queued" }); - expect(wakes).toHaveLength(1); - expect(wakes[0].companyId).toBe(companyId); - expect(wakes[0].coalescedCount).toBe(1); - - const triggers = JSON.parse(wakes[0].triggerDetail!); - expect(triggers).toHaveLength(2); - expect(triggers[0].trigger).toBe("subtask_completed"); - expect(triggers[1].trigger).toBe("comment"); - }); - - it("returns empty array when no wakeup requests exist", () => { - const agentId = createAgent({ name: "TestAgent" }); - const wakes = db.listWakeupRequests(companyId, { agentId, status: "queued" }); - expect(wakes).toHaveLength(0); - }); - - it("does not return wakes for other agents", () => { - const agent1 = createAgent({ name: "Agent1" }); - const agent2 = createAgent({ name: "Agent2" }); - - db.enqueueWakeupRequest(companyId, agent1, "comment", "comment"); - db.enqueueWakeupRequest(companyId, agent2, "subtask_completed", "subtask_completed"); - - const wakes1 = db.listWakeupRequests(companyId, { agentId: agent1, status: "queued" }); - expect(wakes1).toHaveLength(1); - expect(wakes1[0].source).toBe("comment"); - - // Agent2's wake should still be there - const wakes2 = db.listWakeupRequests(companyId, { agentId: agent2, status: "queued" }); - expect(wakes2).toHaveLength(1); - expect(wakes2[0].source).toBe("subtask_completed"); - }); -}); - -// ─── Step 4: Hire Approval Wake ───────────────────────────────────── - -describe("Step 4: Hire approval + decideApproval", () => { - it("decideApproval sets hired agent to idle when approved", () => { - const ceoId = createAgent({ name: "CEO", role: "CEO" }); - const hireId = createAgent({ name: "CTO", role: "CTO", reportsTo: ceoId, status: "pending_approval" as "idle" }); - - const approvalId = db.requestApproval({ - companyId, - requestedByAgentId: ceoId, - type: "hire_agent", - payloadSummary: "Hire CTO", - impactSummary: "Test impact", - payloadJson: JSON.stringify({ agentId: hireId }), - }); - - db.decideApproval({ - approvalId, - state: "approved", - decisionNote: "Approved by board", - }); - - const updatedAgent = db.getAgent(hireId); - expect(updatedAgent.status).toBe("idle"); - }); - - it("decideApproval sets hired agent to terminated when rejected", () => { - const ceoId = createAgent({ name: "CEO", role: "CEO" }); - const hireId = createAgent({ name: "CTO", role: "CTO", reportsTo: ceoId, status: "pending_approval" as "idle" }); - - const approvalId = db.requestApproval({ - companyId, - requestedByAgentId: ceoId, - type: "hire_agent", - payloadSummary: "Hire CTO", - impactSummary: "Test impact", - payloadJson: JSON.stringify({ agentId: hireId }), - }); - - db.decideApproval({ - approvalId, - state: "rejected", - decisionNote: "Not needed", - }); - - const updatedAgent = db.getAgent(hireId); - expect(updatedAgent.status).toBe("terminated"); - }); - - it("getApprovalRecord includes payloadJson", () => { - const ceoId = createAgent({ name: "CEO", role: "CEO" }); - const hireId = createAgent({ name: "CTO", role: "CTO", reportsTo: ceoId, status: "pending_approval" as "idle" }); - const approvalId = db.requestApproval({ - companyId, - requestedByAgentId: ceoId, - type: "hire_agent", - payloadSummary: "Hire CTO", - impactSummary: "Test", - payloadJson: JSON.stringify({ agentId: hireId, config: { connector: "codex_local" } }), - }); - - const approval = db.getApprovalRecord(approvalId); - expect(approval.payloadJson).toBeTruthy(); - const payload = JSON.parse(approval.payloadJson!); - expect(payload.agentId).toBe(hireId); - }); -}); - -// ─── Step 5: Task Status + Comment Wake Propagation ───────────────── - -describe("Step 5: Task status + comment wake propagation", () => { - it("getChainOfCommand returns correct hierarchy", () => { - const ceoId = createAgent({ name: "CEO", role: "CEO", reportsTo: null }); - const ctoId = createAgent({ name: "CTO", role: "CTO", reportsTo: ceoId }); - const icId = createAgent({ name: "Dev", role: "Engineer", reportsTo: ctoId }); - - const chain = db.getChainOfCommand(icId); - expect(chain).toHaveLength(2); - expect(chain[0].id).toBe(ctoId); // immediate manager - expect(chain[1].id).toBe(ceoId); // top level - }); - - it("getChainOfCommand handles circular references safely", () => { - // Create a potential cycle (shouldn't happen in practice, but guard against it) - const agent1 = createAgent({ name: "Agent1" }); - // agent1 reports to no one (CEO), so chain should be empty - const chain = db.getChainOfCommand(agent1); - expect(chain).toHaveLength(0); - }); - - it("task tree supports parent-child relationships", () => { - const ceoId = createAgent({ name: "CEO", role: "CEO" }); - const ctoId = createAgent({ name: "CTO", role: "CTO", reportsTo: ceoId }); - - const parentTaskId = db.saveTask({ - companyId, - title: "Build feature X", - description: "Top-level task", - assigneeAgentId: ctoId, - workspaceId, - priority: "high", - status: "in_progress", - }); - - const childTaskId = db.saveTask({ - companyId, - title: "Implement module A", - description: "Subtask", - assigneeAgentId: ctoId, - parentId: parentTaskId, - workspaceId, - priority: "medium", - status: "todo", - }); - - const childTask = db.getTask(childTaskId); - expect(childTask.parentId).toBe(parentTaskId); - - const parentTask = db.getTask(parentTaskId); - expect(parentTask.assigneeAgentId).toBe(ctoId); - }); -}); - -// ─── Step 6: Run Failure Escalation ───────────────────────────────── - -describe("Step 6: Run failure escalation (database layer)", () => { - it("finishRun marks run as failed and returns outcome", () => { - const agentId = createAgent({ name: "IC", role: "Engineer" }); - const taskId = db.saveTask({ - companyId, - title: "Failing task", - description: "Will fail", - assigneeAgentId: agentId, - workspaceId, - priority: "medium", - status: "in_progress", - }); - - const run = db.createRun({ - companyId, - taskId, - agentId, - workspaceId, - connectorId: "codex_local", - }); - - db.markRunStatus(run.id, "running", "Started"); - - const outcome = db.finishRun({ - runId: run.id, - status: "failed", - summary: null, - errorMessage: "Process crashed", - exitCode: 1, - signal: null, - model: "gpt-5-codex", - sessionDisplayId: null, - costUsd: 0.5, - unattributedCost: false, - }); - - expect(outcome.budgetHardStopped).toBe(false); - - const snapshot = db.listSnapshot(); - const finishedRun = snapshot.runs.find((r) => r.id === run.id); - expect(finishedRun?.status).toBe("failed"); - expect(finishedRun?.errorMessage).toBe("Process crashed"); - }); - - it("getChainOfCommand returns manager for failure escalation", () => { - const ceoId = createAgent({ name: "CEO", role: "CEO" }); - const ctoId = createAgent({ name: "CTO", role: "CTO", reportsTo: ceoId }); - const icId = createAgent({ name: "Dev", role: "Engineer", reportsTo: ctoId }); - - const chain = db.getChainOfCommand(icId); - // First in chain is the immediate manager (CTO) - expect(chain.length).toBeGreaterThan(0); - expect(chain[0].name).toBe("CTO"); - }); -}); - -// ─── Step 7: Worker Crash Recovery ────────────────────────────────── - -describe("Step 7: Worker crash recovery (database layer)", () => { - it("interruptStaleRuns clears running and queued runs on init", async () => { - const agentId = createAgent({ name: "Agent" }); - const taskId = db.saveTask({ - companyId, - title: "In-progress task", - description: "Running when crash happened", - assigneeAgentId: agentId, - workspaceId, - priority: "medium", - status: "in_progress", - }); - - const run = db.createRun({ - companyId, - taskId, - agentId, - workspaceId, - connectorId: "codex_local", - }); - - db.markRunStatus(run.id, "running", "Started"); - - // Verify it's running - let snapshot = db.listSnapshot(); - expect(snapshot.runs.find((r) => r.id === run.id)?.status).toBe("running"); - - // Simulate app restart — close and re-init - db.close(); - - const profileDir = tempDirs[tempDirs.length - 1]!; - db = new AppDatabase(profileDir); - db.init(); // This calls interruptStaleRuns() - - snapshot = db.listSnapshot(); - const recoveredRun = snapshot.runs.find((r) => r.id === run.id); - expect(recoveredRun?.status).toBe("interrupted"); - }); - - it("finishRun can mark interrupted runs from crash recovery", () => { - const agentId = createAgent({ name: "Agent" }); - const taskId = db.saveTask({ - companyId, - title: "Crash recovery task", - description: "Test", - assigneeAgentId: agentId, - workspaceId, - priority: "medium", - status: "in_progress", - }); - - const run = db.createRun({ - companyId, - taskId, - agentId, - workspaceId, - connectorId: "codex_local", - }); - - db.markRunStatus(run.id, "running", "Started"); - - // Simulate crash recovery - mark as interrupted - db.finishRun({ - runId: run.id, - status: "interrupted", - summary: "Worker process crashed.", - errorMessage: "Worker process exited unexpectedly.", - exitCode: 1, - signal: null, - model: null, - sessionDisplayId: null, - costUsd: null, - unattributedCost: true, - }); - - const snapshot = db.listSnapshot(); - const recovered = snapshot.runs.find((r) => r.id === run.id); - expect(recovered?.status).toBe("interrupted"); - expect(recovered?.errorMessage).toBe("Worker process exited unexpectedly."); - }); -}); - -// ─── Step 8: Wake Reason Injection ────────────────────────────────── - -describe("Step 8: Wake reason injection", () => { - it("includes wake reason section for non-timer triggers", () => { - const agentId = createAgent({ name: "CTO", role: "CTO" }); - const ceoId = createAgent({ name: "CEO", role: "CEO" }); - const ceo = db.getAgent(ceoId); - - const ctx = buildCtx({ - agentId, - chainOfCommand: [ceo], - wakeReason: "subtask_completed", - }); - - const prompt = buildTaskPrompt(ctx); - expect(prompt).toContain("Wake Reason"); - expect(prompt).toContain("subtask you delegated has been completed"); - }); - - it("includes wake reason for timer triggers", () => { - const agentId = createAgent({ name: "Agent" }); - - const ctx = buildCtx({ - agentId, - wakeReason: "timer", - }); - - const prompt = buildTaskPrompt(ctx); - expect(prompt).toContain("Wake Reason"); - expect(prompt).toContain("Regular heartbeat check-in"); - }); - - it("includes wake reason for manual triggers", () => { - const agentId = createAgent({ name: "Agent" }); - - const ctx = buildCtx({ - agentId, - wakeReason: "manual", - }); - - const prompt = buildTaskPrompt(ctx); - expect(prompt).toContain("Wake Reason"); - expect(prompt).toContain("manually triggered by the human operator"); - }); - - it("includes correct message for each wake reason", () => { - const agentId = createAgent({ name: "Agent" }); - - const reasons: Record = { - assignment: "You have been assigned a new task", - goal_activated: "goal you own has been activated", - approval_resolved: "approval you requested has been decided", - subtask_completed: "subtask you delegated has been completed", - comment: "new comment has been posted", - report_blocked: "reports is blocked", - report_failed: "reports has failed", - }; - - for (const [reason, expectedText] of Object.entries(reasons)) { - const ctx = buildCtx({ - agentId, - wakeReason: reason, - }); - const prompt = buildTaskPrompt(ctx); - expect(prompt).toContain("Wake Reason"); - expect(prompt).toContain(expectedText); - } - }); - - it("handles unknown wake reason gracefully", () => { - const agentId = createAgent({ name: "Agent" }); - - const ctx = buildCtx({ - agentId, - wakeReason: "custom_trigger_xyz", - }); - - const prompt = buildTaskPrompt(ctx); - expect(prompt).toContain("Wake Reason"); - expect(prompt).toContain("Trigger: custom_trigger_xyz"); - }); - - it("includes wake reason before task description", () => { - const agentId = createAgent({ name: "Agent" }); - const taskId = db.saveTask({ - companyId, - title: "Do the thing", - description: "Test task", - assigneeAgentId: agentId, - workspaceId, - priority: "medium", - status: "todo", - }); - const task = db.getTask(taskId); - - const ctx = buildCtx({ - agentId, - task, - wakeReason: "assignment", - }); - - const prompt = buildTaskPrompt(ctx); - const wakeIndex = prompt.indexOf("Wake Reason"); - const taskIndex = prompt.indexOf("Your immediate task"); - expect(wakeIndex).toBeGreaterThan(-1); - expect(taskIndex).toBeGreaterThan(-1); - expect(wakeIndex).toBeLessThan(taskIndex); - }); -}); - -// ─── E2E: Full Autonomous Chain Flow ──────────────────────────────── - -describe("E2E: Full autonomous CEO cold start flow", () => { - it("simulates complete chain: CEO creation → team build → org approval → delegation → completion", () => { - // 1. Create CEO agent - const ceoId = createAgent({ name: "Atlas", role: "CEO", status: "idle" }); - - // CEO has no team yet → Phase 0 - const phase0Ctx = buildCtx({ - agentId: ceoId, - directReports: [], - chainOfCommand: [], - pendingApprovals: [], - }); - const phase0Prompt = buildSystemPrompt(phase0Ctx); - expect(phase0Prompt).toContain("Team Building Phase"); - - // 2. CEO submits hire requests - const ctoId = createAgent({ name: "Nova", role: "CTO", reportsTo: ceoId, status: "pending_approval" as "idle" }); - const cmoId = createAgent({ name: "Aria", role: "CMO", reportsTo: ceoId, status: "pending_approval" as "idle" }); - - // 3. CEO submits org strategy approval - const orgApprovalId = db.requestApproval({ - companyId, - requestedByAgentId: ceoId, - type: "approve_ceo_strategy", - payloadSummary: "Org: CEO + CTO + CMO", - impactSummary: "3-person team", - }); - - // CEO now in Phase 1 - const ctoRecord = db.getAgent(ctoId); - const cmoRecord = db.getAgent(cmoId); - const phase1Ctx = buildCtx({ - agentId: ceoId, - directReports: [ctoRecord, cmoRecord], - chainOfCommand: [], - pendingApprovals: [{ - id: orgApprovalId, - companyId, - type: "approve_ceo_strategy", - state: "pending", - payloadSummary: "Org: CEO + CTO + CMO", - impactSummary: "3-person team", - requestedByAgentId: ceoId, - relatedTaskId: null, - decisionNote: "", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - } as ApprovalRecord], - }); - const phase1Prompt = buildSystemPrompt(phase1Ctx); - expect(phase1Prompt).toContain("Awaiting Org Approval"); - - // 4. Board approves hires - const hireApprovalCTO = db.requestApproval({ - companyId, - requestedByAgentId: ceoId, - type: "hire_agent", - payloadSummary: "Hire Nova as CTO", - impactSummary: "Test", - payloadJson: JSON.stringify({ agentId: ctoId }), - }); - db.decideApproval({ approvalId: hireApprovalCTO, state: "approved", decisionNote: "Approved" }); - - const hireApprovalCMO = db.requestApproval({ - companyId, - requestedByAgentId: ceoId, - type: "hire_agent", - payloadSummary: "Hire Aria as CMO", - impactSummary: "Test", - payloadJson: JSON.stringify({ agentId: cmoId }), - }); - db.decideApproval({ approvalId: hireApprovalCMO, state: "approved", decisionNote: "Approved" }); - - // Agents now idle - expect(db.getAgent(ctoId).status).toBe("idle"); - expect(db.getAgent(cmoId).status).toBe("idle"); - - // 5. Board approves org strategy - db.decideApproval({ approvalId: orgApprovalId, state: "approved", decisionNote: "Go ahead" }); - - // 6. CEO now in Phase 2 - const updatedCto = db.getAgent(ctoId); - const updatedCmo = db.getAgent(cmoId); - const phase2Ctx = buildCtx({ - agentId: ceoId, - directReports: [updatedCto, updatedCmo], - chainOfCommand: [], - pendingApprovals: [], // all approved now - }); - const phase2Prompt = buildSystemPrompt(phase2Ctx); - expect(phase2Prompt).toContain("Executive / CEO"); - expect(phase2Prompt).toContain("Goal Decomposition"); - expect(phase2Prompt).toContain("Delegation"); - - // 7. Verify hierarchical chain - const ctoChain = db.getChainOfCommand(ctoId); - expect(ctoChain).toHaveLength(1); - expect(ctoChain[0].id).toBe(ceoId); - - // 8. Create task tree - const projectId = db.saveProject({ - companyId, - name: "Launch MVP", - description: "Build and launch MVP", - goalId: null, - leadAgentId: ctoId, - status: "in_progress", - }); - - const parentTaskId = db.saveTask({ - companyId, - title: "Build backend", - description: "Implement API", - assigneeAgentId: ctoId, - projectId, - workspaceId, - priority: "high", - status: "in_progress", - }); - - // CTO creates subtask for IC (would be done via API) - const icId = createAgent({ name: "Dev", role: "Engineer", reportsTo: ctoId, status: "idle" }); - const subtaskId = db.saveTask({ - companyId, - title: "Implement auth", - description: "JWT auth", - assigneeAgentId: icId, - parentId: parentTaskId, - projectId, - workspaceId, - priority: "medium", - status: "todo", - }); - - // Verify parent-child relationship - const subtask = db.getTask(subtaskId); - expect(subtask.parentId).toBe(parentTaskId); - - // 9. Wakeup request test: enqueue wake while IC is busy - db.enqueueWakeupRequest(companyId, icId, "comment", "comment"); - const queuedWakes = db.listWakeupRequests(companyId, { agentId: icId, status: "queued" }); - expect(queuedWakes).toHaveLength(1); - expect(queuedWakes[0].source).toBe("comment"); - - // 10. Verify IC prompt has correct wake reason - const icPromptCtx = buildCtx({ - agentId: icId, - task: subtask, - chainOfCommand: [db.getAgent(ctoId), db.getAgent(ceoId)], - wakeReason: "assignment", - }); - const icPrompt = buildTaskPrompt(icPromptCtx); - expect(icPrompt).toContain("Wake Reason"); - expect(icPrompt).toContain("You have been assigned a new task"); - expect(icPrompt).toContain("Implement auth"); - }); -}); - -// ─── E2E: Deferred Wake + Run Lifecycle ───────────────────────────── - -describe("E2E: Wakeup request lifecycle with run lifecycle", () => { - it("wake enqueue + coalesce + claim + complete cycle", () => { - const agentId = createAgent({ name: "Agent" }); - - // Simulate: agent is running, wakes come in. All triggers are coalesced - // into a single wakeup request with JSON array of trigger details. - db.enqueueWakeupRequest(companyId, agentId, "subtask_completed", "subtask_completed"); - db.enqueueWakeupRequest(companyId, agentId, "comment", "comment"); - db.enqueueWakeupRequest(companyId, agentId, "report_blocked", "report_blocked"); - - // Verify coalesced into single row with all triggers - const wakes = db.listWakeupRequests(companyId, { agentId, status: "queued" }); - expect(wakes).toHaveLength(1); - expect(wakes[0].coalescedCount).toBe(2); - - const triggers = JSON.parse(wakes[0].triggerDetail!); - expect(triggers).toHaveLength(3); - expect(triggers[0].trigger).toBe("subtask_completed"); - expect(triggers[1].trigger).toBe("comment"); - expect(triggers[2].trigger).toBe("report_blocked"); - - // Wakes remain queued until consumed by the run lifecycle - expect(db.listWakeupRequests(companyId, { agentId, status: "queued" })).toHaveLength(1); - }); -}); - -// ─── E2E: Blocked Escalation Chain ────────────────────────────────── - -describe("E2E: Blocked escalation through org hierarchy", () => { - it("getChainOfCommand provides escalation path for blocked IC", () => { - const ceoId = createAgent({ name: "CEO", role: "CEO" }); - const ctoId = createAgent({ name: "CTO", role: "CTO", reportsTo: ceoId }); - const icId = createAgent({ name: "Dev", role: "Engineer", reportsTo: ctoId }); - - // When IC marks task blocked, we need to find its manager - const chain = db.getChainOfCommand(icId); - expect(chain[0].id).toBe(ctoId); // immediate manager gets woken - - // When CTO needs to escalate further - const ctoChain = db.getChainOfCommand(ctoId); - expect(ctoChain[0].id).toBe(ceoId); - }); -}); - -// ─── Adversarial Review Fix Tests ─────────────────────────────────── - -describe("Review Fix: CEO phase ordering edge case", () => { - it("shows awaiting hire approvals when org strategy approved first but hires still pending", () => { - const ceoId = createAgent({ name: "CEO", role: "CEO" }); - // Hires submitted but not yet approved - const hireId = createAgent({ name: "CTO", role: "CTO", reportsTo: ceoId, status: "pending_approval" as "idle" }); - const hire = db.getAgent(hireId); - - // Org strategy already approved (not in pendingApprovals), but hires still pending - const ctx = buildCtx({ - agentId: ceoId, - directReports: [hire], // only pending_approval - chainOfCommand: [], - pendingApprovals: [], // org strategy already resolved - }); - - const prompt = buildSystemPrompt(ctx); - // Should NOT get Phase 0 (team building) — hires are already submitted - expect(prompt).not.toContain("Team Building Phase"); - // Should get the awaiting hire approvals phase - expect(prompt).toContain("Awaiting Hire Approvals"); - }); - - it("moves to Phase 2 once hires are approved (no pending org approval)", () => { - const ceoId = createAgent({ name: "CEO", role: "CEO" }); - const ctoId = createAgent({ name: "CTO", role: "CTO", reportsTo: ceoId, status: "idle" }); - const cto = db.getAgent(ctoId); - - const ctx = buildCtx({ - agentId: ceoId, - directReports: [cto], // active report - chainOfCommand: [], - pendingApprovals: [], - }); - - const prompt = buildSystemPrompt(ctx); - expect(prompt).toContain("Executive / CEO"); - expect(prompt).not.toContain("Awaiting Hire Approvals"); - expect(prompt).not.toContain("Team Building Phase"); - }); -}); - -describe("Review Fix: Org gate consistency (revision_requested)", () => { - it("org-strategy revision_requested blocks the CEO in Phase 1", () => { - const ceoId = createAgent({ name: "CEO", role: "CEO" }); - - const ctx = buildCtx({ - agentId: ceoId, - directReports: [], - chainOfCommand: [], - pendingApprovals: [ - { - id: "approval-rev", - companyId, - type: "approve_ceo_strategy", - state: "revision_requested", - payloadSummary: "Org plan needs changes", - impactSummary: "", - requestedByAgentId: ceoId, - relatedTaskId: null, - decisionNote: "Please add a CPO", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - } as ApprovalRecord, - ], - }); - - const prompt = buildSystemPrompt(ctx); - expect(prompt).toContain("Awaiting Org Approval"); - expect(prompt).toContain("revision_requested"); - }); -}); - -describe("Review Fix: Rejected approval wakes agent", () => { - it("decideApproval with rejected state still triggers wake (tested via DB)", () => { - const ceoId = createAgent({ name: "CEO", role: "CEO" }); - - const approvalId = db.requestApproval({ - companyId, - requestedByAgentId: ceoId, - type: "approve_ceo_strategy", - payloadSummary: "Org plan", - impactSummary: "test", - }); - - db.decideApproval({ - approvalId, - state: "rejected", - decisionNote: "Not ready", - }); - - // Verify approval state - const approval = db.getApprovalRecord(approvalId); - expect(approval.state).toBe("rejected"); - // The IPC handler in index.ts now wakes on rejected too — - // we verify the DB layer works; the wakeAgentIfPossible call is in index.ts - }); -}); - -describe("Review Fix: Root task completion notification", () => { - it("root task (no parentId) should still have org hierarchy for manager lookup", () => { - const ceoId = createAgent({ name: "CEO", role: "CEO" }); - const ctoId = createAgent({ name: "CTO", role: "CTO", reportsTo: ceoId }); - - // CTO has a root-level task (no parentId) - const taskId = db.saveTask({ - companyId, - title: "Standalone task from CEO", - description: "No parent", - assigneeAgentId: ctoId, - workspaceId, - priority: "high", - status: "done", - }); - - const task = db.getTask(taskId); - expect(task.parentId).toBeNull(); - - // CTO's chain of command includes CEO — so api-server can wake CEO on completion - const chain = db.getChainOfCommand(ctoId); - expect(chain).toHaveLength(1); - expect(chain[0].id).toBe(ceoId); - }); -}); - -// ── Automation Rules CRUD ────────────────────────────────────────────── - -describe("Automation Rules CRUD", () => { - it("creates, reads, toggles, and deletes an automation rule", () => { - const ruleId = db.saveAutomationRule({ - companyId, - name: "Auto-escalate blocked tasks", - description: "When a task is blocked, notify the manager", - trigger: "task_blocked", - conditionsJson: JSON.stringify({ department: "engineering" }), - action: "escalate_to_manager", - actionConfigJson: JSON.stringify({ message: "Task blocked" }), - sourceDepartment: "engineering", - targetDepartment: "executive", - priority: 80, - status: "active", - }); - - expect(ruleId).toBeTruthy(); - - // Rule appears in snapshot - const snapshot = db.listSnapshot(); - const rule = snapshot.automationRules.find(r => r.id === ruleId); - expect(rule).toBeTruthy(); - expect(rule!.name).toBe("Auto-escalate blocked tasks"); - expect(rule!.trigger).toBe("task_blocked"); - expect(rule!.action).toBe("escalate_to_manager"); - expect(rule!.status).toBe("active"); - expect(rule!.priority).toBe(80); - expect(rule!.executionCount).toBe(0); - - // Toggle to paused - db.toggleAutomationRule(ruleId, companyId, "paused"); - const updated = db.listSnapshot().automationRules.find(r => r.id === ruleId); - expect(updated!.status).toBe("paused"); - - // Record execution - db.recordAutomationExecution(ruleId, companyId, "Auto-escalate blocked tasks", "task_blocked", "escalate_to_manager", "Escalated task-123"); - const afterExec = db.listSnapshot().automationRules.find(r => r.id === ruleId); - expect(afterExec!.executionCount).toBe(1); - expect(afterExec!.lastExecutedAt).toBeTruthy(); - - // Automation log - const log = db.getAutomationLog(companyId); - expect(log).toHaveLength(1); - expect(log[0].ruleName).toBe("Auto-escalate blocked tasks"); - - // Delete - db.deleteAutomationRule(ruleId, companyId); - const afterDelete = db.listSnapshot().automationRules.find(r => r.id === ruleId); - expect(afterDelete).toBeUndefined(); - }); - - it("updates an existing rule", () => { - const ruleId = db.saveAutomationRule({ - companyId, - name: "Original name", - trigger: "task_created", - action: "assign_task", - }); - - db.saveAutomationRule({ - id: ruleId, - companyId, - name: "Updated name", - trigger: "task_created", - action: "notify_agent", - priority: 90, - }); - - const rule = db.listSnapshot().automationRules.find(r => r.id === ruleId); - expect(rule!.name).toBe("Updated name"); - expect(rule!.action).toBe("notify_agent"); - expect(rule!.priority).toBe(90); - }); -}); - -// ── Agent Messages ───────────────────────────────────────────────────── - -describe("Agent Messages", () => { - let agentAId: string; - let agentBId: string; - - beforeEach(() => { - agentAId = db.saveAgent({ - companyId, name: "Alice", role: "Engineer", title: "Senior Engineer", - department: "engineering", connectorId: "claude_local", status: "active", - capabilities: "Backend development, API design, code review", - budgetMonthlyUsd: 100, - }); - agentBId = db.saveAgent({ - companyId, name: "Bob", role: "Designer", title: "UX Designer", - department: "design", connectorId: "claude_local", status: "active", - capabilities: "UI design, wireframing, user research", - budgetMonthlyUsd: 100, - }); - }); - - it("sends and retrieves direct messages", () => { - const msgId = db.sendAgentMessage({ - companyId, - fromAgentId: agentAId, - toAgentId: agentBId, - channel: "direct", - subject: "Need design review", - body: "Can you review the login page mockup?", - priority: "normal", - }); - - expect(msgId).toBeTruthy(); - - const messages = db.listAgentMessages(companyId); - expect(messages).toHaveLength(1); - expect(messages[0].subject).toBe("Need design review"); - expect(messages[0].fromAgentId).toBe(agentAId); - expect(messages[0].toAgentId).toBe(agentBId); - expect(messages[0].readAt).toBeNull(); - }); - - it("marks messages as read", () => { - const msgId = db.sendAgentMessage({ - companyId, - fromAgentId: agentAId, - toAgentId: agentBId, - subject: "Test", - body: "Hello", - }); - - db.markMessageReadForReader(msgId, companyId); - const messages = db.listAgentMessages(companyId); - const updatedMessage = messages.find((message) => message.id === msgId); - expect(updatedMessage?.readAt).toBeTruthy(); - }); - - it("tracks read state per agent without marking the sender unread", () => { - const msgId = db.sendAgentMessage({ - companyId, - fromAgentId: agentAId, - toAgentId: agentBId, - subject: "Test", - body: "Hello", - }); - - const senderView = db.listAgentMessages(companyId, { agentId: agentAId }); - expect(senderView).toHaveLength(1); - expect(senderView[0].id).toBe(msgId); - expect(senderView[0].readAt).toBeTruthy(); - - const recipientViewBefore = db.listAgentMessages(companyId, { agentId: agentBId, unreadOnly: true }); - expect(recipientViewBefore).toHaveLength(1); - expect(recipientViewBefore[0].id).toBe(msgId); - - db.markMessageReadForReader(msgId, companyId, agentBId); - - const recipientViewAfter = db.listAgentMessages(companyId, { agentId: agentBId, unreadOnly: true }); - expect(recipientViewAfter).toHaveLength(0); - - const thirdAgentId = createAgent({ name: "Agent C" }); - const thirdView = db.listAgentMessages(companyId, { agentId: thirdAgentId }); - expect(thirdView).toHaveLength(0); - }); - - it("limits department and project messages to relevant members", () => { - const engineeringLeadId = createAgent({ name: "Engineering Lead", role: "Lead", department: "engineering" }); - const productLeadId = createAgent({ name: "Product Lead", role: "Lead", department: "product" }); - - const projectId = db.saveProject({ - companyId, - name: "Cross-team Project", - description: "Scope", - leadAgentId: engineeringLeadId, - status: "active", - }); - db.saveTask({ - companyId, - title: "Project task", - description: "Do work", - assigneeAgentId: engineeringLeadId, - projectId, - status: "todo", - priority: "medium", - }); - - const deptMessageId = db.sendAgentMessage({ - companyId, - fromAgentId: engineeringLeadId, - channel: "department", - channelTargetId: "engineering", - subject: "Engineering update", - body: "Only engineering should see this", - }); - const projectMessageId = db.sendAgentMessage({ - companyId, - fromAgentId: engineeringLeadId, - channel: "project", - channelTargetId: projectId, - subject: "Project update", - body: "Only project members should see this", - }); - - const engineeringView = db.listAgentMessages(companyId, { agentId: engineeringLeadId }); - expect(engineeringView.some((message) => message.id === deptMessageId)).toBe(true); - expect(engineeringView.some((message) => message.id === projectMessageId)).toBe(true); - - const productView = db.listAgentMessages(companyId, { agentId: productLeadId }); - expect(productView.some((message) => message.id === deptMessageId)).toBe(false); - expect(productView.some((message) => message.id === projectMessageId)).toBe(false); - }); - - it("filters messages by channel", () => { - db.sendAgentMessage({ companyId, fromAgentId: agentAId, subject: "Dept update", body: "All good", channel: "department", channelTargetId: "engineering" }); - db.sendAgentMessage({ companyId, fromAgentId: agentAId, toAgentId: agentBId, subject: "DM", body: "Hey", channel: "direct" }); - - const deptMsgs = db.listAgentMessages(companyId, { channel: "department" }); - expect(deptMsgs).toHaveLength(1); - expect(deptMsgs[0].subject).toBe("Dept update"); - - const directMsgs = db.listAgentMessages(companyId, { channel: "direct" }); - expect(directMsgs).toHaveLength(1); - expect(directMsgs[0].subject).toBe("DM"); - }); - - it("appears in snapshot", () => { - db.sendAgentMessage({ companyId, fromAgentId: agentAId, channel: "company", subject: "Snapshot test", body: "Hi" }); - const snapshot = db.listSnapshot(); - expect(snapshot.agentMessages.length).toBeGreaterThanOrEqual(1); - expect(snapshot.agentMessages[0].subject).toBe("Snapshot test"); - }); -}); - -// ── Auto Task Assignment ─────────────────────────────────────────────── - -describe("Auto Task Assignment", () => { - let backendId: string; - let frontendId: string; - - beforeEach(() => { - backendId = db.saveAgent({ - companyId, name: "Backend Dev", role: "Backend Engineer", title: "Senior Backend", - department: "engineering", connectorId: "codex_local", status: "active", - capabilities: "API development, database design, Node.js, PostgreSQL, backend testing", - budgetMonthlyUsd: 200, - }); - frontendId = db.saveAgent({ - companyId, name: "Frontend Dev", role: "Frontend Engineer", title: "Senior Frontend", - department: "engineering", connectorId: "codex_local", status: "active", - capabilities: "React, TypeScript, CSS, Tailwind, responsive design, frontend testing, UI components", - budgetMonthlyUsd: 200, - }); - }); - - it("assigns to the best-matching agent by capability keywords", () => { - const taskId = db.saveTask({ - companyId, title: "Build React component for user dashboard", - description: "Create a responsive React component with Tailwind CSS styling", - priority: "medium", status: "todo", - }); - - const assignedId = db.autoAssignTask(taskId, companyId); - expect(assignedId).toBe(frontendId); // "React", "responsive", "Tailwind" match frontend - }); - - it("assigns backend task to backend agent", () => { - const taskId = db.saveTask({ - companyId, title: "Design database schema for orders", - description: "PostgreSQL database design with API endpoints for order management", - priority: "high", status: "todo", - }); - - const assignedId = db.autoAssignTask(taskId, companyId); - expect(assignedId).toBe(backendId); // "database", "PostgreSQL", "API" match backend - }); - - it("returns existing assignee if task already assigned", () => { - const taskId = db.saveTask({ - companyId, title: "Already assigned task", - description: "This task has an assignee", - assigneeAgentId: frontendId, - priority: "medium", status: "todo", - }); - - const result = db.autoAssignTask(taskId, companyId); - expect(result).toBe(frontendId); - }); - - it("returns null if all agents have exhausted their budget", () => { - // Set positive budgets that are fully spent - db.saveAgent({ id: backendId, companyId, name: "Backend Dev", role: "Backend", title: "Backend", connectorId: "codex_local", status: "active", capabilities: "backend", budgetMonthlyUsd: 100 }); - db.saveAgent({ id: frontendId, companyId, name: "Frontend Dev", role: "Frontend", title: "Frontend", connectorId: "codex_local", status: "active", capabilities: "frontend", budgetMonthlyUsd: 100 }); - // Exhaust budgets by recording spending via direct SQL (since finishRun would need a real run) - (db as any).db.prepare("update agents set spent_monthly_usd = 100 where id = ?").run(backendId); - (db as any).db.prepare("update agents set spent_monthly_usd = 100 where id = ?").run(frontendId); - - const taskId = db.saveTask({ companyId, title: "No budget task", description: "Test", priority: "low", status: "todo" }); - const result = db.autoAssignTask(taskId, companyId); - expect(result).toBeNull(); - }); - - it("penalizes agents with many active tasks", () => { - // Give backend agent 5 active tasks - for (let i = 0; i < 5; i++) { - db.saveTask({ companyId, title: `Backend task ${i}`, description: "", assigneeAgentId: backendId, priority: "medium", status: "in_progress" }); - } - - // Generic task with no strong keyword match — should go to less busy frontend agent - const taskId = db.saveTask({ companyId, title: "Implement feature", description: "Build the feature", priority: "medium", status: "todo" }); - const assignedId = db.autoAssignTask(taskId, companyId); - expect(assignedId).toBe(frontendId); // Frontend has 0 active tasks vs backend's 5 - }); -}); - -// ── Workflow Pipelines ───────────────────────────────────────────────── - -describe("Workflow Pipelines", () => { - it("creates, activates, and deletes a workflow", () => { - const wfId = db.saveWorkflow({ - companyId, - name: "Code Review Pipeline", - description: "Auto-review flow for PRs", - triggerType: "task_status_changed", - triggerConfigJson: JSON.stringify({ toStatus: "in_review" }), - stepsJson: JSON.stringify([ - { id: "s1", name: "Run tests", action: "run_task", status: "pending" }, - { id: "s2", name: "Notify reviewer", action: "notify_agent", status: "pending" }, - ]), - status: "draft", - }); - - expect(wfId).toBeTruthy(); - const snapshot = db.listSnapshot(); - const wf = snapshot.workflows.find(w => w.id === wfId); - expect(wf!.name).toBe("Code Review Pipeline"); - expect(wf!.status).toBe("draft"); - expect(wf!.runCount).toBe(0); - - // Delete - db.deleteWorkflow(wfId, companyId); - expect(db.listSnapshot().workflows.find(w => w.id === wfId)).toBeUndefined(); - }); -}); diff --git a/tests/integration/autonomous-chain/approval-decisions.spec.ts b/tests/integration/autonomous-chain/approval-decisions.spec.ts new file mode 100644 index 00000000..a631269b --- /dev/null +++ b/tests/integration/autonomous-chain/approval-decisions.spec.ts @@ -0,0 +1,76 @@ +/** + * Step 4: Hire approval + decideApproval side effects. + * Physically extracted from autonomous-chain.spec.ts without behavior changes. + */ + +import { describe, expect, it } from "vitest"; +import { installSharedSetup } from "./shared-setup"; + +const shared = installSharedSetup(); +const { createAgent } = shared; + +describe("Step 4: Hire approval + decideApproval", () => { + it("decideApproval sets hired agent to idle when approved", () => { + const ceoId = createAgent({ name: "CEO", role: "CEO" }); + const hireId = createAgent({ name: "CTO", role: "CTO", reportsTo: ceoId, status: "pending_approval" as "idle" }); + + const approvalId = shared.db.requestApproval({ + companyId: shared.companyId, + requestedByAgentId: ceoId, + type: "hire_agent", + payloadSummary: "Hire CTO", + impactSummary: "Test impact", + payloadJson: JSON.stringify({ agentId: hireId }), + }); + + shared.db.decideApproval({ + approvalId, + state: "approved", + decisionNote: "Approved by board", + }); + + const updatedAgent = shared.db.getAgent(hireId); + expect(updatedAgent.status).toBe("idle"); + }); + + it("decideApproval sets hired agent to terminated when rejected", () => { + const ceoId = createAgent({ name: "CEO", role: "CEO" }); + const hireId = createAgent({ name: "CTO", role: "CTO", reportsTo: ceoId, status: "pending_approval" as "idle" }); + + const approvalId = shared.db.requestApproval({ + companyId: shared.companyId, + requestedByAgentId: ceoId, + type: "hire_agent", + payloadSummary: "Hire CTO", + impactSummary: "Test impact", + payloadJson: JSON.stringify({ agentId: hireId }), + }); + + shared.db.decideApproval({ + approvalId, + state: "rejected", + decisionNote: "Not needed", + }); + + const updatedAgent = shared.db.getAgent(hireId); + expect(updatedAgent.status).toBe("terminated"); + }); + + it("getApprovalRecord includes payloadJson", () => { + const ceoId = createAgent({ name: "CEO", role: "CEO" }); + const hireId = createAgent({ name: "CTO", role: "CTO", reportsTo: ceoId, status: "pending_approval" as "idle" }); + const approvalId = shared.db.requestApproval({ + companyId: shared.companyId, + requestedByAgentId: ceoId, + type: "hire_agent", + payloadSummary: "Hire CTO", + impactSummary: "Test", + payloadJson: JSON.stringify({ agentId: hireId, config: { connector: "codex_local" } }), + }); + + const approval = shared.db.getApprovalRecord(approvalId); + expect(approval.payloadJson).toBeTruthy(); + const payload = JSON.parse(approval.payloadJson!); + expect(payload.agentId).toBe(hireId); + }); +}); diff --git a/tests/integration/autonomous-chain/automation-messages.spec.ts b/tests/integration/autonomous-chain/automation-messages.spec.ts new file mode 100644 index 00000000..249509ec --- /dev/null +++ b/tests/integration/autonomous-chain/automation-messages.spec.ts @@ -0,0 +1,343 @@ +/** + * Automation rules, agent messages, auto-assignment, and workflow CRUD. + * Physically extracted from autonomous-chain.spec.ts without behavior changes. + */ + +import { beforeEach, describe, expect, it } from "vitest"; +import { installSharedSetup } from "./shared-setup"; + +const shared = installSharedSetup(); +const { createAgent } = shared; + +describe("Automation Rules CRUD", () => { + it("creates, reads, toggles, and deletes an automation rule", () => { + const ruleId = shared.db.saveAutomationRule({ + companyId: shared.companyId, + name: "Auto-escalate blocked tasks", + description: "When a task is blocked, notify the manager", + trigger: "task_blocked", + conditionsJson: JSON.stringify({ department: "engineering" }), + action: "escalate_to_manager", + actionConfigJson: JSON.stringify({ message: "Task blocked" }), + sourceDepartment: "engineering", + targetDepartment: "executive", + priority: 80, + status: "active", + }); + + expect(ruleId).toBeTruthy(); + + // Rule appears in snapshot + const snapshot = shared.db.listSnapshot(); + const rule = snapshot.automationRules.find(r => r.id === ruleId); + expect(rule).toBeTruthy(); + expect(rule!.name).toBe("Auto-escalate blocked tasks"); + expect(rule!.trigger).toBe("task_blocked"); + expect(rule!.action).toBe("escalate_to_manager"); + expect(rule!.status).toBe("active"); + expect(rule!.priority).toBe(80); + expect(rule!.executionCount).toBe(0); + + // Toggle to paused + shared.db.toggleAutomationRule(ruleId, shared.companyId, "paused"); + const updated = shared.db.listSnapshot().automationRules.find(r => r.id === ruleId); + expect(updated!.status).toBe("paused"); + + // Record execution + shared.db.recordAutomationExecution(ruleId, shared.companyId, "Auto-escalate blocked tasks", "task_blocked", "escalate_to_manager", "Escalated task-123"); + const afterExec = shared.db.listSnapshot().automationRules.find(r => r.id === ruleId); + expect(afterExec!.executionCount).toBe(1); + expect(afterExec!.lastExecutedAt).toBeTruthy(); + + // Automation log + const log = shared.db.getAutomationLog(shared.companyId); + expect(log).toHaveLength(1); + expect(log[0].ruleName).toBe("Auto-escalate blocked tasks"); + + // Delete + shared.db.deleteAutomationRule(ruleId, shared.companyId); + const afterDelete = shared.db.listSnapshot().automationRules.find(r => r.id === ruleId); + expect(afterDelete).toBeUndefined(); + }); + + it("updates an existing rule", () => { + const ruleId = shared.db.saveAutomationRule({ + companyId: shared.companyId, + name: "Original name", + trigger: "task_created", + action: "assign_task", + }); + + shared.db.saveAutomationRule({ + id: ruleId, + companyId: shared.companyId, + name: "Updated name", + trigger: "task_created", + action: "notify_agent", + priority: 90, + }); + + const rule = shared.db.listSnapshot().automationRules.find(r => r.id === ruleId); + expect(rule!.name).toBe("Updated name"); + expect(rule!.action).toBe("notify_agent"); + expect(rule!.priority).toBe(90); + }); +}); + +describe("Agent Messages", () => { + let agentAId: string; + let agentBId: string; + + beforeEach(() => { + agentAId = shared.db.saveAgent({ + companyId: shared.companyId, name: "Alice", role: "Engineer", title: "Senior Engineer", + department: "engineering", connectorId: "claude_local", status: "active", + capabilities: "Backend development, API design, code review", + budgetMonthlyUsd: 100, + }); + agentBId = shared.db.saveAgent({ + companyId: shared.companyId, name: "Bob", role: "Designer", title: "UX Designer", + department: "design", connectorId: "claude_local", status: "active", + capabilities: "UI design, wireframing, user research", + budgetMonthlyUsd: 100, + }); + }); + + it("sends and retrieves direct messages", () => { + const msgId = shared.db.sendAgentMessage({ + companyId: shared.companyId, + fromAgentId: agentAId, + toAgentId: agentBId, + channel: "direct", + subject: "Need design review", + body: "Can you review the login page mockup?", + priority: "normal", + }); + + expect(msgId).toBeTruthy(); + + const messages = shared.db.listAgentMessages(shared.companyId); + expect(messages).toHaveLength(1); + expect(messages[0].subject).toBe("Need design review"); + expect(messages[0].fromAgentId).toBe(agentAId); + expect(messages[0].toAgentId).toBe(agentBId); + expect(messages[0].readAt).toBeNull(); + }); + + it("marks messages as read", () => { + const msgId = shared.db.sendAgentMessage({ + companyId: shared.companyId, + fromAgentId: agentAId, + toAgentId: agentBId, + subject: "Test", + body: "Hello", + }); + + shared.db.markMessageReadForReader(msgId, shared.companyId); + const messages = shared.db.listAgentMessages(shared.companyId); + const updatedMessage = messages.find((message) => message.id === msgId); + expect(updatedMessage?.readAt).toBeTruthy(); + }); + + it("tracks read state per agent without marking the sender unread", () => { + const msgId = shared.db.sendAgentMessage({ + companyId: shared.companyId, + fromAgentId: agentAId, + toAgentId: agentBId, + subject: "Test", + body: "Hello", + }); + + const senderView = shared.db.listAgentMessages(shared.companyId, { agentId: agentAId }); + expect(senderView).toHaveLength(1); + expect(senderView[0].id).toBe(msgId); + expect(senderView[0].readAt).toBeTruthy(); + + const recipientViewBefore = shared.db.listAgentMessages(shared.companyId, { agentId: agentBId, unreadOnly: true }); + expect(recipientViewBefore).toHaveLength(1); + expect(recipientViewBefore[0].id).toBe(msgId); + + shared.db.markMessageReadForReader(msgId, shared.companyId, agentBId); + + const recipientViewAfter = shared.db.listAgentMessages(shared.companyId, { agentId: agentBId, unreadOnly: true }); + expect(recipientViewAfter).toHaveLength(0); + + const thirdAgentId = createAgent({ name: "Agent C" }); + const thirdView = shared.db.listAgentMessages(shared.companyId, { agentId: thirdAgentId }); + expect(thirdView).toHaveLength(0); + }); + + it("limits department and project messages to relevant members", () => { + const engineeringLeadId = createAgent({ name: "Engineering Lead", role: "Lead", department: "engineering" }); + const productLeadId = createAgent({ name: "Product Lead", role: "Lead", department: "product" }); + + const projectId = shared.db.saveProject({ + companyId: shared.companyId, + name: "Cross-team Project", + description: "Scope", + leadAgentId: engineeringLeadId, + status: "active", + }); + shared.db.saveTask({ + companyId: shared.companyId, + title: "Project task", + description: "Do work", + assigneeAgentId: engineeringLeadId, + projectId, + status: "todo", + priority: "medium", + }); + + const deptMessageId = shared.db.sendAgentMessage({ + companyId: shared.companyId, + fromAgentId: engineeringLeadId, + channel: "department", + channelTargetId: "engineering", + subject: "Engineering update", + body: "Only engineering should see this", + }); + const projectMessageId = shared.db.sendAgentMessage({ + companyId: shared.companyId, + fromAgentId: engineeringLeadId, + channel: "project", + channelTargetId: projectId, + subject: "Project update", + body: "Only project members should see this", + }); + + const engineeringView = shared.db.listAgentMessages(shared.companyId, { agentId: engineeringLeadId }); + expect(engineeringView.some((message) => message.id === deptMessageId)).toBe(true); + expect(engineeringView.some((message) => message.id === projectMessageId)).toBe(true); + + const productView = shared.db.listAgentMessages(shared.companyId, { agentId: productLeadId }); + expect(productView.some((message) => message.id === deptMessageId)).toBe(false); + expect(productView.some((message) => message.id === projectMessageId)).toBe(false); + }); + + it("filters messages by channel", () => { + shared.db.sendAgentMessage({ companyId: shared.companyId, fromAgentId: agentAId, subject: "Dept update", body: "All good", channel: "department", channelTargetId: "engineering" }); + shared.db.sendAgentMessage({ companyId: shared.companyId, fromAgentId: agentAId, toAgentId: agentBId, subject: "DM", body: "Hey", channel: "direct" }); + + const deptMsgs = shared.db.listAgentMessages(shared.companyId, { channel: "department" }); + expect(deptMsgs).toHaveLength(1); + expect(deptMsgs[0].subject).toBe("Dept update"); + + const directMsgs = shared.db.listAgentMessages(shared.companyId, { channel: "direct" }); + expect(directMsgs).toHaveLength(1); + expect(directMsgs[0].subject).toBe("DM"); + }); + + it("appears in snapshot", () => { + shared.db.sendAgentMessage({ companyId: shared.companyId, fromAgentId: agentAId, channel: "company", subject: "Snapshot test", body: "Hi" }); + const snapshot = shared.db.listSnapshot(); + expect(snapshot.agentMessages.length).toBeGreaterThanOrEqual(1); + expect(snapshot.agentMessages[0].subject).toBe("Snapshot test"); + }); +}); + +describe("Auto Task Assignment", () => { + let backendId: string; + let frontendId: string; + + beforeEach(() => { + backendId = shared.db.saveAgent({ + companyId: shared.companyId, name: "Backend Dev", role: "Backend Engineer", title: "Senior Backend", + department: "engineering", connectorId: "codex_local", status: "active", + capabilities: "API development, database design, Node.js, PostgreSQL, backend testing", + budgetMonthlyUsd: 200, + }); + frontendId = shared.db.saveAgent({ + companyId: shared.companyId, name: "Frontend Dev", role: "Frontend Engineer", title: "Senior Frontend", + department: "engineering", connectorId: "codex_local", status: "active", + capabilities: "React, TypeScript, CSS, Tailwind, responsive design, frontend testing, UI components", + budgetMonthlyUsd: 200, + }); + }); + + it("assigns to the best-matching agent by capability keywords", () => { + const taskId = shared.db.saveTask({ + companyId: shared.companyId, title: "Build React component for user dashboard", + description: "Create a responsive React component with Tailwind CSS styling", + priority: "medium", status: "todo", + }); + + const assignedId = shared.db.autoAssignTask(taskId, shared.companyId); + expect(assignedId).toBe(frontendId); // "React", "responsive", "Tailwind" match frontend + }); + + it("assigns backend task to backend agent", () => { + const taskId = shared.db.saveTask({ + companyId: shared.companyId, title: "Design database schema for orders", + description: "PostgreSQL database design with API endpoints for order management", + priority: "high", status: "todo", + }); + + const assignedId = shared.db.autoAssignTask(taskId, shared.companyId); + expect(assignedId).toBe(backendId); // "database", "PostgreSQL", "API" match backend + }); + + it("returns existing assignee if task already assigned", () => { + const taskId = shared.db.saveTask({ + companyId: shared.companyId, title: "Already assigned task", + description: "This task has an assignee", + assigneeAgentId: frontendId, + priority: "medium", status: "todo", + }); + + const result = shared.db.autoAssignTask(taskId, shared.companyId); + expect(result).toBe(frontendId); + }); + + it("returns null if all agents have exhausted their budget", () => { + // Set positive budgets that are fully spent + shared.db.saveAgent({ id: backendId, companyId: shared.companyId, name: "Backend Dev", role: "Backend", title: "Backend", connectorId: "codex_local", status: "active", capabilities: "backend", budgetMonthlyUsd: 100 }); + shared.db.saveAgent({ id: frontendId, companyId: shared.companyId, name: "Frontend Dev", role: "Frontend", title: "Frontend", connectorId: "codex_local", status: "active", capabilities: "frontend", budgetMonthlyUsd: 100 }); + // Exhaust budgets by recording spending via direct SQL (since finishRun would need a real run) + (shared.db as any).db.prepare("update agents set spent_monthly_usd = 100 where id = ?").run(backendId); + (shared.db as any).db.prepare("update agents set spent_monthly_usd = 100 where id = ?").run(frontendId); + + const taskId = shared.db.saveTask({ companyId: shared.companyId, title: "No budget task", description: "Test", priority: "low", status: "todo" }); + const result = shared.db.autoAssignTask(taskId, shared.companyId); + expect(result).toBeNull(); + }); + + it("penalizes agents with many active tasks", () => { + // Give backend agent 5 active tasks + for (let i = 0; i < 5; i++) { + shared.db.saveTask({ companyId: shared.companyId, title: `Backend task ${i}`, description: "", assigneeAgentId: backendId, priority: "medium", status: "in_progress" }); + } + + // Generic task with no strong keyword match — should go to less busy frontend agent + const taskId = shared.db.saveTask({ companyId: shared.companyId, title: "Implement feature", description: "Build the feature", priority: "medium", status: "todo" }); + const assignedId = shared.db.autoAssignTask(taskId, shared.companyId); + expect(assignedId).toBe(frontendId); // Frontend has 0 active tasks vs backend's 5 + }); +}); + +describe("Workflow Pipelines", () => { + it("creates, activates, and deletes a workflow", () => { + const wfId = shared.db.saveWorkflow({ + companyId: shared.companyId, + name: "Code Review Pipeline", + description: "Auto-review flow for PRs", + triggerType: "task_status_changed", + triggerConfigJson: JSON.stringify({ toStatus: "in_review" }), + stepsJson: JSON.stringify([ + { id: "s1", name: "Run tests", action: "run_task", status: "pending" }, + { id: "s2", name: "Notify reviewer", action: "notify_agent", status: "pending" }, + ]), + status: "draft", + }); + + expect(wfId).toBeTruthy(); + const snapshot = shared.db.listSnapshot(); + const wf = snapshot.workflows.find(w => w.id === wfId); + expect(wf!.name).toBe("Code Review Pipeline"); + expect(wf!.status).toBe("draft"); + expect(wf!.runCount).toBe(0); + + // Delete + shared.db.deleteWorkflow(wfId, shared.companyId); + expect(shared.db.listSnapshot().workflows.find(w => w.id === wfId)).toBeUndefined(); + }); +}); diff --git a/tests/integration/autonomous-chain/ceo-phased-prompts.spec.ts b/tests/integration/autonomous-chain/ceo-phased-prompts.spec.ts new file mode 100644 index 00000000..af927093 --- /dev/null +++ b/tests/integration/autonomous-chain/ceo-phased-prompts.spec.ts @@ -0,0 +1,166 @@ +/** + * Step 2: CEO phased prompts + pending approvals + credential escalation. + * Physically extracted from autonomous-chain.spec.ts without behavior changes. + */ + +import { describe, expect, it } from "vitest"; +import { buildSystemPrompt } from "../../../src/main/prompt-builder"; +import type { ApprovalRecord } from "../../../src/shared/types"; +import { installSharedSetup } from "./shared-setup"; + +const shared = installSharedSetup(); +const { createAgent, buildCtx } = shared; + +describe("Step 2: CEO phased prompts", () => { + it("Phase 0: shows team building instructions when no reports at all", () => { + const ceoId = createAgent({ name: "CEO", role: "CEO" }); + + const ctx = buildCtx({ + agentId: ceoId, + directReports: [], // truly no reports + chainOfCommand: [], + pendingApprovals: [], + }); + + const prompt = buildSystemPrompt(ctx); + expect(prompt).toContain("Team Building Phase"); + expect(prompt).toContain("Design the Organization Structure"); + expect(prompt).toContain("Submit Org Strategy for Approval"); + expect(prompt).not.toContain("Awaiting Org Approval"); + expect(prompt).not.toContain("Goal Decomposition"); + }); + + it("Phase 0.5: shows awaiting hire approvals when pending_approval hires exist but no org approval", () => { + const ceoId = createAgent({ name: "CEO", role: "CEO" }); + + const hireId = createAgent({ name: "CTO", role: "CTO", reportsTo: ceoId, status: "pending_approval" as "idle" }); + const hire = shared.db.getAgent(hireId); + + const ctx = buildCtx({ + agentId: ceoId, + directReports: [hire], + chainOfCommand: [], + pendingApprovals: [], + }); + + const prompt = buildSystemPrompt(ctx); + expect(prompt).toContain("Awaiting Hire Approvals"); + expect(prompt).not.toContain("Team Building Phase"); + expect(prompt).not.toContain("Goal Decomposition"); + }); + + it("Phase 1: shows awaiting org approval when pending approve_ceo_strategy exists", () => { + const ceoId = createAgent({ name: "CEO", role: "CEO" }); + + const ctx = buildCtx({ + agentId: ceoId, + directReports: [], + chainOfCommand: [], + pendingApprovals: [ + { + id: "approval-1", + companyId: shared.companyId, + type: "approve_ceo_strategy", + state: "pending", + payloadSummary: "Org structure: CEO + CTO + CMO", + impactSummary: "Team of 3", + requestedByAgentId: ceoId, + relatedTaskId: null, + decisionNote: "", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + } as ApprovalRecord, + ], + }); + + const prompt = buildSystemPrompt(ctx); + expect(prompt).toContain("Awaiting Org Approval"); + expect(prompt).not.toContain("Team Building Phase"); + expect(prompt).not.toContain("Goal Decomposition"); + }); + + it("Phase 2: shows normal executive instructions when active reports and no pending org approval", () => { + const ceoId = createAgent({ name: "CEO", role: "CEO" }); + const ctoId = createAgent({ name: "CTO", role: "CTO", reportsTo: ceoId, status: "idle" }); + const cto = shared.db.getAgent(ctoId); + + const ctx = buildCtx({ + agentId: ceoId, + directReports: [cto], + chainOfCommand: [], + pendingApprovals: [], + }); + + const prompt = buildSystemPrompt(ctx); + expect(prompt).toContain("Executive / CEO"); + expect(prompt).toContain("Goal Decomposition"); + expect(prompt).toContain("Delegation"); + expect(prompt).toContain("Unblock & Coordinate"); + expect(prompt).not.toContain("Team Building Phase"); + expect(prompt).not.toContain("Awaiting Org Approval"); + }); + + it("includes pending approvals section in prompt", () => { + const ceoId = createAgent({ name: "CEO", role: "CEO" }); + const ctoId = createAgent({ name: "CTO", role: "CTO", reportsTo: ceoId, status: "idle" }); + const cto = shared.db.getAgent(ctoId); + + const ctx = buildCtx({ + agentId: ceoId, + directReports: [cto], + chainOfCommand: [], + pendingApprovals: [ + { + id: "approval-x", + companyId: shared.companyId, + type: "secret_access", + state: "pending", + payloadSummary: "Need Twitter API keys", + impactSummary: "", + requestedByAgentId: ceoId, + relatedTaskId: null, + decisionNote: "", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + } as ApprovalRecord, + ], + }); + + const prompt = buildSystemPrompt(ctx); + expect(prompt).toContain("Pending Approvals"); + expect(prompt).toContain("Need Twitter API keys"); + }); + + it("Manager prompt includes credential escalation guidance", () => { + const ceoId = createAgent({ name: "CEO", role: "CEO" }); + const ceo = shared.db.getAgent(ceoId); + const managerId = createAgent({ name: "CTO", role: "CTO", reportsTo: ceoId }); + const icId = createAgent({ name: "Dev", role: "Engineer", reportsTo: managerId }); + const ic = shared.db.getAgent(icId); + + const ctx = buildCtx({ + agentId: managerId, + directReports: [ic], + chainOfCommand: [ceo], + }); + + const prompt = buildSystemPrompt(ctx); + expect(prompt).toContain("Break down work and delegate it"); + }); + + it("IC prompt includes credential escalation guidance", () => { + const ceoId = createAgent({ name: "CEO", role: "CEO" }); + const ceo = shared.db.getAgent(ceoId); + const icId = createAgent({ name: "Dev", role: "Engineer", reportsTo: ceoId }); + + const ctx = buildCtx({ + agentId: icId, + directReports: [], + chainOfCommand: [ceo], + }); + + const prompt = buildSystemPrompt(ctx); + expect(prompt).toContain("Your manager is auto-notified and will help"); + expect(prompt).toContain("explain what API key/account you need"); + }); +}); diff --git a/tests/integration/autonomous-chain/crash-recovery.spec.ts b/tests/integration/autonomous-chain/crash-recovery.spec.ts new file mode 100644 index 00000000..e9b9a9f5 --- /dev/null +++ b/tests/integration/autonomous-chain/crash-recovery.spec.ts @@ -0,0 +1,93 @@ +/** + * Step 7: Worker crash recovery (database layer). + * Physically extracted from autonomous-chain.spec.ts without behavior changes. + */ + +import { describe, expect, it } from "vitest"; +import { AppDatabase } from "../../../src/main/database"; +import { installSharedSetup } from "./shared-setup"; + +const shared = installSharedSetup(); +const { createAgent } = shared; + +describe("Step 7: Worker crash recovery (database layer)", () => { + it("interruptStaleRuns clears running and queued runs on init", async () => { + const agentId = createAgent({ name: "Agent" }); + const taskId = shared.db.saveTask({ + companyId: shared.companyId, + title: "In-progress task", + description: "Running when crash happened", + assigneeAgentId: agentId, + workspaceId: shared.workspaceId, + priority: "medium", + status: "in_progress", + }); + + const run = shared.db.createRun({ + companyId: shared.companyId, + taskId, + agentId, + workspaceId: shared.workspaceId, + connectorId: "codex_local", + }); + + shared.db.markRunStatus(run.id, "running", "Started"); + + // Verify it's running + let snapshot = shared.db.listSnapshot(); + expect(snapshot.runs.find((r) => r.id === run.id)?.status).toBe("running"); + + // Simulate app restart — close and re-init + shared.db.close(); + + const profileDir = shared.tempDirs[shared.tempDirs.length - 1]!; + shared.db = new AppDatabase(profileDir); + shared.db.init(); // This calls interruptStaleRuns() + + snapshot = shared.db.listSnapshot(); + const recoveredRun = snapshot.runs.find((r) => r.id === run.id); + expect(recoveredRun?.status).toBe("interrupted"); + }); + + it("finishRun can mark interrupted runs from crash recovery", () => { + const agentId = createAgent({ name: "Agent" }); + const taskId = shared.db.saveTask({ + companyId: shared.companyId, + title: "Crash recovery task", + description: "Test", + assigneeAgentId: agentId, + workspaceId: shared.workspaceId, + priority: "medium", + status: "in_progress", + }); + + const run = shared.db.createRun({ + companyId: shared.companyId, + taskId, + agentId, + workspaceId: shared.workspaceId, + connectorId: "codex_local", + }); + + shared.db.markRunStatus(run.id, "running", "Started"); + + // Simulate crash recovery - mark as interrupted + shared.db.finishRun({ + runId: run.id, + status: "interrupted", + summary: "Worker process crashed.", + errorMessage: "Worker process exited unexpectedly.", + exitCode: 1, + signal: null, + model: null, + sessionDisplayId: null, + costUsd: null, + unattributedCost: true, + }); + + const snapshot = shared.db.listSnapshot(); + const recovered = snapshot.runs.find((r) => r.id === run.id); + expect(recovered?.status).toBe("interrupted"); + expect(recovered?.errorMessage).toBe("Worker process exited unexpectedly."); + }); +}); diff --git a/tests/integration/autonomous-chain/e2e-flows.spec.ts b/tests/integration/autonomous-chain/e2e-flows.spec.ts new file mode 100644 index 00000000..bd6be668 --- /dev/null +++ b/tests/integration/autonomous-chain/e2e-flows.spec.ts @@ -0,0 +1,336 @@ +/** + * End-to-end chain flows + adversarial review-fix regression tests. + * Physically extracted from autonomous-chain.spec.ts without behavior changes. + */ + +import { describe, expect, it } from "vitest"; +import { buildSystemPrompt, buildTaskPrompt } from "../../../src/main/prompt-builder"; +import type { ApprovalRecord } from "../../../src/shared/types"; +import { installSharedSetup } from "./shared-setup"; + +const shared = installSharedSetup(); +const { createAgent, buildCtx } = shared; + +describe("E2E: Full autonomous CEO cold start flow", () => { + it("simulates complete chain: CEO creation → team build → org approval → delegation → completion", () => { + // 1. Create CEO agent + const ceoId = createAgent({ name: "Atlas", role: "CEO", status: "idle" }); + + // CEO has no team yet → Phase 0 + const phase0Ctx = buildCtx({ + agentId: ceoId, + directReports: [], + chainOfCommand: [], + pendingApprovals: [], + }); + const phase0Prompt = buildSystemPrompt(phase0Ctx); + expect(phase0Prompt).toContain("Team Building Phase"); + + // 2. CEO submits hire requests + const ctoId = createAgent({ name: "Nova", role: "CTO", reportsTo: ceoId, status: "pending_approval" as "idle" }); + const cmoId = createAgent({ name: "Aria", role: "CMO", reportsTo: ceoId, status: "pending_approval" as "idle" }); + + // 3. CEO submits org strategy approval + const orgApprovalId = shared.db.requestApproval({ + companyId: shared.companyId, + requestedByAgentId: ceoId, + type: "approve_ceo_strategy", + payloadSummary: "Org: CEO + CTO + CMO", + impactSummary: "3-person team", + }); + + // CEO now in Phase 1 + const ctoRecord = shared.db.getAgent(ctoId); + const cmoRecord = shared.db.getAgent(cmoId); + const phase1Ctx = buildCtx({ + agentId: ceoId, + directReports: [ctoRecord, cmoRecord], + chainOfCommand: [], + pendingApprovals: [{ + id: orgApprovalId, + companyId: shared.companyId, + type: "approve_ceo_strategy", + state: "pending", + payloadSummary: "Org: CEO + CTO + CMO", + impactSummary: "3-person team", + requestedByAgentId: ceoId, + relatedTaskId: null, + decisionNote: "", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + } as ApprovalRecord], + }); + const phase1Prompt = buildSystemPrompt(phase1Ctx); + expect(phase1Prompt).toContain("Awaiting Org Approval"); + + // 4. Board approves hires + const hireApprovalCTO = shared.db.requestApproval({ + companyId: shared.companyId, + requestedByAgentId: ceoId, + type: "hire_agent", + payloadSummary: "Hire Nova as CTO", + impactSummary: "Test", + payloadJson: JSON.stringify({ agentId: ctoId }), + }); + shared.db.decideApproval({ approvalId: hireApprovalCTO, state: "approved", decisionNote: "Approved" }); + + const hireApprovalCMO = shared.db.requestApproval({ + companyId: shared.companyId, + requestedByAgentId: ceoId, + type: "hire_agent", + payloadSummary: "Hire Aria as CMO", + impactSummary: "Test", + payloadJson: JSON.stringify({ agentId: cmoId }), + }); + shared.db.decideApproval({ approvalId: hireApprovalCMO, state: "approved", decisionNote: "Approved" }); + + // Agents now idle + expect(shared.db.getAgent(ctoId).status).toBe("idle"); + expect(shared.db.getAgent(cmoId).status).toBe("idle"); + + // 5. Board approves org strategy + shared.db.decideApproval({ approvalId: orgApprovalId, state: "approved", decisionNote: "Go ahead" }); + + // 6. CEO now in Phase 2 + const updatedCto = shared.db.getAgent(ctoId); + const updatedCmo = shared.db.getAgent(cmoId); + const phase2Ctx = buildCtx({ + agentId: ceoId, + directReports: [updatedCto, updatedCmo], + chainOfCommand: [], + pendingApprovals: [], // all approved now + }); + const phase2Prompt = buildSystemPrompt(phase2Ctx); + expect(phase2Prompt).toContain("Executive / CEO"); + expect(phase2Prompt).toContain("Goal Decomposition"); + expect(phase2Prompt).toContain("Delegation"); + + // 7. Verify hierarchical chain + const ctoChain = shared.db.getChainOfCommand(ctoId); + expect(ctoChain).toHaveLength(1); + expect(ctoChain[0].id).toBe(ceoId); + + // 8. Create task tree + const projectId = shared.db.saveProject({ + companyId: shared.companyId, + name: "Launch MVP", + description: "Build and launch MVP", + goalId: null, + leadAgentId: ctoId, + status: "in_progress", + }); + + const parentTaskId = shared.db.saveTask({ + companyId: shared.companyId, + title: "Build backend", + description: "Implement API", + assigneeAgentId: ctoId, + projectId, + workspaceId: shared.workspaceId, + priority: "high", + status: "in_progress", + }); + + // CTO creates subtask for IC (would be done via API) + const icId = createAgent({ name: "Dev", role: "Engineer", reportsTo: ctoId, status: "idle" }); + const subtaskId = shared.db.saveTask({ + companyId: shared.companyId, + title: "Implement auth", + description: "JWT auth", + assigneeAgentId: icId, + parentId: parentTaskId, + projectId, + workspaceId: shared.workspaceId, + priority: "medium", + status: "todo", + }); + + // Verify parent-child relationship + const subtask = shared.db.getTask(subtaskId); + expect(subtask.parentId).toBe(parentTaskId); + + // 9. Wakeup request test: enqueue wake while IC is busy + shared.db.enqueueWakeupRequest(shared.companyId, icId, "comment", "comment"); + const queuedWakes = shared.db.listWakeupRequests(shared.companyId, { agentId: icId, status: "queued" }); + expect(queuedWakes).toHaveLength(1); + expect(queuedWakes[0].source).toBe("comment"); + + // 10. Verify IC prompt has correct wake reason + const icPromptCtx = buildCtx({ + agentId: icId, + task: subtask, + chainOfCommand: [shared.db.getAgent(ctoId), shared.db.getAgent(ceoId)], + wakeReason: "assignment", + }); + const icPrompt = buildTaskPrompt(icPromptCtx); + expect(icPrompt).toContain("Wake Reason"); + expect(icPrompt).toContain("You have been assigned a new task"); + expect(icPrompt).toContain("Implement auth"); + }); +}); + +describe("E2E: Wakeup request lifecycle with run lifecycle", () => { + it("wake enqueue + coalesce + claim + complete cycle", () => { + const agentId = createAgent({ name: "Agent" }); + + // Simulate: agent is running, wakes come in. All triggers are coalesced + // into a single wakeup request with JSON array of trigger details. + shared.db.enqueueWakeupRequest(shared.companyId, agentId, "subtask_completed", "subtask_completed"); + shared.db.enqueueWakeupRequest(shared.companyId, agentId, "comment", "comment"); + shared.db.enqueueWakeupRequest(shared.companyId, agentId, "report_blocked", "report_blocked"); + + // Verify coalesced into single row with all triggers + const wakes = shared.db.listWakeupRequests(shared.companyId, { agentId, status: "queued" }); + expect(wakes).toHaveLength(1); + expect(wakes[0].coalescedCount).toBe(2); + + const triggers = JSON.parse(wakes[0].triggerDetail!); + expect(triggers).toHaveLength(3); + expect(triggers[0].trigger).toBe("subtask_completed"); + expect(triggers[1].trigger).toBe("comment"); + expect(triggers[2].trigger).toBe("report_blocked"); + + // Wakes remain queued until consumed by the run lifecycle + expect(shared.db.listWakeupRequests(shared.companyId, { agentId, status: "queued" })).toHaveLength(1); + }); +}); + +describe("E2E: Blocked escalation through org hierarchy", () => { + it("getChainOfCommand provides escalation path for blocked IC", () => { + const ceoId = createAgent({ name: "CEO", role: "CEO" }); + const ctoId = createAgent({ name: "CTO", role: "CTO", reportsTo: ceoId }); + const icId = createAgent({ name: "Dev", role: "Engineer", reportsTo: ctoId }); + + // When IC marks task blocked, we need to find its manager + const chain = shared.db.getChainOfCommand(icId); + expect(chain[0].id).toBe(ctoId); // immediate manager gets woken + + // When CTO needs to escalate further + const ctoChain = shared.db.getChainOfCommand(ctoId); + expect(ctoChain[0].id).toBe(ceoId); + }); +}); + +describe("Review Fix: CEO phase ordering edge case", () => { + it("shows awaiting hire approvals when org strategy approved first but hires still pending", () => { + const ceoId = createAgent({ name: "CEO", role: "CEO" }); + // Hires submitted but not yet approved + const hireId = createAgent({ name: "CTO", role: "CTO", reportsTo: ceoId, status: "pending_approval" as "idle" }); + const hire = shared.db.getAgent(hireId); + + // Org strategy already approved (not in pendingApprovals), but hires still pending + const ctx = buildCtx({ + agentId: ceoId, + directReports: [hire], // only pending_approval + chainOfCommand: [], + pendingApprovals: [], // org strategy already resolved + }); + + const prompt = buildSystemPrompt(ctx); + // Should NOT get Phase 0 (team building) — hires are already submitted + expect(prompt).not.toContain("Team Building Phase"); + // Should get the awaiting hire approvals phase + expect(prompt).toContain("Awaiting Hire Approvals"); + }); + + it("moves to Phase 2 once hires are approved (no pending org approval)", () => { + const ceoId = createAgent({ name: "CEO", role: "CEO" }); + const ctoId = createAgent({ name: "CTO", role: "CTO", reportsTo: ceoId, status: "idle" }); + const cto = shared.db.getAgent(ctoId); + + const ctx = buildCtx({ + agentId: ceoId, + directReports: [cto], // active report + chainOfCommand: [], + pendingApprovals: [], + }); + + const prompt = buildSystemPrompt(ctx); + expect(prompt).toContain("Executive / CEO"); + expect(prompt).not.toContain("Awaiting Hire Approvals"); + expect(prompt).not.toContain("Team Building Phase"); + }); +}); + +describe("Review Fix: Org gate consistency (revision_requested)", () => { + it("org-strategy revision_requested blocks the CEO in Phase 1", () => { + const ceoId = createAgent({ name: "CEO", role: "CEO" }); + + const ctx = buildCtx({ + agentId: ceoId, + directReports: [], + chainOfCommand: [], + pendingApprovals: [ + { + id: "approval-rev", + companyId: shared.companyId, + type: "approve_ceo_strategy", + state: "revision_requested", + payloadSummary: "Org plan needs changes", + impactSummary: "", + requestedByAgentId: ceoId, + relatedTaskId: null, + decisionNote: "Please add a CPO", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + } as ApprovalRecord, + ], + }); + + const prompt = buildSystemPrompt(ctx); + expect(prompt).toContain("Awaiting Org Approval"); + expect(prompt).toContain("revision_requested"); + }); +}); + +describe("Review Fix: Rejected approval wakes agent", () => { + it("decideApproval with rejected state still triggers wake (tested via DB)", () => { + const ceoId = createAgent({ name: "CEO", role: "CEO" }); + + const approvalId = shared.db.requestApproval({ + companyId: shared.companyId, + requestedByAgentId: ceoId, + type: "approve_ceo_strategy", + payloadSummary: "Org plan", + impactSummary: "test", + }); + + shared.db.decideApproval({ + approvalId, + state: "rejected", + decisionNote: "Not ready", + }); + + // Verify approval state + const approval = shared.db.getApprovalRecord(approvalId); + expect(approval.state).toBe("rejected"); + // The IPC handler in index.ts now wakes on rejected too — + // we verify the DB layer works; the wakeAgentIfPossible call is in index.ts + }); +}); + +describe("Review Fix: Root task completion notification", () => { + it("root task (no parentId) should still have org hierarchy for manager lookup", () => { + const ceoId = createAgent({ name: "CEO", role: "CEO" }); + const ctoId = createAgent({ name: "CTO", role: "CTO", reportsTo: ceoId }); + + // CTO has a root-level task (no parentId) + const taskId = shared.db.saveTask({ + companyId: shared.companyId, + title: "Standalone task from CEO", + description: "No parent", + assigneeAgentId: ctoId, + workspaceId: shared.workspaceId, + priority: "high", + status: "done", + }); + + const task = shared.db.getTask(taskId); + expect(task.parentId).toBeNull(); + + // CTO's chain of command includes CEO — so api-server can wake CEO on completion + const chain = shared.db.getChainOfCommand(ctoId); + expect(chain).toHaveLength(1); + expect(chain[0].id).toBe(ceoId); + }); +}); diff --git a/tests/integration/autonomous-chain/role-detection.spec.ts b/tests/integration/autonomous-chain/role-detection.spec.ts new file mode 100644 index 00000000..3acfb4b1 --- /dev/null +++ b/tests/integration/autonomous-chain/role-detection.spec.ts @@ -0,0 +1,65 @@ +/** + * Step 1: CEO role detection fix. + * Physically extracted from autonomous-chain.spec.ts without behavior changes. + */ + +import { describe, expect, it } from "vitest"; +import { buildSystemPrompt } from "../../../src/main/prompt-builder"; +import { installSharedSetup } from "./shared-setup"; + +const shared = installSharedSetup(); +const { createAgent, buildCtx } = shared; + +describe("Step 1: CEO role detection", () => { + it("identifies CEO when chainOfCommand is empty, even with no direct reports", () => { + const ceoId = createAgent({ name: "CEO", role: "CEO" }); + const ctx = buildCtx({ + agentId: ceoId, + directReports: [], // no reports yet! + chainOfCommand: [], // top-level + }); + + const prompt = buildSystemPrompt(ctx); + + // CEO should NOT get IC instructions + expect(prompt).not.toContain("Individual Contributor"); + // CEO should get executive instructions (Phase 0: Team Building) + expect(prompt).toContain("CEO"); + expect(prompt).toContain("Team Building"); + }); + + it("identifies manager when has reports and has chain of command", () => { + const ceoId = createAgent({ name: "CEO", role: "CEO" }); + const managerId = createAgent({ name: "CTO", role: "CTO", reportsTo: ceoId }); + const ceo = shared.db.getAgent(ceoId); + const icId = createAgent({ name: "IC", role: "Engineer", reportsTo: managerId }); + const ic = shared.db.getAgent(icId); + + const ctx = buildCtx({ + agentId: managerId, + directReports: [ic], + chainOfCommand: [ceo], // reports to CEO + }); + + const prompt = buildSystemPrompt(ctx); + expect(prompt).toContain("Operating Instructions (Manager)"); + expect(prompt).not.toContain("Individual Contributor"); + expect(prompt).not.toContain("Executive / CEO"); + }); + + it("identifies IC when has chain of command and no direct reports", () => { + const ceoId = createAgent({ name: "CEO", role: "CEO" }); + const ceo = shared.db.getAgent(ceoId); + const icId = createAgent({ name: "Dev", role: "Engineer", reportsTo: ceoId }); + + const ctx = buildCtx({ + agentId: icId, + directReports: [], + chainOfCommand: [ceo], + }); + + const prompt = buildSystemPrompt(ctx); + expect(prompt).toContain("Individual Contributor"); + expect(prompt).not.toContain("Operating Instructions (Manager)"); + }); +}); diff --git a/tests/integration/autonomous-chain/run-failure.spec.ts b/tests/integration/autonomous-chain/run-failure.spec.ts new file mode 100644 index 00000000..f3ce65d3 --- /dev/null +++ b/tests/integration/autonomous-chain/run-failure.spec.ts @@ -0,0 +1,66 @@ +/** + * Step 6: Run failure escalation (database layer). + * Physically extracted from autonomous-chain.spec.ts without behavior changes. + */ + +import { describe, expect, it } from "vitest"; +import { installSharedSetup } from "./shared-setup"; + +const shared = installSharedSetup(); +const { createAgent } = shared; + +describe("Step 6: Run failure escalation (database layer)", () => { + it("finishRun marks run as failed and returns outcome", () => { + const agentId = createAgent({ name: "IC", role: "Engineer" }); + const taskId = shared.db.saveTask({ + companyId: shared.companyId, + title: "Failing task", + description: "Will fail", + assigneeAgentId: agentId, + workspaceId: shared.workspaceId, + priority: "medium", + status: "in_progress", + }); + + const run = shared.db.createRun({ + companyId: shared.companyId, + taskId, + agentId, + workspaceId: shared.workspaceId, + connectorId: "codex_local", + }); + + shared.db.markRunStatus(run.id, "running", "Started"); + + const outcome = shared.db.finishRun({ + runId: run.id, + status: "failed", + summary: null, + errorMessage: "Process crashed", + exitCode: 1, + signal: null, + model: "gpt-5-codex", + sessionDisplayId: null, + costUsd: 0.5, + unattributedCost: false, + }); + + expect(outcome.budgetHardStopped).toBe(false); + + const snapshot = shared.db.listSnapshot(); + const finishedRun = snapshot.runs.find((r) => r.id === run.id); + expect(finishedRun?.status).toBe("failed"); + expect(finishedRun?.errorMessage).toBe("Process crashed"); + }); + + it("getChainOfCommand returns manager for failure escalation", () => { + const ceoId = createAgent({ name: "CEO", role: "CEO" }); + const ctoId = createAgent({ name: "CTO", role: "CTO", reportsTo: ceoId }); + const icId = createAgent({ name: "Dev", role: "Engineer", reportsTo: ctoId }); + + const chain = shared.db.getChainOfCommand(icId); + // First in chain is the immediate manager (CTO) + expect(chain.length).toBeGreaterThan(0); + expect(chain[0].name).toBe("CTO"); + }); +}); diff --git a/tests/integration/autonomous-chain/shared-setup.ts b/tests/integration/autonomous-chain/shared-setup.ts new file mode 100644 index 00000000..967427e0 --- /dev/null +++ b/tests/integration/autonomous-chain/shared-setup.ts @@ -0,0 +1,135 @@ +/** + * Shared setup for the autonomous-chain integration test suite. + * + * The original 50KB autonomous-chain.spec.ts shared a module-level `db`, + * `companyId`, and `workspaceId` across every describe block via top-level + * `beforeEach`/`afterEach` hooks. This module exports `installSharedSetup`, + * which each split spec file calls to wire equivalent hooks into its own + * vitest context and hand back a live handle. + * + * The helpers preserve the exact behavior of the original file — tests are + * physically moved, not rewritten. + */ + +import { mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { afterEach, beforeEach } from "vitest"; +import { AppDatabase } from "../../../src/main/database"; +import { buildSystemPrompt } from "../../../src/main/prompt-builder"; +import type { AgentRecord, ApprovalRecord, TaskRecord } from "../../../src/shared/types"; + +export interface SharedContext { + db: AppDatabase; + companyId: string; + workspaceId: string; +} + +export interface SharedHandle { + get db(): AppDatabase; + set db(value: AppDatabase); + get companyId(): string; + get workspaceId(): string; + createAgent(overrides?: Partial[0]>): string; + buildCtx(overrides?: Record): Parameters[0]; + tempDirs: string[]; +} + +export function installSharedSetup(): SharedHandle { + const state = { + db: null as AppDatabase | null, + companyId: "", + workspaceId: "", + }; + const tempDirs: string[] = []; + + async function createTempDir(prefix: string) { + const dir = await mkdtemp(join(tmpdir(), prefix)); + tempDirs.push(dir); + return dir; + } + + beforeEach(async () => { + const profileDir = await createTempDir("agentcompany-chain-test-"); + state.db = new AppDatabase(profileDir); + state.db.init(); + + state.companyId = state.db.saveCompany({ + name: "Autonomous Test Corp", + description: "Test company for autonomous chain", + status: "active", + }); + + state.workspaceId = state.db.saveWorkspace({ + companyId: state.companyId, + name: "Primary", + localPath: profileDir, + repoUrl: "https://example.com/repo.git", + repoRef: "main", + isPrimary: true, + }); + }); + + afterEach(async () => { + state.db?.close(); + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); + }); + + function currentDb(): AppDatabase { + if (!state.db) throw new Error("shared-setup: db accessed outside test hook"); + return state.db; + } + + function createAgent(overrides: Partial[0]> = {}): string { + return currentDb().saveAgent({ + companyId: state.companyId, + name: "TestAgent", + role: "CEO", + title: "Test", + status: "idle", + reportsTo: null, + connectorId: "codex_local", + workspaceId: state.workspaceId, + model: null, + capabilities: "Test capabilities", + budgetMonthlyUsd: 200, + ...overrides, + }); + } + + function buildCtx(overrides: Record = {}): Parameters[0] { + const agent = currentDb().getAgent(overrides.agentId as string ?? createAgent()); + return { + agent, + apiUrl: "http://127.0.0.1:3000", + apiKey: "test-key", + runId: "test-run-id", + task: null, + companyName: "Autonomous Test Corp", + goals: [], + projects: [], + directReports: overrides.directReports as AgentRecord[] ?? [], + chainOfCommand: overrides.chainOfCommand as AgentRecord[] ?? [], + assignedTasks: [], + allAgents: [], + pendingApprovals: overrides.pendingApprovals as ApprovalRecord[] ?? [], + tasksAwaitingReview: overrides.tasksAwaitingReview as TaskRecord[] ?? [], + wakeReason: overrides.wakeReason as string | null ?? null, + allTasks: [], + recentComments: [], + companyDescription: "", + socialAccounts: [], + ...overrides, + } as Parameters[0]; + } + + return { + get db() { return currentDb(); }, + set db(value: AppDatabase) { state.db = value; }, + get companyId() { return state.companyId; }, + get workspaceId() { return state.workspaceId; }, + createAgent, + buildCtx, + tempDirs, + }; +} diff --git a/tests/integration/autonomous-chain/task-propagation.spec.ts b/tests/integration/autonomous-chain/task-propagation.spec.ts new file mode 100644 index 00000000..333cee8b --- /dev/null +++ b/tests/integration/autonomous-chain/task-propagation.spec.ts @@ -0,0 +1,63 @@ +/** + * Step 5: Task status + comment wake propagation. + * Physically extracted from autonomous-chain.spec.ts without behavior changes. + */ + +import { describe, expect, it } from "vitest"; +import { installSharedSetup } from "./shared-setup"; + +const shared = installSharedSetup(); +const { createAgent } = shared; + +describe("Step 5: Task status + comment wake propagation", () => { + it("getChainOfCommand returns correct hierarchy", () => { + const ceoId = createAgent({ name: "CEO", role: "CEO", reportsTo: null }); + const ctoId = createAgent({ name: "CTO", role: "CTO", reportsTo: ceoId }); + const icId = createAgent({ name: "Dev", role: "Engineer", reportsTo: ctoId }); + + const chain = shared.db.getChainOfCommand(icId); + expect(chain).toHaveLength(2); + expect(chain[0].id).toBe(ctoId); // immediate manager + expect(chain[1].id).toBe(ceoId); // top level + }); + + it("getChainOfCommand handles circular references safely", () => { + // Create a potential cycle (shouldn't happen in practice, but guard against it) + const agent1 = createAgent({ name: "Agent1" }); + // agent1 reports to no one (CEO), so chain should be empty + const chain = shared.db.getChainOfCommand(agent1); + expect(chain).toHaveLength(0); + }); + + it("task tree supports parent-child relationships", () => { + const ceoId = createAgent({ name: "CEO", role: "CEO" }); + const ctoId = createAgent({ name: "CTO", role: "CTO", reportsTo: ceoId }); + + const parentTaskId = shared.db.saveTask({ + companyId: shared.companyId, + title: "Build feature X", + description: "Top-level task", + assigneeAgentId: ctoId, + workspaceId: shared.workspaceId, + priority: "high", + status: "in_progress", + }); + + const childTaskId = shared.db.saveTask({ + companyId: shared.companyId, + title: "Implement module A", + description: "Subtask", + assigneeAgentId: ctoId, + parentId: parentTaskId, + workspaceId: shared.workspaceId, + priority: "medium", + status: "todo", + }); + + const childTask = shared.db.getTask(childTaskId); + expect(childTask.parentId).toBe(parentTaskId); + + const parentTask = shared.db.getTask(parentTaskId); + expect(parentTask.assigneeAgentId).toBe(ctoId); + }); +}); diff --git a/tests/integration/autonomous-chain/wake-reason.spec.ts b/tests/integration/autonomous-chain/wake-reason.spec.ts new file mode 100644 index 00000000..6167b785 --- /dev/null +++ b/tests/integration/autonomous-chain/wake-reason.spec.ts @@ -0,0 +1,119 @@ +/** + * Step 8: Wake reason injection into task prompt. + * Physically extracted from autonomous-chain.spec.ts without behavior changes. + */ + +import { describe, expect, it } from "vitest"; +import { buildTaskPrompt } from "../../../src/main/prompt-builder"; +import { installSharedSetup } from "./shared-setup"; + +const shared = installSharedSetup(); +const { createAgent, buildCtx } = shared; + +describe("Step 8: Wake reason injection", () => { + it("includes wake reason section for non-timer triggers", () => { + const agentId = createAgent({ name: "CTO", role: "CTO" }); + const ceoId = createAgent({ name: "CEO", role: "CEO" }); + const ceo = shared.db.getAgent(ceoId); + + const ctx = buildCtx({ + agentId, + chainOfCommand: [ceo], + wakeReason: "subtask_completed", + }); + + const prompt = buildTaskPrompt(ctx); + expect(prompt).toContain("Wake Reason"); + expect(prompt).toContain("subtask you delegated has been completed"); + }); + + it("includes wake reason for timer triggers", () => { + const agentId = createAgent({ name: "Agent" }); + + const ctx = buildCtx({ + agentId, + wakeReason: "timer", + }); + + const prompt = buildTaskPrompt(ctx); + expect(prompt).toContain("Wake Reason"); + expect(prompt).toContain("Regular heartbeat check-in"); + }); + + it("includes wake reason for manual triggers", () => { + const agentId = createAgent({ name: "Agent" }); + + const ctx = buildCtx({ + agentId, + wakeReason: "manual", + }); + + const prompt = buildTaskPrompt(ctx); + expect(prompt).toContain("Wake Reason"); + expect(prompt).toContain("manually triggered by the human operator"); + }); + + it("includes correct message for each wake reason", () => { + const agentId = createAgent({ name: "Agent" }); + + const reasons: Record = { + assignment: "You have been assigned a new task", + goal_activated: "goal you own has been activated", + approval_resolved: "approval you requested has been decided", + subtask_completed: "subtask you delegated has been completed", + comment: "new comment has been posted", + report_blocked: "reports is blocked", + report_failed: "reports has failed", + }; + + for (const [reason, expectedText] of Object.entries(reasons)) { + const ctx = buildCtx({ + agentId, + wakeReason: reason, + }); + const prompt = buildTaskPrompt(ctx); + expect(prompt).toContain("Wake Reason"); + expect(prompt).toContain(expectedText); + } + }); + + it("handles unknown wake reason gracefully", () => { + const agentId = createAgent({ name: "Agent" }); + + const ctx = buildCtx({ + agentId, + wakeReason: "custom_trigger_xyz", + }); + + const prompt = buildTaskPrompt(ctx); + expect(prompt).toContain("Wake Reason"); + expect(prompt).toContain("Trigger: custom_trigger_xyz"); + }); + + it("includes wake reason before task description", () => { + const agentId = createAgent({ name: "Agent" }); + const taskId = shared.db.saveTask({ + companyId: shared.companyId, + title: "Do the thing", + description: "Test task", + assigneeAgentId: agentId, + workspaceId: shared.workspaceId, + priority: "medium", + status: "todo", + }); + const task = shared.db.getTask(taskId); + + const ctx = buildCtx({ + agentId, + task, + wakeReason: "assignment", + }); + + const prompt = buildTaskPrompt(ctx); + const wakeIndex = prompt.indexOf("Wake Reason"); + const taskIndex = prompt.indexOf("Your immediate task"); + expect(wakeIndex).toBeGreaterThan(-1); + expect(taskIndex).toBeGreaterThan(-1); + expect(wakeIndex).toBeLessThan(taskIndex); + }); +}); diff --git a/tests/integration/autonomous-chain/wakeup-queue.spec.ts b/tests/integration/autonomous-chain/wakeup-queue.spec.ts new file mode 100644 index 00000000..874b1330 --- /dev/null +++ b/tests/integration/autonomous-chain/wakeup-queue.spec.ts @@ -0,0 +1,55 @@ +/** + * Step 3: Deferred wake queue lifecycle. + * Physically extracted from autonomous-chain.spec.ts without behavior changes. + */ + +import { describe, expect, it } from "vitest"; +import { installSharedSetup } from "./shared-setup"; + +const shared = installSharedSetup(); +const { createAgent } = shared; + +describe("Step 3: Wakeup request lifecycle (database)", () => { + it("enqueue and list wakeup requests with coalescing", () => { + const agentId = createAgent({ name: "TestAgent" }); + + // Enqueue two wakes for the same busy agent. Coalesced into one row + // with trigger_detail JSON array containing both triggers. + shared.db.enqueueWakeupRequest(shared.companyId, agentId, "subtask_completed", "subtask_completed"); + shared.db.enqueueWakeupRequest(shared.companyId, agentId, "comment", "comment"); + + // List returns the coalesced wake + const wakes = shared.db.listWakeupRequests(shared.companyId, { agentId, status: "queued" }); + expect(wakes).toHaveLength(1); + expect(wakes[0].companyId).toBe(shared.companyId); + expect(wakes[0].coalescedCount).toBe(1); + + const triggers = JSON.parse(wakes[0].triggerDetail!); + expect(triggers).toHaveLength(2); + expect(triggers[0].trigger).toBe("subtask_completed"); + expect(triggers[1].trigger).toBe("comment"); + }); + + it("returns empty array when no wakeup requests exist", () => { + const agentId = createAgent({ name: "TestAgent" }); + const wakes = shared.db.listWakeupRequests(shared.companyId, { agentId, status: "queued" }); + expect(wakes).toHaveLength(0); + }); + + it("does not return wakes for other agents", () => { + const agent1 = createAgent({ name: "Agent1" }); + const agent2 = createAgent({ name: "Agent2" }); + + shared.db.enqueueWakeupRequest(shared.companyId, agent1, "comment", "comment"); + shared.db.enqueueWakeupRequest(shared.companyId, agent2, "subtask_completed", "subtask_completed"); + + const wakes1 = shared.db.listWakeupRequests(shared.companyId, { agentId: agent1, status: "queued" }); + expect(wakes1).toHaveLength(1); + expect(wakes1[0].source).toBe("comment"); + + // Agent2's wake should still be there + const wakes2 = shared.db.listWakeupRequests(shared.companyId, { agentId: agent2, status: "queued" }); + expect(wakes2).toHaveLength(1); + expect(wakes2[0].source).toBe("subtask_completed"); + }); +});