Build the current workspace snapshot on native CI runners
This branch snapshots the in-progress desktop workspace so GitHub-hosted Windows and Linux runners can package the same source tree that already passes local verification on macOS. Constraint: Local Apple Silicon Docker/Wine packaging crashes before Windows artifacts are produced Rejected: Publish from the incomplete base repo state | remote typecheck failed because current source additions were missing Confidence: medium Scope-risk: broad Directive: Treat this branch as a build snapshot only; do not merge it without reviewing the unrelated workspace changes it contains Tested: local typecheck, lint, workspace typecheck, unit tests, integration tests, e2e smoke, macOS signed release upload Not-tested: Native Windows/Linux artifacts before this CI run
This commit is contained in:
1 parent
0e497cd892
commit
b772e1c758
271 files changed
+35047
-22879
No files matched your search
@@ -0,0 +1,249 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { AppDatabase } from "../../src/main/database";
|
||||
import {
|
||||
canAssignTask,
|
||||
canControlAgent,
|
||||
canManageGoal,
|
||||
canManageOrganization,
|
||||
canManageProject,
|
||||
canRequestHire,
|
||||
} from "../../src/main/agent-permissions";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
async function createTempDir(prefix: string) {
|
||||
const dir = await mkdtemp(join(tmpdir(), prefix));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe("canManageOrganization", () => {
|
||||
it("allows executive leaders without direct reports to manage organization growth", async () => {
|
||||
const profileDir = await createTempDir("agentcompany-agent-perms-");
|
||||
const db = new AppDatabase(profileDir);
|
||||
db.init();
|
||||
|
||||
const companyId = db.saveCompany({
|
||||
name: "Org Permissions Co",
|
||||
description: "Tests recursive org authority",
|
||||
status: "active",
|
||||
});
|
||||
const workspaceId = db.saveWorkspace({
|
||||
companyId,
|
||||
name: "Primary",
|
||||
localPath: profileDir,
|
||||
repoUrl: "",
|
||||
repoRef: "",
|
||||
isPrimary: true,
|
||||
});
|
||||
|
||||
const ceoId = db.saveAgent({
|
||||
companyId,
|
||||
name: "CEO",
|
||||
role: "CEO",
|
||||
title: "Chief Executive Officer",
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "codex_local",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Lead the company",
|
||||
budgetMonthlyUsd: 250,
|
||||
});
|
||||
const ctoId = db.saveAgent({
|
||||
companyId,
|
||||
name: "CTO",
|
||||
role: "CTO",
|
||||
title: "Chief Technology Officer",
|
||||
status: "active",
|
||||
reportsTo: ceoId,
|
||||
connectorId: "codex_local",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Lead engineering",
|
||||
budgetMonthlyUsd: 220,
|
||||
department: "engineering",
|
||||
});
|
||||
const engineerId = db.saveAgent({
|
||||
companyId,
|
||||
name: "Engineer",
|
||||
role: "Engineer",
|
||||
title: "Software Engineer",
|
||||
status: "active",
|
||||
reportsTo: ceoId,
|
||||
connectorId: "codex_local",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Build features",
|
||||
budgetMonthlyUsd: 180,
|
||||
department: "engineering",
|
||||
});
|
||||
|
||||
expect(canManageOrganization(db, companyId, ceoId)).toBe(true);
|
||||
expect(canManageOrganization(db, companyId, ctoId)).toBe(false);
|
||||
expect(canRequestHire(db, companyId, ctoId)).toBe(true);
|
||||
expect(canManageOrganization(db, companyId, engineerId)).toBe(false);
|
||||
expect(canRequestHire(db, companyId, engineerId)).toBe(false);
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("allows explicit organization_manage permission to grant recursive org authority", async () => {
|
||||
const profileDir = await createTempDir("agentcompany-agent-perms-override-");
|
||||
const db = new AppDatabase(profileDir);
|
||||
db.init();
|
||||
|
||||
const companyId = db.saveCompany({
|
||||
name: "Org Permission Override Co",
|
||||
description: "Tests permission-based org authority",
|
||||
status: "active",
|
||||
});
|
||||
const workspaceId = db.saveWorkspace({
|
||||
companyId,
|
||||
name: "Primary",
|
||||
localPath: profileDir,
|
||||
repoUrl: "",
|
||||
repoRef: "",
|
||||
isPrimary: true,
|
||||
});
|
||||
const ceoId = db.saveAgent({
|
||||
companyId,
|
||||
name: "CEO",
|
||||
role: "CEO",
|
||||
title: "Chief Executive Officer",
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "codex_local",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Lead the company",
|
||||
budgetMonthlyUsd: 250,
|
||||
});
|
||||
const agentId = db.saveAgent({
|
||||
companyId,
|
||||
name: "Operations Lead",
|
||||
role: "Specialist",
|
||||
title: "Operations Specialist",
|
||||
status: "active",
|
||||
reportsTo: ceoId,
|
||||
connectorId: "codex_local",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Coordinate operations",
|
||||
budgetMonthlyUsd: 180,
|
||||
department: "operations",
|
||||
});
|
||||
|
||||
expect(canManageOrganization(db, companyId, agentId)).toBe(false);
|
||||
db.setAgentPermission(agentId, companyId, "organization_manage", true);
|
||||
expect(canManageOrganization(db, companyId, agentId)).toBe(true);
|
||||
expect(canRequestHire(db, companyId, agentId)).toBe(true);
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("manager subtree growth does not become org-wide admin after the first hire", async () => {
|
||||
const profileDir = await createTempDir("agentcompany-agent-perms-scope-");
|
||||
const db = new AppDatabase(profileDir);
|
||||
db.init();
|
||||
|
||||
const companyId = db.saveCompany({
|
||||
name: "Scoped Authority Co",
|
||||
description: "Tests that branch growth does not unlock org-wide admin powers",
|
||||
status: "active",
|
||||
});
|
||||
const workspaceId = db.saveWorkspace({
|
||||
companyId,
|
||||
name: "Primary",
|
||||
localPath: profileDir,
|
||||
repoUrl: "",
|
||||
repoRef: "",
|
||||
isPrimary: true,
|
||||
});
|
||||
const ceoId = db.saveAgent({
|
||||
companyId,
|
||||
name: "CEO",
|
||||
role: "CEO",
|
||||
title: "Chief Executive Officer",
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "codex_local",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Lead the company",
|
||||
budgetMonthlyUsd: 250,
|
||||
});
|
||||
const ctoId = db.saveAgent({
|
||||
companyId,
|
||||
name: "CTO",
|
||||
role: "CTO",
|
||||
title: "Chief Technology Officer",
|
||||
status: "active",
|
||||
reportsTo: ceoId,
|
||||
connectorId: "codex_local",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Lead engineering",
|
||||
budgetMonthlyUsd: 220,
|
||||
department: "engineering",
|
||||
});
|
||||
const cmoId = db.saveAgent({
|
||||
companyId,
|
||||
name: "CMO",
|
||||
role: "CMO",
|
||||
title: "Chief Marketing Officer",
|
||||
status: "active",
|
||||
reportsTo: ceoId,
|
||||
connectorId: "codex_local",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Lead marketing",
|
||||
budgetMonthlyUsd: 220,
|
||||
department: "marketing",
|
||||
});
|
||||
const ctoReportId = db.saveAgent({
|
||||
companyId,
|
||||
name: "Engineer",
|
||||
role: "Engineer",
|
||||
title: "Frontend Engineer",
|
||||
status: "active",
|
||||
reportsTo: ctoId,
|
||||
connectorId: "codex_local",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Build frontend features",
|
||||
budgetMonthlyUsd: 180,
|
||||
department: "engineering",
|
||||
});
|
||||
|
||||
const cmoTaskId = db.saveTask({
|
||||
companyId,
|
||||
title: "Launch campaign",
|
||||
description: "Owned by the marketing branch",
|
||||
assigneeAgentId: cmoId,
|
||||
workspaceId,
|
||||
priority: "high",
|
||||
status: "todo",
|
||||
});
|
||||
expect(canRequestHire(db, companyId, ctoId)).toBe(true);
|
||||
expect(canManageOrganization(db, companyId, ctoId)).toBe(false);
|
||||
expect(canControlAgent(db, companyId, ctoId, ctoReportId)).toBe(true);
|
||||
expect(canControlAgent(db, companyId, ctoId, cmoId)).toBe(false);
|
||||
expect(canAssignTask(db, companyId, ctoId, ctoReportId)).toBe(true);
|
||||
expect(canAssignTask(db, companyId, ctoId, cmoId)).toBe(false);
|
||||
expect(canManageGoal(db, companyId, ctoId, cmoId)).toBe(false);
|
||||
expect(canManageProject(db, companyId, ctoId, cmoId)).toBe(false);
|
||||
expect(canManageGoal(db, companyId, ceoId, cmoId)).toBe(true);
|
||||
expect(canManageProject(db, companyId, ceoId, cmoId)).toBe(true);
|
||||
expect(db.getTask(cmoTaskId).assigneeAgentId).toBe(cmoId);
|
||||
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { AppDatabase } from "../../src/main/database";
|
||||
import { handleApprovalResolved } from "../../src/main/automation/automation-approval-handler";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
async function createTempDir(prefix: string) {
|
||||
const dir = await mkdtemp(join(tmpdir(), prefix));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe("hire approval routing", () => {
|
||||
it("approved subordinate hires wake the hire, requester, and CEO", async () => {
|
||||
const profileDir = await createTempDir("agentcompany-hire-routing-");
|
||||
const db = new AppDatabase(profileDir);
|
||||
db.init();
|
||||
|
||||
db.updateConnectorHealth({
|
||||
id: "codex_local",
|
||||
status: "ready",
|
||||
authState: "authenticated",
|
||||
version: "test",
|
||||
lastError: null,
|
||||
});
|
||||
|
||||
const companyId = db.saveCompany({
|
||||
name: "Hire Routing Co",
|
||||
description: "Tests recursive hire wake routing",
|
||||
status: "active",
|
||||
});
|
||||
const workspaceId = db.saveWorkspace({
|
||||
companyId,
|
||||
name: "Primary",
|
||||
localPath: profileDir,
|
||||
repoUrl: "",
|
||||
repoRef: "",
|
||||
isPrimary: true,
|
||||
});
|
||||
const ceoId = db.saveAgent({
|
||||
companyId,
|
||||
name: "CEO",
|
||||
role: "CEO",
|
||||
title: "Chief Executive Officer",
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "codex_local",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Lead the company",
|
||||
budgetMonthlyUsd: 250,
|
||||
});
|
||||
const ctoId = db.saveAgent({
|
||||
companyId,
|
||||
name: "CTO",
|
||||
role: "CTO",
|
||||
title: "Chief Technology Officer",
|
||||
status: "active",
|
||||
reportsTo: ceoId,
|
||||
connectorId: "codex_local",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Lead engineering",
|
||||
budgetMonthlyUsd: 220,
|
||||
department: "engineering",
|
||||
});
|
||||
|
||||
const { agentId: hireId, approvalId } = db.createHireRequest({
|
||||
companyId,
|
||||
requestedByAgentId: ctoId,
|
||||
name: "New Engineer",
|
||||
role: "Engineer",
|
||||
title: "Frontend Engineer",
|
||||
department: "engineering",
|
||||
reportsTo: ctoId,
|
||||
connectorId: "codex_local",
|
||||
workspaceId,
|
||||
model: null,
|
||||
metadataJson: "{}",
|
||||
capabilities: "React, TypeScript, Tailwind",
|
||||
budgetMonthlyUsd: 180,
|
||||
payloadSummary: "Hire new engineer",
|
||||
impactSummary: "Need another IC under the CTO",
|
||||
payloadJson: "{}",
|
||||
});
|
||||
|
||||
db.decideApproval({
|
||||
approvalId,
|
||||
state: "approved",
|
||||
decisionNote: "Approved by board",
|
||||
});
|
||||
|
||||
const wakeAgentIfPossible = vi.fn().mockReturnValue(null);
|
||||
const dispatchAutomation = vi.fn();
|
||||
const notify = vi.fn();
|
||||
|
||||
handleApprovalResolved({
|
||||
db,
|
||||
logger: { info: () => {}, warn: () => {}, error: () => {} },
|
||||
publishDomainChanged: () => {},
|
||||
notify,
|
||||
wakeAgentIfPossible,
|
||||
dispatchBrowserAction: async () => {},
|
||||
isQuitting: () => false,
|
||||
}, {
|
||||
dispatchAutomation,
|
||||
handleTaskStatusChange: vi.fn(),
|
||||
runWorkflowPipeline: async () => {},
|
||||
trackTimer: vi.fn(),
|
||||
}, approvalId, companyId, "approved");
|
||||
|
||||
expect(db.getAgent(hireId).status).toBe("idle");
|
||||
expect(dispatchAutomation).toHaveBeenCalledWith("approval_resolved", expect.objectContaining({ companyId, agentId: ctoId }));
|
||||
expect(dispatchAutomation).toHaveBeenCalledWith("hire_approved", expect.objectContaining({ companyId, agentId: hireId }));
|
||||
expect(wakeAgentIfPossible).toHaveBeenCalledWith(hireId, companyId, "assignment");
|
||||
expect(wakeAgentIfPossible).toHaveBeenCalledWith(ceoId, companyId, "assignment");
|
||||
expect(wakeAgentIfPossible).toHaveBeenCalledWith(ctoId, companyId, "approval_resolved");
|
||||
expect(notify).toHaveBeenCalledWith(expect.objectContaining({
|
||||
body: expect.stringContaining("New Engineer"),
|
||||
}));
|
||||
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { AppDatabase } from "../../src/main/database";
|
||||
import { handleApprovalResolved } from "../../src/main/automation/automation-approval-handler";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
async function createTempDir(prefix: string) {
|
||||
const dir = await mkdtemp(join(tmpdir(), prefix));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe("deliverable review feedback writeback", () => {
|
||||
it("persists high-importance knowledge when the board requests revisions on a deliverable", async () => {
|
||||
const profileDir = await createTempDir("agentcompany-review-feedback-");
|
||||
const db = new AppDatabase(profileDir);
|
||||
db.init();
|
||||
|
||||
db.updateConnectorHealth({
|
||||
id: "codex_local",
|
||||
status: "ready",
|
||||
authState: "authenticated",
|
||||
version: "test",
|
||||
lastError: null,
|
||||
});
|
||||
|
||||
const companyId = db.saveCompany({
|
||||
name: "Review Feedback Co",
|
||||
description: "Tests review feedback knowledge writeback",
|
||||
status: "active",
|
||||
});
|
||||
const workspaceId = db.saveWorkspace({
|
||||
companyId,
|
||||
name: "Primary",
|
||||
localPath: profileDir,
|
||||
repoUrl: "",
|
||||
repoRef: "",
|
||||
isPrimary: true,
|
||||
});
|
||||
const agentId = db.saveAgent({
|
||||
companyId,
|
||||
name: "Research Lead",
|
||||
role: "Researcher",
|
||||
title: "Research Lead",
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "codex_local",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Research and synthesis",
|
||||
budgetMonthlyUsd: 180,
|
||||
department: "research",
|
||||
});
|
||||
const taskId = db.saveTask({
|
||||
companyId,
|
||||
title: "Draft market report",
|
||||
description: "Produce a market report with concrete evidence and business insight.",
|
||||
assigneeAgentId: agentId,
|
||||
workspaceId,
|
||||
priority: "high",
|
||||
status: "in_review",
|
||||
});
|
||||
const documentId = db.saveDocument({
|
||||
companyId,
|
||||
type: "knowledge_article",
|
||||
title: "Market report",
|
||||
content: "Draft report body",
|
||||
authorAgentId: agentId,
|
||||
status: "in_review",
|
||||
});
|
||||
const approvalId = db.requestApproval({
|
||||
companyId,
|
||||
relatedTaskId: taskId,
|
||||
requestedByAgentId: agentId,
|
||||
type: "deliverable_review",
|
||||
payloadSummary: "Review market report deliverable",
|
||||
impactSummary: "Board review required before the report is finalized.",
|
||||
payloadJson: JSON.stringify({ documentIds: [documentId] }),
|
||||
});
|
||||
|
||||
db.decideApproval({
|
||||
approvalId,
|
||||
state: "revision_requested",
|
||||
decisionNote: "Add stronger competitor evidence and a clearer recommendation.",
|
||||
});
|
||||
|
||||
const wakeAgentIfPossible = vi.fn().mockReturnValue(null);
|
||||
handleApprovalResolved({
|
||||
db,
|
||||
logger: { info: () => {}, warn: () => {}, error: () => {} },
|
||||
publishDomainChanged: () => {},
|
||||
notify: vi.fn(),
|
||||
wakeAgentIfPossible,
|
||||
dispatchBrowserAction: async () => {},
|
||||
isQuitting: () => false,
|
||||
}, {
|
||||
dispatchAutomation: vi.fn(),
|
||||
handleTaskStatusChange: vi.fn(),
|
||||
runWorkflowPipeline: async () => {},
|
||||
trackTimer: vi.fn(),
|
||||
}, approvalId, companyId, "revision_requested");
|
||||
|
||||
const knowledgeEntries = db.listSnapshot().knowledgeBase.filter((entry) => entry.companyId === companyId);
|
||||
expect(knowledgeEntries).toHaveLength(1);
|
||||
expect(knowledgeEntries[0]?.category).toBe("lesson_learned");
|
||||
expect(knowledgeEntries[0]?.importance).toBe("high");
|
||||
expect(knowledgeEntries[0]?.referencedEntityType).toBe("task");
|
||||
expect(knowledgeEntries[0]?.referencedEntityId).toBe(taskId);
|
||||
expect(knowledgeEntries[0]?.content).toContain("revision_requested");
|
||||
expect(knowledgeEntries[0]?.content).toContain("stronger competitor evidence");
|
||||
expect(wakeAgentIfPossible).toHaveBeenCalledWith(agentId, companyId, "revision_needed");
|
||||
expect(db.getTask(taskId).status).toBe("in_progress");
|
||||
const document = db.listSnapshot().documents.find((entry) => entry.id === documentId);
|
||||
expect(document?.status).toBe("draft");
|
||||
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
|
||||
/**
|
||||
* Tests for budget incident lifecycle: creation, deduplication (D-08),
|
||||
@@ -231,7 +231,7 @@ describe("autoCloseExpiredIncidents", () => {
|
||||
});
|
||||
|
||||
it("closes incidents whose window_end is in the past", () => {
|
||||
const policy = makePolicy();
|
||||
const _policy = makePolicy();
|
||||
// Manually create an expired incident
|
||||
db.incidents.push({
|
||||
id: "expired-1",
|
||||
@@ -329,7 +329,7 @@ describe("checkAutoResolve (D-06)", () => {
|
||||
|
||||
// Spent is now 7500 (below warn threshold of 8000 = 10000*80/100)
|
||||
const spent = 7500;
|
||||
const warnAmount = Math.floor((policy.amount * policy.warnPercent) / 100);
|
||||
const _warnAmount = Math.floor((policy.amount * policy.warnPercent) / 100);
|
||||
if (spent < policy.amount) {
|
||||
db.resolveIncidentsForPolicy("pol-1", "auto_resolved_limit_increase");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildCapabilityRegistrySection } from "@main/prompt-builder/capability-registry";
|
||||
|
||||
describe("buildCapabilityRegistrySection", () => {
|
||||
it("renders a company-wide capability summary for delegation decisions", () => {
|
||||
const section = buildCapabilityRegistrySection({
|
||||
companySkillCatalog: [
|
||||
{ name: "React Standards", slug: "react-standards", description: "Use when building React UI" },
|
||||
],
|
||||
companyWorkspaces: [
|
||||
{ name: "Primary", localPath: "/tmp/workspace" },
|
||||
],
|
||||
allConnectors: [
|
||||
{ id: "codex_local", label: "Codex", status: "ready", enabledModels: ["gpt-5.4"] },
|
||||
],
|
||||
companyDocuments: [
|
||||
{
|
||||
id: "doc-1",
|
||||
companyId: "company-1",
|
||||
title: "Architecture Decision",
|
||||
type: "architecture_decision",
|
||||
content: "",
|
||||
status: "approved",
|
||||
authorAgentId: null,
|
||||
reviewerAgentId: null,
|
||||
projectId: null,
|
||||
goalId: null,
|
||||
parentDocId: null,
|
||||
version: 1,
|
||||
tagsJson: "[]",
|
||||
latestRevisionId: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(section).toContain("# Company Capability Registry");
|
||||
expect(section).toContain("React Standards (`react-standards`)");
|
||||
expect(section).toContain("Primary: /tmp/workspace");
|
||||
expect(section).toContain("Codex (`codex_local`) — models: gpt-5.4");
|
||||
expect(section).toContain("architecture_decision");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeCommandValue } from "@agentcompany/adapter-utils/server-utils";
|
||||
import { commandLooksLike } from "@agentcompany/adapter-utils/test-utils";
|
||||
|
||||
describe("command path normalization", () => {
|
||||
it("removes surrounding quotes from absolute executable paths", () => {
|
||||
expect(normalizeCommandValue('"C:\\Users\\First Last\\AppData\\Local\\bin\\claude.exe"')).toBe(
|
||||
"C:\\Users\\First Last\\AppData\\Local\\bin\\claude.exe",
|
||||
);
|
||||
expect(normalizeCommandValue("'C:\\Users\\First Last\\AppData\\Local\\bin\\claude.exe'")).toBe(
|
||||
"C:\\Users\\First Last\\AppData\\Local\\bin\\claude.exe",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps bare commands unchanged", () => {
|
||||
expect(normalizeCommandValue("claude")).toBe("claude");
|
||||
expect(normalizeCommandValue("codex")).toBe("codex");
|
||||
});
|
||||
|
||||
it("matches quoted commands against connector executables", () => {
|
||||
expect(commandLooksLike('"C:\\Users\\First Last\\AppData\\Local\\bin\\claude.exe"', "claude")).toBe(true);
|
||||
expect(commandLooksLike("'C:\\Users\\First Last\\AppData\\Local\\bin\\codex.exe'", "codex")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -4,8 +4,8 @@ import {
|
||||
inferTaskType,
|
||||
getConnectorStrengths,
|
||||
TASK_TYPE_PREFERENCE,
|
||||
CONNECTOR_STRENGTHS,
|
||||
ROLE_DEPARTMENT_HINTS,
|
||||
CONNECTOR_STRENGTHS as _CONNECTOR_STRENGTHS,
|
||||
ROLE_DEPARTMENT_HINTS as _ROLE_DEPARTMENT_HINTS,
|
||||
} from "../../src/main/connector-matching";
|
||||
import type { ConnectorId, ConnectorStatus } from "@shared/types";
|
||||
|
||||
@@ -35,10 +35,9 @@ describe("connector-matching", () => {
|
||||
expect(TASK_TYPE_PREFERENCE.general[0]).toBe("claude_local");
|
||||
});
|
||||
|
||||
it("includes all three connectors for every task type", () => {
|
||||
it("includes all three core connectors for every task type", () => {
|
||||
for (const key of Object.keys(TASK_TYPE_PREFERENCE)) {
|
||||
const prefs = TASK_TYPE_PREFERENCE[key as keyof typeof TASK_TYPE_PREFERENCE];
|
||||
expect(prefs).toHaveLength(3);
|
||||
expect(prefs).toContain("claude_local");
|
||||
expect(prefs).toContain("codex_local");
|
||||
expect(prefs).toContain("gemini_local");
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getConnectorModelPolicy, validateConnectorModelSelection } from "@main/connector-model-policy";
|
||||
import { getConnectorDefinition } from "@main/connectors";
|
||||
|
||||
function createDeps(overrides: {
|
||||
connectorModel?: string | null;
|
||||
disabledModels?: Array<{ id: string; label: string }>;
|
||||
} = {}) {
|
||||
return {
|
||||
getConnector: () => ({
|
||||
id: "claude_compatible" as const,
|
||||
label: "Claude Compatible",
|
||||
model: overrides.connectorModel ?? "model-alpha,model-beta,model-gamma",
|
||||
}),
|
||||
getDisabledModels: () => overrides.disabledModels ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
describe("connector model policy", () => {
|
||||
it("derives enabled compat models from connector config minus disabled entries", () => {
|
||||
const policy = getConnectorModelPolicy(
|
||||
createDeps({ disabledModels: [{ id: "model-beta", label: "model-beta" }] }),
|
||||
"claude_compatible",
|
||||
);
|
||||
|
||||
expect(policy.enabledModels.map((model) => model.id)).toEqual(["model-alpha", "model-gamma"]);
|
||||
});
|
||||
|
||||
it("accepts an explicit enabled model", () => {
|
||||
const result = validateConnectorModelSelection(createDeps(), "claude_compatible", "model-alpha");
|
||||
|
||||
expect(result).toEqual({ ok: true, effectiveModel: "model-alpha" });
|
||||
});
|
||||
|
||||
it("rejects an explicit disabled model", () => {
|
||||
const result = validateConnectorModelSelection(
|
||||
createDeps({ disabledModels: [{ id: "model-beta", label: "model-beta" }] }),
|
||||
"claude_compatible",
|
||||
"model-beta",
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) {
|
||||
throw new Error("Expected explicit disabled model to be rejected.");
|
||||
}
|
||||
expect(result.error).toContain('does not allow model "model-beta"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("native connector defaults", () => {
|
||||
it("uses the persisted connector model as the native default when configured", () => {
|
||||
const codexModels = getConnectorDefinition("codex_local").module.models ?? [];
|
||||
const configuredDefault = codexModels[1]?.id;
|
||||
|
||||
if (!configuredDefault) {
|
||||
throw new Error("codex_local requires at least two models for this default policy test.");
|
||||
}
|
||||
|
||||
const policy = getConnectorModelPolicy({
|
||||
getConnector: () => ({
|
||||
id: "codex_local" as const,
|
||||
label: "Codex",
|
||||
model: configuredDefault,
|
||||
}),
|
||||
getDisabledModels: () => [],
|
||||
}, "codex_local");
|
||||
|
||||
expect(policy.defaultModel).toBe(configuredDefault);
|
||||
expect(validateConnectorModelSelection({
|
||||
getConnector: () => ({
|
||||
id: "codex_local" as const,
|
||||
label: "Codex",
|
||||
model: configuredDefault,
|
||||
}),
|
||||
getDisabledModels: () => [],
|
||||
}, "codex_local", null)).toEqual({
|
||||
ok: true,
|
||||
effectiveModel: configuredDefault,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
mergeConnectorModelEntries,
|
||||
resolveSelectedConnectorModel,
|
||||
} from "@shared/connector-models";
|
||||
|
||||
describe("connector model helpers", () => {
|
||||
it("merges enabled and disabled models into one toggle list", () => {
|
||||
const entries = mergeConnectorModelEntries(
|
||||
[
|
||||
{ id: "gpt-5.4", label: "GPT-5.4" },
|
||||
{ id: "gpt-5.4-mini", label: "GPT-5.4 Mini" },
|
||||
],
|
||||
[{ id: "o4-mini", label: "o4-mini" }],
|
||||
);
|
||||
|
||||
expect(entries).toEqual([
|
||||
{ id: "gpt-5.4", label: "GPT-5.4", enabled: true },
|
||||
{ id: "gpt-5.4-mini", label: "GPT-5.4 Mini", enabled: true },
|
||||
{ id: "o4-mini", label: "o4-mini", enabled: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the current selection only if it remains enabled", () => {
|
||||
const entries = mergeConnectorModelEntries(
|
||||
[{ id: "gpt-5.4", label: "GPT-5.4" }],
|
||||
[{ id: "gpt-5.4-mini", label: "GPT-5.4 Mini" }],
|
||||
);
|
||||
|
||||
expect(resolveSelectedConnectorModel(entries, "gpt-5.4")).toBe("gpt-5.4");
|
||||
expect(resolveSelectedConnectorModel(entries, "gpt-5.4-mini")).toBe("gpt-5.4");
|
||||
expect(resolveSelectedConnectorModel(entries, "")).toBe("gpt-5.4");
|
||||
});
|
||||
|
||||
it("dedupes duplicate ids and keeps enabled entries ahead of disabled ones", () => {
|
||||
const entries = mergeConnectorModelEntries(
|
||||
[{ id: "gpt-5.4", label: "GPT-5.4" }],
|
||||
[
|
||||
{ id: "gpt-5.4", label: "GPT-5.4" },
|
||||
{ id: "gpt-4.1", label: "GPT-4.1" },
|
||||
],
|
||||
);
|
||||
|
||||
expect(entries).toEqual([
|
||||
{ id: "gpt-5.4", label: "GPT-5.4", enabled: true },
|
||||
{ id: "gpt-4.1", label: "GPT-4.1", enabled: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns an empty selection when all entries are disabled", () => {
|
||||
const entries = mergeConnectorModelEntries([], [{ id: "gpt-5.4", label: "GPT-5.4" }]);
|
||||
|
||||
expect(resolveSelectedConnectorModel(entries, "gpt-5.4")).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -11,13 +11,13 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
*/
|
||||
|
||||
interface MockStatement {
|
||||
run: ReturnType<typeof vi.fn>;
|
||||
get: ReturnType<typeof vi.fn>;
|
||||
all: ReturnType<typeof vi.fn>;
|
||||
run: (...args: unknown[]) => unknown;
|
||||
get: (...args: unknown[]) => unknown;
|
||||
all: (...args: unknown[]) => unknown[];
|
||||
}
|
||||
|
||||
interface MockDatabase {
|
||||
prepare: ReturnType<typeof vi.fn>;
|
||||
prepare: (key: string) => MockStatement;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -25,7 +25,7 @@ interface MockDatabase {
|
||||
*/
|
||||
function simulateInterruptStaleRuns(
|
||||
db: MockDatabase,
|
||||
releaseAllWorkspaceLocks: ReturnType<typeof vi.fn>,
|
||||
releaseAllWorkspaceLocks: () => void,
|
||||
transactionFn: (fn: () => void) => void,
|
||||
runningCount: number,
|
||||
): number {
|
||||
@@ -95,7 +95,7 @@ function simulateMarkInterruptedTaskFailed(
|
||||
|
||||
describe("Crash Recovery Detection", () => {
|
||||
let db: MockDatabase;
|
||||
let releaseAllWorkspaceLocks: ReturnType<typeof vi.fn>;
|
||||
let releaseAllWorkspaceLocks: () => void;
|
||||
|
||||
beforeEach(() => {
|
||||
db = {
|
||||
@@ -104,8 +104,8 @@ describe("Crash Recovery Detection", () => {
|
||||
get: vi.fn().mockReturnValue(undefined),
|
||||
all: vi.fn().mockReturnValue([]),
|
||||
}),
|
||||
};
|
||||
releaseAllWorkspaceLocks = vi.fn();
|
||||
} as unknown as MockDatabase;
|
||||
releaseAllWorkspaceLocks = vi.fn() as unknown as () => void;
|
||||
});
|
||||
|
||||
describe("interruptStaleRuns", () => {
|
||||
|
||||
@@ -7,9 +7,9 @@ import {
|
||||
formatShortDate,
|
||||
} from "../../src/renderer/components/dashboard/charts/chart-utils";
|
||||
import type {
|
||||
DailyRunData,
|
||||
DailyCostData,
|
||||
AgentWorkloadData,
|
||||
DailyRunData as _DailyRunData,
|
||||
DailyCostData as _DailyCostData,
|
||||
AgentWorkloadData as _AgentWorkloadData,
|
||||
} from "../../src/renderer/components/dashboard/charts/chart-utils";
|
||||
|
||||
// Helper to create a minimal RunRecord for testing
|
||||
|
||||
@@ -195,7 +195,7 @@ describe("Edge Case: Session Rotation During Execution (D-05.3)", () => {
|
||||
});
|
||||
db.finishRun({
|
||||
runId: run.id,
|
||||
status: "completed",
|
||||
status: "succeeded",
|
||||
summary: "Completed initial implementation",
|
||||
errorMessage: null,
|
||||
exitCode: 0,
|
||||
@@ -341,7 +341,7 @@ describe("Edge Case: Session Rotation During Execution (D-05.3)", () => {
|
||||
});
|
||||
db.finishRun({
|
||||
runId: run.id,
|
||||
status: "completed",
|
||||
status: "succeeded",
|
||||
summary: "Previous work done",
|
||||
errorMessage: null,
|
||||
exitCode: 0,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from "vitest";
|
||||
|
||||
/**
|
||||
* Tests for the retry-aware failure escalation pipeline.
|
||||
@@ -15,19 +15,19 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
// and verifying the calls it makes to the database and notification system.
|
||||
|
||||
interface MockDb {
|
||||
getTaskRetryCount: ReturnType<typeof vi.fn>;
|
||||
incrementTaskRetryCount: ReturnType<typeof vi.fn>;
|
||||
resetTaskRetryCount: ReturnType<typeof vi.fn>;
|
||||
getChainOfCommand: ReturnType<typeof vi.fn>;
|
||||
getCeoForCompany: ReturnType<typeof vi.fn>;
|
||||
advanceTaskStatus: ReturnType<typeof vi.fn>;
|
||||
getTaskRetryCount: MockInstance & ((taskId: string) => number);
|
||||
incrementTaskRetryCount: MockInstance & ((taskId: string) => void);
|
||||
resetTaskRetryCount: MockInstance & ((taskId: string) => void);
|
||||
getChainOfCommand: MockInstance & ((agentId: string) => { id: string; name: string }[]);
|
||||
getCeoForCompany: MockInstance & ((companyId: string) => { id: string; name: string } | null);
|
||||
advanceTaskStatus: MockInstance & ((taskId: string, status: string, note: string) => { previousStatus: string });
|
||||
}
|
||||
|
||||
interface MockDeps {
|
||||
db: MockDb;
|
||||
wakeAgentIfPossible: ReturnType<typeof vi.fn>;
|
||||
notify: ReturnType<typeof vi.fn>;
|
||||
logger: { info: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn>; error: ReturnType<typeof vi.fn> };
|
||||
wakeAgentIfPossible: MockInstance & ((agentId: string, companyId: string, reason: string) => string);
|
||||
notify: MockInstance & ((...args: unknown[]) => void);
|
||||
logger: { info: MockInstance & ((...args: unknown[]) => void); warn: MockInstance & ((...args: unknown[]) => void); error: MockInstance & ((...args: unknown[]) => void) };
|
||||
}
|
||||
|
||||
const MAX_RETRY_ATTEMPTS = 2;
|
||||
@@ -146,7 +146,7 @@ function createMockDeps(): MockDeps {
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
};
|
||||
} as unknown as MockDeps;
|
||||
}
|
||||
|
||||
const RUN = { taskId: "task-1", agentId: "agent-1", companyId: "company-1" };
|
||||
|
||||
@@ -144,7 +144,7 @@ describe("Heartbeat task coalescing", () => {
|
||||
const agentId = createTestAgent(db, companyId, wsId, "CEO");
|
||||
|
||||
// Create two done heartbeat tasks
|
||||
const oldTaskId = createHeartbeatTask(db, companyId, agentId, "done", "CEO");
|
||||
const _oldTaskId = createHeartbeatTask(db, companyId, agentId, "done", "CEO");
|
||||
const newTaskId = createHeartbeatTask(db, companyId, agentId, "done", "CEO");
|
||||
|
||||
// Advance the newer one so its updated_at is more recent
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { IPC_CHANNELS } from "@shared/contracts";
|
||||
import { AppDatabase } from "../../src/main/database";
|
||||
import { registerCoreAgentGraphHandlers } from "../../src/main/ipc-core-agent-graph-handlers";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
async function createTempDir(prefix: string) {
|
||||
const dir = await mkdtemp(join(tmpdir(), prefix));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe("ipc-core-agent-graph-handlers model enforcement", () => {
|
||||
it("rejects saveAgent when the selected model is disabled for the connector", async () => {
|
||||
const profileDir = await createTempDir("agentcompany-agent-handler-");
|
||||
const db = new AppDatabase(profileDir);
|
||||
db.init();
|
||||
|
||||
db.updateConnectorHealth({
|
||||
id: "codex_local",
|
||||
status: "ready",
|
||||
authState: "authenticated",
|
||||
version: "test",
|
||||
lastError: null,
|
||||
});
|
||||
db.setDisabledModels("codex_local", [{ id: "gpt-5.4", label: "gpt-5.4" }]);
|
||||
|
||||
const companyId = db.saveCompany({
|
||||
name: "Handler Test Co",
|
||||
description: "Tests saveAgent model validation",
|
||||
status: "active",
|
||||
});
|
||||
const workspaceId = db.saveWorkspace({
|
||||
companyId,
|
||||
name: "Primary",
|
||||
localPath: profileDir,
|
||||
repoUrl: "",
|
||||
repoRef: "",
|
||||
isPrimary: true,
|
||||
});
|
||||
const agentId = db.saveAgent({
|
||||
companyId,
|
||||
name: "CEO",
|
||||
role: "CEO",
|
||||
title: "Chief Executive Officer",
|
||||
status: "idle",
|
||||
reportsTo: null,
|
||||
connectorId: "codex_local",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Lead the company",
|
||||
budgetMonthlyUsd: 250,
|
||||
});
|
||||
|
||||
const handlers = new Map<string, (...args: any[]) => any>();
|
||||
registerCoreAgentGraphHandlers({
|
||||
registerHandle: (channel, handler) => {
|
||||
handlers.set(channel, handler);
|
||||
},
|
||||
db,
|
||||
ensureCompanyExists: (id) => (db.hasCompany(id) ? null : { ok: false, error: { code: "COMPANY_NOT_FOUND", message: "missing" } }),
|
||||
findInvalidReference: () => null,
|
||||
publishDomainChanged: vi.fn(),
|
||||
handleApprovalResolved: vi.fn(),
|
||||
});
|
||||
|
||||
const saveAgent = handlers.get(IPC_CHANNELS.saveAgent);
|
||||
if (!saveAgent) {
|
||||
throw new Error("saveAgent handler was not registered.");
|
||||
}
|
||||
|
||||
const result = await saveAgent(null, {
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "CEO",
|
||||
role: "CEO",
|
||||
title: "Chief Executive Officer",
|
||||
department: "executive",
|
||||
status: "idle",
|
||||
reportsTo: null,
|
||||
connectorId: "codex_local",
|
||||
workspaceId,
|
||||
model: "gpt-5.4",
|
||||
metadataJson: "{}",
|
||||
capabilities: "Lead the company",
|
||||
budgetMonthlyUsd: 250,
|
||||
}) as { ok: boolean; error?: { code: string; message: string } };
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error?.code).toBe("CONNECTOR_MODEL_NOT_ALLOWED");
|
||||
});
|
||||
});
|
||||
@@ -144,7 +144,7 @@ describe("createDomainEventBatcher", () => {
|
||||
const batcher = createDomainEventBatcher(onFull, onGranular, 50);
|
||||
|
||||
// These events are not recognized as granular -- per D-05, unknown triggers full refresh
|
||||
batcher.push({ type: "run-log", runId: "r-1", stream: "stdout", text: "hi" } as DesktopEvent);
|
||||
batcher.push({ type: "run-log", runId: "r-1", stream: "stdout", chunk: "hi" } as DesktopEvent);
|
||||
|
||||
vi.advanceTimersByTime(50);
|
||||
expect(onFull).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { IPC_CHANNELS } from "@shared/contracts";
|
||||
import { AppDatabase } from "../../src/main/database";
|
||||
import { registerCoreAdminHandlers } from "../../src/main/ipc-core-admin-handlers";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
async function createTempDir(prefix: string) {
|
||||
const dir = await mkdtemp(join(tmpdir(), prefix));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe("ipc-core-admin-handlers connector saves", () => {
|
||||
it("accepts compatible connector model catalogs stored as csv", async () => {
|
||||
const profileDir = await createTempDir("agentcompany-admin-handler-");
|
||||
const db = new AppDatabase(profileDir);
|
||||
db.init();
|
||||
|
||||
const handlers = new Map<string, (...args: any[]) => any>();
|
||||
registerCoreAdminHandlers({
|
||||
registerHandle: (channel, handler) => {
|
||||
handlers.set(channel, handler);
|
||||
},
|
||||
db,
|
||||
ensureCompanyExists: () => null,
|
||||
publishDomainChanged: vi.fn(),
|
||||
checkConnector: vi.fn(),
|
||||
openConnectorAuthTerminal: vi.fn(),
|
||||
openGenericTerminal: vi.fn(),
|
||||
});
|
||||
|
||||
const saveConnector = handlers.get(IPC_CHANNELS.saveConnector);
|
||||
if (!saveConnector) {
|
||||
throw new Error("saveConnector handler was not registered.");
|
||||
}
|
||||
|
||||
const result = await saveConnector(null, {
|
||||
id: "claude_compatible",
|
||||
command: "claude",
|
||||
model: "model-alpha,model-beta,model-gamma",
|
||||
envBindingText: "ANTHROPIC_BASE_URL=https://example.com/anthropic",
|
||||
notes: "Compat provider",
|
||||
}) as { ok: boolean; error?: { code: string; message: string } };
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(db.getConnector("claude_compatible").model).toBe("model-alpha,model-beta,model-gamma");
|
||||
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { AppDatabase } from "../../src/main/database";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
async function createTempDir(prefix: string) {
|
||||
const dir = await mkdtemp(join(tmpdir(), prefix));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe("knowledge provenance", () => {
|
||||
it("preserves or refreshes sourceRunId when an existing knowledge entry is updated", async () => {
|
||||
const profileDir = await createTempDir("agentcompany-knowledge-provenance-");
|
||||
const db = new AppDatabase(profileDir);
|
||||
db.init();
|
||||
|
||||
const companyId = db.saveCompany({
|
||||
name: "Knowledge Provenance Co",
|
||||
description: "Tests sourceRunId updates on knowledge entry edits",
|
||||
status: "active",
|
||||
});
|
||||
|
||||
const knowledgeId = db.saveKnowledgeEntry({
|
||||
companyId,
|
||||
category: "lesson_learned",
|
||||
topic: "Original lesson",
|
||||
content: "Initial content",
|
||||
importance: "medium",
|
||||
sourceRunId: null,
|
||||
});
|
||||
|
||||
db.saveKnowledgeEntry({
|
||||
id: knowledgeId,
|
||||
companyId,
|
||||
category: "lesson_learned",
|
||||
topic: "Original lesson",
|
||||
content: "Updated content with better guidance",
|
||||
importance: "high",
|
||||
sourceRunId: "run-123",
|
||||
});
|
||||
|
||||
const entry = db.queryAll<{ sourceRunId: string | null; importance: string }>(
|
||||
"select source_run_id as sourceRunId, importance from knowledge_base where id = ? limit 1",
|
||||
knowledgeId,
|
||||
)[0];
|
||||
expect(entry?.sourceRunId).toBe("run-123");
|
||||
expect(entry?.importance).toBe("high");
|
||||
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,251 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { AppDatabase } from "../../src/main/database";
|
||||
import { createThreadIntegration } from "../../src/main/thread-integration";
|
||||
import { dispatchAgentMessage, HUMAN_SENDER_ID } from "../../src/main/message-dispatch";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
async function createTempDir(prefix: string) {
|
||||
const dir = await mkdtemp(join(tmpdir(), prefix));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
function createScaffold(db: AppDatabase) {
|
||||
db.updateConnectorHealth({
|
||||
id: "codex_local",
|
||||
status: "ready",
|
||||
authState: "authenticated",
|
||||
version: "test",
|
||||
lastError: null,
|
||||
});
|
||||
|
||||
const companyId = db.saveCompany({
|
||||
name: "Message Dispatch Co",
|
||||
description: "Tests board wake and wait-state resolution",
|
||||
status: "active",
|
||||
executionMode: "ai_cofounder",
|
||||
});
|
||||
const workspaceId = db.saveWorkspace({
|
||||
companyId,
|
||||
name: "Primary",
|
||||
localPath: "/tmp/message-dispatch",
|
||||
repoUrl: "",
|
||||
repoRef: "",
|
||||
isPrimary: true,
|
||||
});
|
||||
const ceoId = db.saveAgent({
|
||||
companyId,
|
||||
name: "CEO",
|
||||
role: "CEO",
|
||||
title: "Chief Executive Officer",
|
||||
status: "idle",
|
||||
reportsTo: null,
|
||||
connectorId: "codex_local",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Lead the company",
|
||||
budgetMonthlyUsd: 250,
|
||||
});
|
||||
const ctoId = db.saveAgent({
|
||||
companyId,
|
||||
name: "CTO",
|
||||
role: "CTO",
|
||||
title: "Chief Technology Officer",
|
||||
status: "idle",
|
||||
reportsTo: ceoId,
|
||||
connectorId: "codex_local",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Lead engineering",
|
||||
budgetMonthlyUsd: 200,
|
||||
department: "engineering",
|
||||
});
|
||||
const cmoId = db.saveAgent({
|
||||
companyId,
|
||||
name: "CMO",
|
||||
role: "CMO",
|
||||
title: "Chief Marketing Officer",
|
||||
status: "idle",
|
||||
reportsTo: ceoId,
|
||||
connectorId: "codex_local",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Lead marketing",
|
||||
budgetMonthlyUsd: 200,
|
||||
department: "marketing",
|
||||
});
|
||||
const pendingId = db.saveAgent({
|
||||
companyId,
|
||||
name: "Pending Recruit",
|
||||
role: "IC",
|
||||
title: "Pending Hire",
|
||||
status: "pending_approval",
|
||||
reportsTo: ctoId,
|
||||
connectorId: "codex_local",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Pending",
|
||||
budgetMonthlyUsd: 100,
|
||||
department: "engineering",
|
||||
});
|
||||
const terminatedId = db.saveAgent({
|
||||
companyId,
|
||||
name: "Former COO",
|
||||
role: "COO",
|
||||
title: "Former COO",
|
||||
status: "terminated",
|
||||
reportsTo: ceoId,
|
||||
connectorId: "codex_local",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "No longer active",
|
||||
budgetMonthlyUsd: 100,
|
||||
department: "operations",
|
||||
});
|
||||
|
||||
const taskId = db.saveTask({
|
||||
companyId,
|
||||
title: "Review architecture plan",
|
||||
description: "Need board feedback in the thread.",
|
||||
assigneeAgentId: ctoId,
|
||||
workspaceId,
|
||||
priority: "high",
|
||||
status: "todo",
|
||||
});
|
||||
|
||||
return { companyId, workspaceId, ceoId, ctoId, cmoId, pendingId, terminatedId, taskId };
|
||||
}
|
||||
|
||||
describe("message dispatch", () => {
|
||||
it("wakes top-level active agents for company messages and active agents for incident messages", async () => {
|
||||
const profileDir = await createTempDir("agentcompany-message-dispatch-");
|
||||
const db = new AppDatabase(profileDir);
|
||||
db.init();
|
||||
|
||||
const { companyId, ceoId, ctoId, cmoId } = createScaffold(db);
|
||||
const wakeAgentIfPossible = vi.fn().mockReturnValue(null);
|
||||
|
||||
const companyResult = dispatchAgentMessage({
|
||||
db,
|
||||
wakeAgentIfPossible,
|
||||
}, {
|
||||
companyId,
|
||||
fromAgentId: HUMAN_SENDER_ID,
|
||||
fromHumanUser: true,
|
||||
channel: "company",
|
||||
body: "Board broadcast",
|
||||
});
|
||||
|
||||
expect(companyResult.ok).toBe(true);
|
||||
if (!companyResult.ok) throw new Error("Expected company message dispatch to succeed.");
|
||||
expect(companyResult.data.wakeTargets).toEqual([ceoId]);
|
||||
|
||||
const incidentResult = dispatchAgentMessage({
|
||||
db,
|
||||
wakeAgentIfPossible,
|
||||
}, {
|
||||
companyId,
|
||||
fromAgentId: HUMAN_SENDER_ID,
|
||||
fromHumanUser: true,
|
||||
channel: "incident",
|
||||
body: "Urgent issue",
|
||||
});
|
||||
|
||||
expect(incidentResult.ok).toBe(true);
|
||||
if (!incidentResult.ok) throw new Error("Expected incident message dispatch to succeed.");
|
||||
expect(new Set(incidentResult.data.wakeTargets)).toEqual(new Set([ceoId, ctoId, cmoId]));
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("resolves only user_reply wait states for company-wide human messages, but scoped replies resolve thread wait states", async () => {
|
||||
const profileDir = await createTempDir("agentcompany-message-wait-");
|
||||
const db = new AppDatabase(profileDir);
|
||||
db.init();
|
||||
|
||||
const { companyId, ctoId, taskId } = createScaffold(db);
|
||||
const threadIntegration = createThreadIntegration({
|
||||
db,
|
||||
logger: { info: () => {}, error: () => {} },
|
||||
});
|
||||
|
||||
threadIntegration.onTaskCreated(taskId, companyId);
|
||||
const thread = db.listThreads({
|
||||
companyId,
|
||||
type: "task",
|
||||
relatedEntityType: "task",
|
||||
relatedEntityId: taskId,
|
||||
limit: 1,
|
||||
})[0];
|
||||
|
||||
if (!thread) {
|
||||
throw new Error("Expected task thread to exist.");
|
||||
}
|
||||
|
||||
const replyWait = db.setThreadWaitState({
|
||||
threadId: thread.id,
|
||||
companyId,
|
||||
waitingFor: "user_reply",
|
||||
relatedApprovalId: null,
|
||||
reason: "Reply needed",
|
||||
});
|
||||
const reviewWait = db.setThreadWaitState({
|
||||
threadId: thread.id,
|
||||
companyId,
|
||||
waitingFor: "user_review",
|
||||
relatedApprovalId: null,
|
||||
reason: "Review needed",
|
||||
});
|
||||
const approvalWait = db.setThreadWaitState({
|
||||
threadId: thread.id,
|
||||
companyId,
|
||||
waitingFor: "user_approval",
|
||||
relatedApprovalId: "approval-1",
|
||||
reason: "Approval needed",
|
||||
});
|
||||
|
||||
dispatchAgentMessage({
|
||||
db,
|
||||
onHumanMessage: (currentCompanyId, toAgentId, threadId, waitStateId) =>
|
||||
threadIntegration.onHumanMessage(currentCompanyId, toAgentId, threadId, waitStateId),
|
||||
}, {
|
||||
companyId,
|
||||
fromAgentId: HUMAN_SENDER_ID,
|
||||
fromHumanUser: true,
|
||||
channel: "company",
|
||||
body: "Board-wide update",
|
||||
});
|
||||
|
||||
let unresolved = db.listThreadWaitStates(companyId);
|
||||
expect(unresolved.some((entry) => entry.id === replyWait.id)).toBe(false);
|
||||
expect(unresolved.some((entry) => entry.id === reviewWait.id)).toBe(true);
|
||||
|
||||
dispatchAgentMessage({
|
||||
db,
|
||||
onHumanMessage: (currentCompanyId, toAgentId, threadId, waitStateId) =>
|
||||
threadIntegration.onHumanMessage(currentCompanyId, toAgentId, threadId, waitStateId),
|
||||
}, {
|
||||
companyId,
|
||||
fromAgentId: HUMAN_SENDER_ID,
|
||||
fromHumanUser: true,
|
||||
channel: "direct",
|
||||
toAgentId: ctoId,
|
||||
threadId: thread.id,
|
||||
body: "Specific reply for the thread",
|
||||
});
|
||||
|
||||
unresolved = db.listThreadWaitStates(companyId);
|
||||
expect(unresolved.some((entry) => entry.id === reviewWait.id)).toBe(false);
|
||||
expect(unresolved.some((entry) => entry.id === approvalWait.id)).toBe(true);
|
||||
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import type { BrowserWindow } from "electron";
|
||||
import type { DesktopEvent } from "@shared/types";
|
||||
|
||||
// Track all constructed notification instances
|
||||
const constructedNotifications: Array<{
|
||||
@@ -61,9 +63,9 @@ describe("notification-service", () => {
|
||||
};
|
||||
|
||||
service = createNotificationService({
|
||||
getMainWindow: mockGetMainWindow,
|
||||
emitEvent: mockEmitEvent,
|
||||
logger: mockLogger,
|
||||
getMainWindow: mockGetMainWindow as unknown as () => BrowserWindow | null,
|
||||
emitEvent: mockEmitEvent as unknown as (event: DesktopEvent) => void,
|
||||
logger: mockLogger as { info: (...args: unknown[]) => void; error: (...args: unknown[]) => void },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -203,7 +205,7 @@ describe("notification-service", () => {
|
||||
|
||||
// emitEvent should have been called with section but no entityId
|
||||
const emitCall = mockEmitEvent.mock.calls.find(
|
||||
([event]: [{ type: string }]) => event.type === "notification-click",
|
||||
(args: unknown[]) => (args[0] as { type?: string })?.type === "notification-click",
|
||||
);
|
||||
expect(emitCall).toBeDefined();
|
||||
expect(emitCall![0].section).toBe("orgchart");
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildSystemPrompt } from "../../src/main/prompt-builder";
|
||||
import type { PromptContext } from "../../src/main/prompt-builder/index";
|
||||
|
||||
function buildCtx(overrides: Partial<PromptContext> = {}): PromptContext {
|
||||
return {
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
name: "CTO",
|
||||
role: "CTO",
|
||||
title: "Chief Technology Officer",
|
||||
department: "engineering",
|
||||
status: "active",
|
||||
reportsTo: "ceo-1",
|
||||
connectorId: "codex_local",
|
||||
workspaceId: null,
|
||||
model: null,
|
||||
capabilities: "Lead engineering",
|
||||
budgetMonthlyUsd: 200,
|
||||
spentMonthlyUsd: 0,
|
||||
metadataJson: "{}",
|
||||
heartbeatEnabled: true,
|
||||
heartbeatIntervalSec: 120,
|
||||
lastHeartbeatAt: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
apiUrl: "http://127.0.0.1:3210",
|
||||
apiKey: "token",
|
||||
runId: "run-1",
|
||||
task: null,
|
||||
companyName: "Prompt Co",
|
||||
companyDescription: "Tests leadership prompt classification",
|
||||
goals: [],
|
||||
projects: [],
|
||||
directReports: [],
|
||||
chainOfCommand: [{
|
||||
id: "ceo-1",
|
||||
companyId: "company-1",
|
||||
name: "CEO",
|
||||
role: "CEO",
|
||||
title: "Chief Executive Officer",
|
||||
department: "executive",
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "codex_local",
|
||||
workspaceId: null,
|
||||
model: null,
|
||||
capabilities: "Lead the company",
|
||||
budgetMonthlyUsd: 250,
|
||||
spentMonthlyUsd: 0,
|
||||
metadataJson: "{}",
|
||||
heartbeatEnabled: true,
|
||||
heartbeatIntervalSec: 120,
|
||||
lastHeartbeatAt: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}],
|
||||
assignedTasks: [],
|
||||
allAgents: [],
|
||||
pendingApprovals: [],
|
||||
tasksAwaitingReview: [],
|
||||
wakeReason: null,
|
||||
allTasks: [],
|
||||
recentComments: [],
|
||||
socialAccounts: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildSystemPrompt leadership classification", () => {
|
||||
it("treats first-layer executive leaders as managers even before they have direct reports", () => {
|
||||
const prompt = buildSystemPrompt(buildCtx());
|
||||
|
||||
expect(prompt).toContain("Operating Instructions (Manager)");
|
||||
expect(prompt).toContain("New hires for your team should report to you by default");
|
||||
expect(prompt).not.toContain("Operating Instructions (Individual Contributor)");
|
||||
});
|
||||
});
|
||||
@@ -106,6 +106,8 @@ describe("selective IPC event types", () => {
|
||||
return "agent-updated";
|
||||
case "budget-changed":
|
||||
return "budget-changed";
|
||||
case "thread-wait-state-updated":
|
||||
return "thread-wait-state-updated";
|
||||
default: {
|
||||
const _exhaustive: never = event;
|
||||
return _exhaustive;
|
||||
|
||||
@@ -205,7 +205,7 @@ describe("task_sessions CRUD", () => {
|
||||
const run1 = db.createRun({ companyId, taskId, agentId, workspaceId, connectorId: "claude_local" });
|
||||
db.finishRun({
|
||||
runId: run1.id,
|
||||
status: "completed",
|
||||
status: "succeeded",
|
||||
summary: "First run done",
|
||||
errorMessage: null,
|
||||
exitCode: 0,
|
||||
@@ -237,7 +237,7 @@ describe("task_sessions CRUD", () => {
|
||||
expect(runs[0].status).toBe("failed");
|
||||
expect(runs[0].errorMessage).toBe("Timeout");
|
||||
expect(runs[1].id).toBe(run1.id);
|
||||
expect(runs[1].status).toBe("completed");
|
||||
expect(runs[1].status).toBe("succeeded");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -561,7 +561,7 @@ describe("session management service", () => {
|
||||
const run = db.createRun({ companyId, taskId, agentId, workspaceId, connectorId: "gemini_local" as ConnectorId });
|
||||
db.finishRun({
|
||||
runId: run.id,
|
||||
status: "completed",
|
||||
status: "succeeded",
|
||||
summary: "Implemented initial logic",
|
||||
errorMessage: null,
|
||||
exitCode: 0,
|
||||
@@ -582,7 +582,7 @@ describe("session management service", () => {
|
||||
expect(handoff).toContain("# Session Handoff");
|
||||
expect(handoff).toContain("Implement session rotation"); // task title
|
||||
expect(handoff).toContain("## Recent Run History");
|
||||
expect(handoff).toContain("completed");
|
||||
expect(handoff).toContain("succeeded");
|
||||
expect(handoff).toContain("15000 total tokens"); // 10000 + 5000
|
||||
});
|
||||
});
|
||||
@@ -736,7 +736,7 @@ describe("wiring integration", () => {
|
||||
function simulateRunFinishedSessionWiring(
|
||||
sessionService: ReturnType<typeof createMockSessionService>,
|
||||
db: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
||||
getActiveTaskSession: (...args: any[]) => any;
|
||||
},
|
||||
task: { id: string } | null,
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildConnectorSection } from "@main/prompt-builder/skill-injection";
|
||||
|
||||
describe("buildConnectorSection", () => {
|
||||
it("includes enabled model pools for ready connectors", () => {
|
||||
const section = buildConnectorSection({
|
||||
allConnectors: [
|
||||
{
|
||||
id: "codex_local",
|
||||
label: "Codex",
|
||||
status: "ready",
|
||||
enabledModels: ["gpt-5.4", "gpt-5.4-mini"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(section).toContain("Enabled models: gpt-5.4, gpt-5.4-mini.");
|
||||
expect(section).toContain("choose from the enabled model pool");
|
||||
});
|
||||
|
||||
it("does not render disabled models in the connector section", () => {
|
||||
const section = buildConnectorSection({
|
||||
allConnectors: [
|
||||
{
|
||||
id: "codex_local",
|
||||
label: "Codex",
|
||||
status: "ready",
|
||||
enabledModels: ["gpt-5.4"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(section).toContain("Enabled models: gpt-5.4.");
|
||||
expect(section).not.toContain("gpt-5.4-mini");
|
||||
});
|
||||
|
||||
it("omits ready connectors whose enabled pool is empty", () => {
|
||||
const section = buildConnectorSection({
|
||||
allConnectors: [
|
||||
{
|
||||
id: "codex_local",
|
||||
label: "Codex",
|
||||
status: "ready",
|
||||
enabledModels: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(section).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
TOKEN_BUDGETS,
|
||||
resolveTokenBudget,
|
||||
} from "@main/skill-types";
|
||||
import type {
|
||||
SkillSourceType,
|
||||
@@ -16,20 +17,36 @@ import type {
|
||||
|
||||
describe("skill-types", () => {
|
||||
describe("TOKEN_BUDGETS", () => {
|
||||
it("has entries for all five adapter types", () => {
|
||||
it("has entries for all supported adapter types", () => {
|
||||
expect(TOKEN_BUDGETS).toHaveProperty("claude_local");
|
||||
expect(TOKEN_BUDGETS).toHaveProperty("codex_local");
|
||||
expect(TOKEN_BUDGETS).toHaveProperty("gemini_local");
|
||||
expect(TOKEN_BUDGETS).toHaveProperty("opencode_local");
|
||||
expect(TOKEN_BUDGETS).toHaveProperty("kimi_local");
|
||||
expect(TOKEN_BUDGETS).toHaveProperty("claude_compatible");
|
||||
});
|
||||
|
||||
it("has numeric token caps for each adapter", () => {
|
||||
expect(TOKEN_BUDGETS.claude_local).toBe(50000);
|
||||
expect(TOKEN_BUDGETS.codex_local).toBe(30000);
|
||||
expect(TOKEN_BUDGETS.gemini_local).toBe(50000);
|
||||
expect(TOKEN_BUDGETS.opencode_local).toBe(30000);
|
||||
expect(TOKEN_BUDGETS.kimi_local).toBe(20000);
|
||||
expect(TOKEN_BUDGETS.claude_compatible).toBe(50000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveTokenBudget", () => {
|
||||
it("returns exact match for known adapter types", () => {
|
||||
expect(resolveTokenBudget("claude_local")).toBe(50000);
|
||||
expect(resolveTokenBudget("codex_local")).toBe(30000);
|
||||
expect(resolveTokenBudget("gemini_local")).toBe(50000);
|
||||
});
|
||||
|
||||
it("resolves claude_compat_* variants to claude_compatible budget", () => {
|
||||
expect(resolveTokenBudget("claude_compat_2")).toBe(50000);
|
||||
expect(resolveTokenBudget("claude_compat_10")).toBe(50000);
|
||||
expect(resolveTokenBudget("claude_compatible")).toBe(50000);
|
||||
});
|
||||
|
||||
it("returns 30000 default for unknown adapter types", () => {
|
||||
expect(resolveTokenBudget("unknown_adapter")).toBe(30000);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -105,14 +122,22 @@ describe("skill-types", () => {
|
||||
expect(result.isUpdate).toBe(false);
|
||||
});
|
||||
|
||||
it("RuntimeSkillEntry has runtimeName, sourcePath, name, tokenEstimate", () => {
|
||||
it("RuntimeSkillEntry has runtimeName, sourcePath, name, tokenEstimate and semantics", () => {
|
||||
const entry: RuntimeSkillEntry = {
|
||||
runtimeName: "claude",
|
||||
sourcePath: "/path/to/skill",
|
||||
name: "test-skill",
|
||||
tokenEstimate: 1000,
|
||||
allowedTools: ["read", "write"],
|
||||
hooks: ["on_task_start"],
|
||||
contextMode: "inline",
|
||||
isAgent: false,
|
||||
effort: "medium",
|
||||
whenToUse: "when writing code",
|
||||
};
|
||||
expect(entry.tokenEstimate).toBe(1000);
|
||||
expect(entry.allowedTools).toEqual(["read", "write"]);
|
||||
expect(entry.contextMode).toBe("inline");
|
||||
});
|
||||
|
||||
it("ManagedSkillRef has runtimeName and sourcePath only", () => {
|
||||
|
||||
@@ -272,9 +272,12 @@ const fakeTask = (id: string, title = "Task"): TaskRecord => ({
|
||||
sessionDisplayId: null,
|
||||
costUsd: 0,
|
||||
requiresUserReview: false,
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
cancelledAt: null,
|
||||
createdAt: "2026-01-01",
|
||||
updatedAt: "2026-01-01",
|
||||
} as TaskRecord);
|
||||
} as unknown as TaskRecord);
|
||||
|
||||
const fakeRun = (id: string): RunRecord => ({
|
||||
id,
|
||||
@@ -326,15 +329,17 @@ const fakeApproval = (id: string): ApprovalRecord => ({
|
||||
id,
|
||||
companyId: "company-1",
|
||||
relatedTaskId: "t-1",
|
||||
relatedAgentId: null,
|
||||
requestedByAgentId: "ag-1",
|
||||
type: "task_completion",
|
||||
payloadSummary: "",
|
||||
impactSummary: "",
|
||||
payloadJson: null,
|
||||
decisionNote: null,
|
||||
state: "pending",
|
||||
createdAt: "2026-01-01",
|
||||
updatedAt: "2026-01-01",
|
||||
} as ApprovalRecord);
|
||||
} as unknown as ApprovalRecord);
|
||||
|
||||
describe("CompanySlice patch methods", () => {
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
/**
|
||||
* Tests for non-code task deliverable enforcement in automation-service.ts.
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
REVIEW_TIMEOUT_MS,
|
||||
sweepStaleReviews,
|
||||
} from "../../src/main/task-quality-gates";
|
||||
import type { SweepStaleReviewsDeps } from "../../src/main/task-quality-gates";
|
||||
|
||||
describe("sweepStaleReviews", () => {
|
||||
const NOW = Date.now();
|
||||
const THIRTY_FIVE_MIN_AGO = new Date(NOW - 35 * 60 * 1000).toISOString();
|
||||
const TWENTY_FIVE_MIN_AGO = new Date(NOW - 25 * 60 * 1000).toISOString();
|
||||
|
||||
function makeSweepDeps(overrides: Partial<SweepStaleReviewsDeps> = {}): SweepStaleReviewsDeps {
|
||||
return {
|
||||
db: {
|
||||
queryAll: vi.fn().mockReturnValue([]),
|
||||
getManagerForAgent: vi.fn().mockReturnValue(null),
|
||||
getChainOfCommand: vi.fn().mockReturnValue([]),
|
||||
advanceTaskStatus: vi.fn().mockReturnValue({ previousStatus: "in_review" }),
|
||||
},
|
||||
getCompanyExecutionMode: vi.fn().mockReturnValue("ai_ceo"),
|
||||
dispatchAgentMessage: vi.fn().mockReturnValue({ ok: true }),
|
||||
resolveApprovalDecision: vi.fn(),
|
||||
notify: vi.fn(),
|
||||
handleTaskStatusChange: vi.fn(),
|
||||
handleApprovalResolved: vi.fn(),
|
||||
emitEvent: vi.fn(),
|
||||
publishDomainChanged: vi.fn(),
|
||||
wakeAgentIfPossible: vi.fn(),
|
||||
logger: { info: vi.fn(), warn: vi.fn() },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it("escalates to manager's manager when task in_review for 35 minutes", () => {
|
||||
const deps = makeSweepDeps();
|
||||
const dbMock = deps.db as unknown as {
|
||||
queryAll: ReturnType<typeof vi.fn>;
|
||||
getManagerForAgent: ReturnType<typeof vi.fn>;
|
||||
getChainOfCommand: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
dbMock.queryAll.mockReturnValueOnce([
|
||||
{ id: "task-1", assigneeAgentId: "agent-1", updatedAt: THIRTY_FIVE_MIN_AGO, title: "Build login" },
|
||||
]);
|
||||
dbMock.getManagerForAgent.mockReturnValueOnce({ id: "manager-1", name: "Manager One", companyId: "company-1" });
|
||||
dbMock.getChainOfCommand.mockReturnValueOnce([{ id: "higher-manager-1", name: "Higher Manager" }]);
|
||||
|
||||
const count = sweepStaleReviews("company-1", deps, NOW);
|
||||
expect(count).toBe(1);
|
||||
expect(deps.dispatchAgentMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ db: deps.db }),
|
||||
expect.objectContaining({
|
||||
companyId: "company-1",
|
||||
fromAgentId: "manager-1",
|
||||
toAgentId: "higher-manager-1",
|
||||
channel: "direct",
|
||||
priority: "urgent",
|
||||
}),
|
||||
"subtask_completed",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not escalate when task in_review for only 25 minutes", () => {
|
||||
const deps = makeSweepDeps();
|
||||
const dbMock = deps.db as unknown as { queryAll: ReturnType<typeof vi.fn> };
|
||||
|
||||
dbMock.queryAll.mockReturnValueOnce([
|
||||
{ id: "task-1", assigneeAgentId: "agent-1", updatedAt: TWENTY_FIVE_MIN_AGO, title: "Build login" },
|
||||
]);
|
||||
|
||||
const count = sweepStaleReviews("company-1", deps, NOW);
|
||||
expect(count).toBe(0);
|
||||
expect(deps.dispatchAgentMessage).not.toHaveBeenCalled();
|
||||
expect(deps.notify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("notifies the human when the reviewer has no higher manager", () => {
|
||||
const deps = makeSweepDeps();
|
||||
const dbMock = deps.db as unknown as {
|
||||
queryAll: ReturnType<typeof vi.fn>;
|
||||
getManagerForAgent: ReturnType<typeof vi.fn>;
|
||||
getChainOfCommand: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
dbMock.queryAll
|
||||
.mockReturnValueOnce([
|
||||
{ id: "task-1", assigneeAgentId: "agent-1", updatedAt: THIRTY_FIVE_MIN_AGO, title: "Build login" },
|
||||
])
|
||||
.mockReturnValueOnce([]);
|
||||
dbMock.getManagerForAgent.mockReturnValueOnce({ id: "ceo-1", name: "CEO", companyId: "company-1" });
|
||||
dbMock.getChainOfCommand.mockReturnValueOnce([]);
|
||||
|
||||
const count = sweepStaleReviews("company-1", deps, NOW);
|
||||
expect(count).toBe(1);
|
||||
expect(deps.notify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: "Review Timeout",
|
||||
urgency: "critical",
|
||||
batchKey: "review_escalation",
|
||||
navigation: expect.objectContaining({ section: "tasks", entityId: "task-1" }),
|
||||
}),
|
||||
);
|
||||
expect(deps.dispatchAgentMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("auto-approves pending approval linked to a stale review task", () => {
|
||||
const deps = makeSweepDeps();
|
||||
const dbMock = deps.db as unknown as {
|
||||
queryAll: ReturnType<typeof vi.fn>;
|
||||
getManagerForAgent: ReturnType<typeof vi.fn>;
|
||||
getChainOfCommand: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
dbMock.queryAll.mockReturnValueOnce([
|
||||
{ id: "task-1", assigneeAgentId: "agent-1", updatedAt: THIRTY_FIVE_MIN_AGO, title: "Build login" },
|
||||
]);
|
||||
dbMock.getManagerForAgent.mockReturnValueOnce({ id: "manager-1", name: "Manager", companyId: "company-1" });
|
||||
dbMock.getChainOfCommand.mockReturnValueOnce([{ id: "higher-1", name: "Higher" }]);
|
||||
dbMock.queryAll.mockReturnValueOnce([{ id: "approval-1", type: "deliverable_review" }]);
|
||||
|
||||
sweepStaleReviews("company-1", deps, NOW);
|
||||
|
||||
expect(deps.resolveApprovalDecision).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
db: deps.db,
|
||||
companyId: "company-1",
|
||||
approvalId: "approval-1",
|
||||
state: "approved",
|
||||
decisionNote: "Auto-approved: review timeout exceeded 30 minutes.",
|
||||
actor: "system",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("swallows resolveApprovalDecision races gracefully", () => {
|
||||
const deps = makeSweepDeps();
|
||||
const dbMock = deps.db as unknown as {
|
||||
queryAll: ReturnType<typeof vi.fn>;
|
||||
getManagerForAgent: ReturnType<typeof vi.fn>;
|
||||
getChainOfCommand: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
dbMock.queryAll.mockReturnValueOnce([
|
||||
{ id: "task-1", assigneeAgentId: "agent-1", updatedAt: THIRTY_FIVE_MIN_AGO, title: "Build login" },
|
||||
]);
|
||||
dbMock.getManagerForAgent.mockReturnValueOnce({ id: "manager-1", name: "Manager", companyId: "company-1" });
|
||||
dbMock.getChainOfCommand.mockReturnValueOnce([{ id: "higher-1", name: "Higher" }]);
|
||||
dbMock.queryAll.mockReturnValueOnce([{ id: "approval-1", type: "deliverable_review" }]);
|
||||
(deps.resolveApprovalDecision as ReturnType<typeof vi.fn>).mockImplementationOnce(() => {
|
||||
throw new Error("Approval already decided");
|
||||
});
|
||||
|
||||
expect(() => sweepStaleReviews("company-1", deps, NOW)).not.toThrow();
|
||||
});
|
||||
|
||||
it("advances task status to done after escalation", () => {
|
||||
const deps = makeSweepDeps();
|
||||
const dbMock = deps.db as unknown as {
|
||||
queryAll: ReturnType<typeof vi.fn>;
|
||||
getManagerForAgent: ReturnType<typeof vi.fn>;
|
||||
getChainOfCommand: ReturnType<typeof vi.fn>;
|
||||
advanceTaskStatus: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
dbMock.queryAll.mockReturnValueOnce([
|
||||
{ id: "task-1", assigneeAgentId: "agent-1", updatedAt: THIRTY_FIVE_MIN_AGO, title: "Build login" },
|
||||
]);
|
||||
dbMock.getManagerForAgent.mockReturnValueOnce({ id: "manager-1", name: "Manager", companyId: "company-1" });
|
||||
dbMock.getChainOfCommand.mockReturnValueOnce([{ id: "higher-1", name: "Higher" }]);
|
||||
dbMock.queryAll.mockReturnValueOnce([]);
|
||||
|
||||
sweepStaleReviews("company-1", deps, NOW);
|
||||
|
||||
expect(dbMock.advanceTaskStatus).toHaveBeenCalledWith(
|
||||
"task-1",
|
||||
"done",
|
||||
"Auto-advanced: review timeout exceeded 30 minutes.",
|
||||
);
|
||||
expect(deps.handleTaskStatusChange).toHaveBeenCalledWith("task-1", "company-1", "in_review", "done");
|
||||
});
|
||||
|
||||
it("skips tasks without an assignee", () => {
|
||||
const deps = makeSweepDeps();
|
||||
const dbMock = deps.db as unknown as { queryAll: ReturnType<typeof vi.fn> };
|
||||
|
||||
dbMock.queryAll.mockReturnValueOnce([
|
||||
{ id: "task-1", assigneeAgentId: null, updatedAt: THIRTY_FIVE_MIN_AGO, title: "Orphan task" },
|
||||
]);
|
||||
|
||||
const count = sweepStaleReviews("company-1", deps, NOW);
|
||||
expect(count).toBe(0);
|
||||
expect(deps.dispatchAgentMessage).not.toHaveBeenCalled();
|
||||
expect(deps.notify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("exports REVIEW_TIMEOUT_MS as 30 minutes in milliseconds", () => {
|
||||
expect(REVIEW_TIMEOUT_MS).toBe(30 * 60 * 1000);
|
||||
});
|
||||
|
||||
it("does not remind before the cofounder review timeout threshold", () => {
|
||||
const deps = makeSweepDeps({
|
||||
getCompanyExecutionMode: vi.fn().mockReturnValue("ai_cofounder"),
|
||||
});
|
||||
const dbMock = deps.db as unknown as { queryAll: ReturnType<typeof vi.fn> };
|
||||
|
||||
dbMock.queryAll.mockReturnValueOnce([
|
||||
{ id: "task-1", assigneeAgentId: "agent-1", updatedAt: TWENTY_FIVE_MIN_AGO, title: "Review me" },
|
||||
]);
|
||||
|
||||
const count = sweepStaleReviews("company-1", deps, NOW);
|
||||
expect(count).toBe(0);
|
||||
expect(deps.notify).not.toHaveBeenCalled();
|
||||
expect(deps.resolveApprovalDecision).not.toHaveBeenCalled();
|
||||
expect(deps.dispatchAgentMessage).not.toHaveBeenCalled();
|
||||
expect(deps.db.advanceTaskStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends a critical cofounder review reminder without auto-approving", () => {
|
||||
const deps = makeSweepDeps({
|
||||
getCompanyExecutionMode: vi.fn().mockReturnValue("ai_cofounder"),
|
||||
});
|
||||
const dbMock = deps.db as unknown as {
|
||||
queryAll: ReturnType<typeof vi.fn>;
|
||||
getManagerForAgent: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
dbMock.queryAll
|
||||
.mockReturnValueOnce([
|
||||
{ id: "task-1", assigneeAgentId: "agent-1", updatedAt: THIRTY_FIVE_MIN_AGO, title: "Review me" },
|
||||
])
|
||||
.mockReturnValueOnce([{ id: "approval-1", type: "deliverable_review" }]);
|
||||
dbMock.getManagerForAgent.mockReturnValueOnce({ id: "manager-1", name: "Manager", companyId: "company-1" });
|
||||
|
||||
const count = sweepStaleReviews("company-1", deps, NOW);
|
||||
expect(count).toBe(0);
|
||||
expect(deps.notify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: "Your Review Required",
|
||||
urgency: "critical",
|
||||
batchKey: "cofounder_review_needed",
|
||||
navigation: expect.objectContaining({ section: "approvals", entityId: "approval-1" }),
|
||||
}),
|
||||
);
|
||||
expect(deps.resolveApprovalDecision).not.toHaveBeenCalled();
|
||||
expect(deps.dispatchAgentMessage).not.toHaveBeenCalled();
|
||||
expect(deps.db.advanceTaskStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,11 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
validateTaskCreation,
|
||||
checkAcceptanceCriteria,
|
||||
checkCapabilityMatch,
|
||||
ACCEPTANCE_CRITERIA_KEYWORDS,
|
||||
sweepStaleReviews,
|
||||
REVIEW_TIMEOUT_MS,
|
||||
} from "../../src/main/task-quality-gates";
|
||||
import type {
|
||||
TaskValidationResult,
|
||||
SweepStaleReviewsDeps,
|
||||
} from "../../src/main/task-quality-gates";
|
||||
import type { TaskValidationResult as _TaskValidationResult } from "../../src/main/task-quality-gates";
|
||||
|
||||
// --- TASK-01: validateTaskCreation tests ---
|
||||
|
||||
@@ -183,10 +178,11 @@ describe("checkCapabilityMatch", () => {
|
||||
expect(result.warning).toContain("Data Analyst");
|
||||
});
|
||||
|
||||
it("returns matched=true when agent has empty capabilities and no skills (graceful degradation)", () => {
|
||||
it("returns matched=false with warning when agent has empty capabilities and no skills", () => {
|
||||
const db = makeMockDb({ name: "New Agent", capabilities: "" });
|
||||
const result = checkCapabilityMatch(db, "agent-1", "Build React login form", "company-1");
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.warning).toContain("New Agent");
|
||||
});
|
||||
|
||||
it("returns matched=true when agent has no capabilities but skill name overlaps with task", () => {
|
||||
@@ -206,186 +202,3 @@ describe("checkCapabilityMatch", () => {
|
||||
expect(result.matched).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// --- TASK-04: sweepStaleReviews tests ---
|
||||
|
||||
describe("sweepStaleReviews", () => {
|
||||
const NOW = Date.now();
|
||||
const THIRTY_FIVE_MIN_AGO = new Date(NOW - 35 * 60 * 1000).toISOString();
|
||||
const TWENTY_FIVE_MIN_AGO = new Date(NOW - 25 * 60 * 1000).toISOString();
|
||||
|
||||
function makeSweepDeps(overrides: Partial<SweepStaleReviewsDeps> = {}): SweepStaleReviewsDeps {
|
||||
return {
|
||||
db: {
|
||||
queryAll: vi.fn().mockReturnValue([]),
|
||||
getManagerForAgent: vi.fn().mockReturnValue(null),
|
||||
getChainOfCommand: vi.fn().mockReturnValue([]),
|
||||
advanceTaskStatus: vi.fn().mockReturnValue({ previousStatus: "in_review" }),
|
||||
},
|
||||
dispatchAgentMessage: vi.fn().mockReturnValue({ ok: true }),
|
||||
resolveApprovalDecision: vi.fn(),
|
||||
notify: vi.fn(),
|
||||
handleTaskStatusChange: vi.fn(),
|
||||
handleApprovalResolved: vi.fn(),
|
||||
emitEvent: vi.fn(),
|
||||
publishDomainChanged: vi.fn(),
|
||||
wakeAgentIfPossible: vi.fn(),
|
||||
logger: { info: vi.fn(), warn: vi.fn() },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it("escalates to manager's manager when task in_review for 35 minutes", () => {
|
||||
const deps = makeSweepDeps();
|
||||
const dbMock = deps.db as unknown as { queryAll: ReturnType<typeof vi.fn>; getManagerForAgent: ReturnType<typeof vi.fn>; getChainOfCommand: ReturnType<typeof vi.fn> };
|
||||
|
||||
// Task in review for 35 min
|
||||
dbMock.queryAll.mockReturnValueOnce([
|
||||
{ id: "task-1", assigneeAgentId: "agent-1", updatedAt: THIRTY_FIVE_MIN_AGO, title: "Build login" },
|
||||
]);
|
||||
// Direct manager of the assignee (the reviewer)
|
||||
dbMock.getManagerForAgent.mockReturnValueOnce({ id: "manager-1", name: "Manager One", companyId: "company-1" });
|
||||
// Chain of command for the reviewer — the reviewer's manager (escalation target)
|
||||
dbMock.getChainOfCommand.mockReturnValueOnce([{ id: "higher-manager-1", name: "Higher Manager" }]);
|
||||
|
||||
const count = sweepStaleReviews("company-1", deps, NOW);
|
||||
expect(count).toBe(1);
|
||||
expect(deps.dispatchAgentMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ db: deps.db }),
|
||||
expect.objectContaining({
|
||||
companyId: "company-1",
|
||||
fromAgentId: "manager-1",
|
||||
toAgentId: "higher-manager-1",
|
||||
channel: "direct",
|
||||
priority: "urgent",
|
||||
}),
|
||||
"subtask_completed",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not escalate when task in_review for only 25 minutes (under threshold)", () => {
|
||||
const deps = makeSweepDeps();
|
||||
const dbMock = deps.db as unknown as { queryAll: ReturnType<typeof vi.fn> };
|
||||
|
||||
dbMock.queryAll.mockReturnValueOnce([
|
||||
{ id: "task-1", assigneeAgentId: "agent-1", updatedAt: TWENTY_FIVE_MIN_AGO, title: "Build login" },
|
||||
]);
|
||||
|
||||
const count = sweepStaleReviews("company-1", deps, NOW);
|
||||
expect(count).toBe(0);
|
||||
expect(deps.dispatchAgentMessage).not.toHaveBeenCalled();
|
||||
expect(deps.notify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("notifies human when no higher manager (reviewer is CEO)", () => {
|
||||
const deps = makeSweepDeps();
|
||||
const dbMock = deps.db as unknown as { queryAll: ReturnType<typeof vi.fn>; getManagerForAgent: ReturnType<typeof vi.fn>; getChainOfCommand: ReturnType<typeof vi.fn> };
|
||||
|
||||
dbMock.queryAll
|
||||
.mockReturnValueOnce([
|
||||
{ id: "task-1", assigneeAgentId: "agent-1", updatedAt: THIRTY_FIVE_MIN_AGO, title: "Build login" },
|
||||
])
|
||||
.mockReturnValueOnce([]); // no pending approvals
|
||||
dbMock.getManagerForAgent.mockReturnValueOnce({ id: "ceo-1", name: "CEO", companyId: "company-1" });
|
||||
// Empty chain means reviewer is the top of hierarchy
|
||||
dbMock.getChainOfCommand.mockReturnValueOnce([]);
|
||||
|
||||
const count = sweepStaleReviews("company-1", deps, NOW);
|
||||
expect(count).toBe(1);
|
||||
expect(deps.notify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: "Review Timeout",
|
||||
urgency: "critical",
|
||||
batchKey: "review_escalation",
|
||||
navigation: expect.objectContaining({ section: "tasks", entityId: "task-1" }),
|
||||
}),
|
||||
);
|
||||
expect(deps.dispatchAgentMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("auto-approves pending approval linked to stale review task", () => {
|
||||
const deps = makeSweepDeps();
|
||||
const dbMock = deps.db as unknown as { queryAll: ReturnType<typeof vi.fn>; getManagerForAgent: ReturnType<typeof vi.fn>; getChainOfCommand: ReturnType<typeof vi.fn> };
|
||||
|
||||
// First queryAll: stale task
|
||||
dbMock.queryAll.mockReturnValueOnce([
|
||||
{ id: "task-1", assigneeAgentId: "agent-1", updatedAt: THIRTY_FIVE_MIN_AGO, title: "Build login" },
|
||||
]);
|
||||
dbMock.getManagerForAgent.mockReturnValueOnce({ id: "manager-1", name: "Manager", companyId: "company-1" });
|
||||
dbMock.getChainOfCommand.mockReturnValueOnce([{ id: "higher-1", name: "Higher" }]);
|
||||
// Second queryAll: pending approval
|
||||
dbMock.queryAll.mockReturnValueOnce([{ id: "approval-1" }]);
|
||||
|
||||
sweepStaleReviews("company-1", deps, NOW);
|
||||
|
||||
expect(deps.resolveApprovalDecision).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
db: deps.db,
|
||||
companyId: "company-1",
|
||||
approvalId: "approval-1",
|
||||
state: "approved",
|
||||
decisionNote: "Auto-approved: review timeout exceeded 30 minutes.",
|
||||
actor: "system",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("catches resolveApprovalDecision error gracefully (race condition)", () => {
|
||||
const deps = makeSweepDeps();
|
||||
const dbMock = deps.db as unknown as { queryAll: ReturnType<typeof vi.fn>; getManagerForAgent: ReturnType<typeof vi.fn>; getChainOfCommand: ReturnType<typeof vi.fn> };
|
||||
|
||||
dbMock.queryAll.mockReturnValueOnce([
|
||||
{ id: "task-1", assigneeAgentId: "agent-1", updatedAt: THIRTY_FIVE_MIN_AGO, title: "Build login" },
|
||||
]);
|
||||
dbMock.getManagerForAgent.mockReturnValueOnce({ id: "manager-1", name: "Manager", companyId: "company-1" });
|
||||
dbMock.getChainOfCommand.mockReturnValueOnce([{ id: "higher-1", name: "Higher" }]);
|
||||
dbMock.queryAll.mockReturnValueOnce([{ id: "approval-1" }]);
|
||||
|
||||
// resolveApprovalDecision throws (already decided)
|
||||
(deps.resolveApprovalDecision as ReturnType<typeof vi.fn>).mockImplementationOnce(() => {
|
||||
throw new Error("Approval already decided");
|
||||
});
|
||||
|
||||
// Should not throw
|
||||
expect(() => sweepStaleReviews("company-1", deps, NOW)).not.toThrow();
|
||||
});
|
||||
|
||||
it("advances task status to done after escalation", () => {
|
||||
const deps = makeSweepDeps();
|
||||
const dbMock = deps.db as unknown as { queryAll: ReturnType<typeof vi.fn>; getManagerForAgent: ReturnType<typeof vi.fn>; getChainOfCommand: ReturnType<typeof vi.fn>; advanceTaskStatus: ReturnType<typeof vi.fn> };
|
||||
|
||||
dbMock.queryAll.mockReturnValueOnce([
|
||||
{ id: "task-1", assigneeAgentId: "agent-1", updatedAt: THIRTY_FIVE_MIN_AGO, title: "Build login" },
|
||||
]);
|
||||
dbMock.getManagerForAgent.mockReturnValueOnce({ id: "manager-1", name: "Manager", companyId: "company-1" });
|
||||
dbMock.getChainOfCommand.mockReturnValueOnce([{ id: "higher-1", name: "Higher" }]);
|
||||
dbMock.queryAll.mockReturnValueOnce([]); // no approvals
|
||||
|
||||
sweepStaleReviews("company-1", deps, NOW);
|
||||
|
||||
expect(dbMock.advanceTaskStatus).toHaveBeenCalledWith(
|
||||
"task-1",
|
||||
"done",
|
||||
"Auto-advanced: review timeout exceeded 30 minutes.",
|
||||
);
|
||||
expect(deps.handleTaskStatusChange).toHaveBeenCalledWith("task-1", "company-1", "in_review", "done");
|
||||
});
|
||||
|
||||
it("skips task with no assigneeAgentId", () => {
|
||||
const deps = makeSweepDeps();
|
||||
const dbMock = deps.db as unknown as { queryAll: ReturnType<typeof vi.fn> };
|
||||
|
||||
dbMock.queryAll.mockReturnValueOnce([
|
||||
{ id: "task-1", assigneeAgentId: null, updatedAt: THIRTY_FIVE_MIN_AGO, title: "Orphan task" },
|
||||
]);
|
||||
|
||||
const count = sweepStaleReviews("company-1", deps, NOW);
|
||||
expect(count).toBe(0);
|
||||
expect(deps.dispatchAgentMessage).not.toHaveBeenCalled();
|
||||
expect(deps.notify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("exports REVIEW_TIMEOUT_MS as 30 minutes in milliseconds", () => {
|
||||
expect(REVIEW_TIMEOUT_MS).toBe(30 * 60 * 1000);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user