update
This commit is contained in:
1 parent
66158e222e
commit
67c757f481
314 files changed
+37945
-22962
No files matched your search
+136
-56
@@ -5,7 +5,33 @@ import { tmpdir } from "node:os";
|
||||
import { _electron as electron, expect, test } from "@playwright/test";
|
||||
import { getElectronExecutablePath, getMainEntryPath } from "./launch";
|
||||
|
||||
test("first launch onboarding creates a company and opens the desktop shell", async () => {
|
||||
function buildLaunchEnv(profileDir: string) {
|
||||
const env: Record<string, string | undefined> = {
|
||||
...process.env,
|
||||
AGENTCOMPANY_PROFILE_DIR: profileDir,
|
||||
AGENTCOMPANY_DISABLE_UPDATES: "true",
|
||||
AGENTCOMPANY_DISABLE_SINGLE_INSTANCE: "true",
|
||||
};
|
||||
delete env.NO_COLOR;
|
||||
return Object.fromEntries(
|
||||
Object.entries(env).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
|
||||
);
|
||||
}
|
||||
|
||||
async function advanceFromWelcome(page: import("@playwright/test").Page) {
|
||||
await expect(page.getByText("AgentCompany")).toBeVisible();
|
||||
await expect(page.getByText("Build your AI company here.")).toBeVisible();
|
||||
await page.getByRole("button", { name: /Get Started/i }).click();
|
||||
}
|
||||
|
||||
async function advancePastNodeCheck(page: import("@playwright/test").Page) {
|
||||
const continueButton = page.getByRole("button", { name: /^Continue$/i });
|
||||
await expect(page.getByText("Check Node.js")).toBeVisible();
|
||||
await expect(continueButton).toBeEnabled({ timeout: 15_000 });
|
||||
await continueButton.click();
|
||||
}
|
||||
|
||||
test("first launch onboarding advances into runtime setup and shell-ready flow", async () => {
|
||||
const entry = getMainEntryPath();
|
||||
expect(existsSync(entry)).toBeTruthy();
|
||||
|
||||
@@ -16,46 +42,38 @@ test("first launch onboarding creates a company and opens the desktop shell", as
|
||||
executablePath: getElectronExecutablePath(),
|
||||
args: [entry],
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
AGENTCOMPANY_PROFILE_DIR: profileDir,
|
||||
AGENTCOMPANY_DISABLE_UPDATES: "true",
|
||||
AGENTCOMPANY_DISABLE_SINGLE_INSTANCE: "true",
|
||||
},
|
||||
env: buildLaunchEnv(profileDir),
|
||||
});
|
||||
|
||||
try {
|
||||
const page = await app.firstWindow();
|
||||
await expect(page.getByText("AgentCompany Desktop")).toBeVisible();
|
||||
await advanceFromWelcome(page);
|
||||
await advancePastNodeCheck(page);
|
||||
|
||||
await expect(page.getByText("Set up runtimes")).toBeVisible();
|
||||
|
||||
const providersContinue = page.getByRole("button", { name: /^Continue$/i });
|
||||
if (!(await providersContinue.isEnabled())) {
|
||||
await expect(page.getByText(/Set up one runtime to continue\./i)).toBeVisible();
|
||||
await expect(page.getByText(/Ready|Login required|Not installed|Checking|Error/i).first()).toBeVisible();
|
||||
return;
|
||||
}
|
||||
|
||||
await providersContinue.click();
|
||||
await expect(page.getByLabel("Company name")).toBeVisible();
|
||||
await page.getByLabel("Company name").fill("Orbit Foundry");
|
||||
await page.getByLabel("Workspace folder").fill(workspaceDir);
|
||||
const continueButton = page.getByRole("button", { name: /^Continue$/i });
|
||||
if (await continueButton.isEnabled()) {
|
||||
await continueButton.click();
|
||||
await page.getByLabel("Goal").fill("Launch the company");
|
||||
await page.getByRole("button", { name: /Launch Company/i }).click();
|
||||
await expect(page.getByRole("heading", { name: /Orbit Foundry is live/i })).toBeVisible();
|
||||
await expect(page.getByText("Initial run")).toBeVisible();
|
||||
await page.getByRole("button", { name: /Open control center/i }).click();
|
||||
await page.getByRole("button", { name: /^Continue$/i }).click();
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Runs" })).toBeVisible();
|
||||
// Company name is rendered in a custom dropdown trigger button in the sidebar.
|
||||
await expect(page.locator("aside button", { hasText: "Orbit Foundry" }).first()).toBeVisible();
|
||||
const showConsoleButton = page.getByRole("button", { name: /show console/i });
|
||||
const hideConsoleButton = page.getByRole("button", { name: /hide console/i });
|
||||
if (await hideConsoleButton.isVisible().catch(() => false)) {
|
||||
await expect(page.getByText("Console", { exact: true })).toBeVisible();
|
||||
await hideConsoleButton.click();
|
||||
await expect(page.getByText("Console", { exact: true })).toBeHidden();
|
||||
} else {
|
||||
await expect(showConsoleButton).toBeVisible();
|
||||
}
|
||||
await showConsoleButton.click();
|
||||
await expect(page.getByText("Console", { exact: true })).toBeVisible();
|
||||
} else {
|
||||
await expect(page.getByText(/Install, authenticate, and fully validate at least one supported CLI runtime/i)).toBeVisible();
|
||||
await expect(page.getByText(/cannot be used for autonomous execution/i).first()).toBeVisible();
|
||||
}
|
||||
await expect(page.getByText("How should your company operate?")).toBeVisible();
|
||||
await page.getByRole("button", { name: /^Continue$/i }).click();
|
||||
|
||||
await expect(page.getByLabel("Goal")).toBeVisible();
|
||||
await page.getByLabel("Goal").fill("Launch the company");
|
||||
await page.getByRole("button", { name: /Launch Company/i }).click();
|
||||
|
||||
await expect(page.getByRole("heading", { name: /Orbit Foundry.*is live/i })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: /Open Control Center/i })).toBeVisible();
|
||||
} finally {
|
||||
await app.close();
|
||||
await rm(profileDir, { recursive: true, force: true });
|
||||
@@ -63,8 +81,6 @@ test("first launch onboarding creates a company and opens the desktop shell", as
|
||||
}
|
||||
});
|
||||
|
||||
// NOTE: On headless Linux (CI), run via: xvfb-run pnpm test:e2e
|
||||
// See RESEARCH.md Pitfall 5 for details.
|
||||
test("platform launch: app opens, window renders, screenshot captured", async () => {
|
||||
const entry = getMainEntryPath();
|
||||
expect(existsSync(entry)).toBeTruthy();
|
||||
@@ -77,41 +93,105 @@ test("platform launch: app opens, window renders, screenshot captured", async ()
|
||||
executablePath: getElectronExecutablePath(),
|
||||
args: [entry],
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
AGENTCOMPANY_PROFILE_DIR: profileDir,
|
||||
AGENTCOMPANY_DISABLE_UPDATES: "true",
|
||||
AGENTCOMPANY_DISABLE_SINGLE_INSTANCE: "true",
|
||||
},
|
||||
env: buildLaunchEnv(profileDir),
|
||||
});
|
||||
|
||||
try {
|
||||
const page = await app.firstWindow();
|
||||
await expect(page.getByText("AgentCompany")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByText("Build your AI company here.")).toBeVisible();
|
||||
|
||||
// Verify the window appeared and has content
|
||||
await expect(page.getByText("AgentCompany Desktop")).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Verify the window has reasonable dimensions (not collapsed or zero-size)
|
||||
const viewport = page.viewportSize();
|
||||
expect(viewport).not.toBeNull();
|
||||
if (viewport) {
|
||||
expect(viewport.width).toBeGreaterThan(400);
|
||||
expect(viewport.height).toBeGreaterThan(300);
|
||||
}
|
||||
const dimensions = viewport ?? await page.evaluate(() => ({
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
}));
|
||||
expect(dimensions.width).toBeGreaterThan(400);
|
||||
expect(dimensions.height).toBeGreaterThan(300);
|
||||
|
||||
// Capture screenshot for D-07 validation evidence
|
||||
const platformName = process.platform === "darwin" ? "macos"
|
||||
: process.platform === "win32" ? "windows"
|
||||
: "linux";
|
||||
const screenshotPath = join(screenshotDir, `launch-${platformName}.png`);
|
||||
await page.screenshot({ path: screenshotPath, fullPage: true });
|
||||
|
||||
// Verify screenshot was actually written
|
||||
expect(existsSync(screenshotPath)).toBeTruthy();
|
||||
|
||||
console.log(`[platform-launch] Screenshot saved: ${screenshotPath}`);
|
||||
} finally {
|
||||
await app.close();
|
||||
await rm(profileDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("onboarding model pool persists across relaunch for a ready connector", async () => {
|
||||
const entry = getMainEntryPath();
|
||||
expect(existsSync(entry)).toBeTruthy();
|
||||
|
||||
const profileDir = await mkdtemp(join(tmpdir(), "agentcompany-model-pool-"));
|
||||
const workspaceDir = await mkdtemp(join(tmpdir(), "agentcompany-model-pool-workspace-"));
|
||||
|
||||
const launchApp = async () => electron.launch({
|
||||
executablePath: getElectronExecutablePath(),
|
||||
args: [entry],
|
||||
cwd: process.cwd(),
|
||||
env: buildLaunchEnv(profileDir),
|
||||
});
|
||||
|
||||
let app = await launchApp();
|
||||
|
||||
try {
|
||||
const page = await app.firstWindow();
|
||||
await advanceFromWelcome(page);
|
||||
await advancePastNodeCheck(page);
|
||||
|
||||
await expect(page.getByText("Set up runtimes")).toBeVisible();
|
||||
const providersContinue = page.getByRole("button", { name: /^Continue$/i });
|
||||
if (!(await providersContinue.isEnabled())) {
|
||||
await expect(page.getByText(/Set up one runtime to continue\./i)).toBeVisible();
|
||||
return;
|
||||
}
|
||||
await providersContinue.click();
|
||||
|
||||
await expect(page.getByLabel("Company name")).toBeVisible();
|
||||
const enabledCountLabel = page.getByText(/Models \(\d+\/\d+ enabled\)/i);
|
||||
await expect(enabledCountLabel).toBeVisible();
|
||||
const initialCount = await enabledCountLabel.textContent();
|
||||
|
||||
const switches = page.getByRole("switch");
|
||||
const switchCount = await switches.count();
|
||||
if (switchCount < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
await switches.nth(1).click();
|
||||
const updatedCount = await enabledCountLabel.textContent();
|
||||
expect(updatedCount).not.toBe(initialCount);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
|
||||
app = await launchApp();
|
||||
|
||||
try {
|
||||
const page = await app.firstWindow();
|
||||
await advanceFromWelcome(page);
|
||||
await advancePastNodeCheck(page);
|
||||
const providersContinue = page.getByRole("button", { name: /^Continue$/i });
|
||||
if (!(await providersContinue.isEnabled())) {
|
||||
await expect(page.getByText(/Set up at least one provider to power your agents\./i)).toBeVisible();
|
||||
return;
|
||||
}
|
||||
await providersContinue.click();
|
||||
|
||||
await expect(page.getByLabel("Company name")).toBeVisible();
|
||||
const enabledCountLabel = page.getByText(/Models \(\d+\/\d+ enabled\)/i);
|
||||
await expect(enabledCountLabel).toBeVisible();
|
||||
|
||||
await page.getByLabel("Company name").fill("Persisted Model Pool Co");
|
||||
await page.getByLabel("Workspace folder").fill(workspaceDir);
|
||||
await page.getByRole("button", { name: /^Continue$/i }).click();
|
||||
await expect(page.getByText("How should your company operate?")).toBeVisible();
|
||||
} finally {
|
||||
await app.close();
|
||||
await rm(profileDir, { recursive: true, force: true });
|
||||
await rm(workspaceDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { createElectronLaunchEnv } from "./launch";
|
||||
|
||||
/**
|
||||
* Playwright globalSetup: build the Electron app from source before E2E tests.
|
||||
@@ -9,6 +10,7 @@ export default function globalSetup() {
|
||||
stdio: "inherit",
|
||||
cwd: process.cwd(),
|
||||
timeout: 120_000,
|
||||
env: createElectronLaunchEnv({}),
|
||||
shell: process.platform === "win32",
|
||||
});
|
||||
}
|
||||
+36
-2
@@ -1,12 +1,46 @@
|
||||
import { createRequire } from "node:module";
|
||||
import { resolve } from "node:path";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||
|
||||
const EXPERIMENTAL_WARNING_FLAG = "--disable-warning=ExperimentalWarning";
|
||||
|
||||
export function getElectronExecutablePath() {
|
||||
return require("electron");
|
||||
}
|
||||
|
||||
export function getMainEntryPath() {
|
||||
return resolve(process.cwd(), "out/main/index.js");
|
||||
return resolve(repoRoot, "out/main/index.js");
|
||||
}
|
||||
|
||||
export function createElectronLaunchEnv(
|
||||
overrides: Record<string, string>,
|
||||
) : Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (typeof value === "string") {
|
||||
env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
Object.assign(env, overrides);
|
||||
|
||||
if (env.FORCE_COLOR && env.NO_COLOR) {
|
||||
delete env.NO_COLOR;
|
||||
}
|
||||
|
||||
const existingNodeOptions = env.NODE_OPTIONS?.trim();
|
||||
if (!existingNodeOptions) {
|
||||
env.NODE_OPTIONS = EXPERIMENTAL_WARNING_FLAG;
|
||||
return env;
|
||||
}
|
||||
|
||||
if (!existingNodeOptions.includes(EXPERIMENTAL_WARNING_FLAG)) {
|
||||
env.NODE_OPTIONS = `${existingNodeOptions} ${EXPERIMENTAL_WARNING_FLAG}`;
|
||||
}
|
||||
|
||||
return env;
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
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 { createBudgetEnforcementService } from "../../src/main/budget-enforcement-service";
|
||||
import { createRunDispatch } from "../../src/main/orchestration/run-dispatch";
|
||||
import { createTaskRunner } from "../../src/main/orchestration/task-runner";
|
||||
|
||||
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 })));
|
||||
});
|
||||
|
||||
const logger = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
|
||||
function createHarness(db: AppDatabase) {
|
||||
const enqueueWorkerRun = vi.fn();
|
||||
const budgetService = createBudgetEnforcementService({
|
||||
db,
|
||||
logger,
|
||||
notify: vi.fn(),
|
||||
publishDomainChanged: vi.fn(),
|
||||
});
|
||||
|
||||
const deps = {
|
||||
db,
|
||||
logger,
|
||||
publishDomainChanged: vi.fn(),
|
||||
emitEvent: vi.fn(),
|
||||
notify: vi.fn(),
|
||||
createRunToken: () => "token",
|
||||
getApiPort: () => 3210,
|
||||
dispatchAutomation: vi.fn(),
|
||||
hasWorkerReady: () => true,
|
||||
enqueueWorkerRun,
|
||||
isQuitting: () => false,
|
||||
listRuntimeSkillEntries: () => [],
|
||||
budgetService,
|
||||
checkpointEngine: { shouldPauseHeartbeat: () => false },
|
||||
};
|
||||
|
||||
const runDispatch = createRunDispatch(deps);
|
||||
const taskRunner = createTaskRunner(
|
||||
deps,
|
||||
runDispatch.resolveWorkspace,
|
||||
runDispatch.getRunEligibilityFailure,
|
||||
runDispatch.dispatchRunToWorker,
|
||||
(filter) => db.hasActiveRun(filter),
|
||||
);
|
||||
|
||||
return { enqueueWorkerRun, taskRunner };
|
||||
}
|
||||
|
||||
describe("agent model switching", () => {
|
||||
it("uses the agent's updated model for subsequent runtime dispatches", async () => {
|
||||
const profileDir = await createTempDir("agentcompany-model-switching-");
|
||||
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: "Model Switch Co",
|
||||
description: "Tests post-hire model switching",
|
||||
status: "active",
|
||||
});
|
||||
const workspaceId = db.saveWorkspace({
|
||||
companyId,
|
||||
name: "Primary",
|
||||
localPath: profileDir,
|
||||
repoUrl: "",
|
||||
repoRef: "",
|
||||
isPrimary: true,
|
||||
});
|
||||
const agentId = db.saveAgent({
|
||||
companyId,
|
||||
name: "Engineer",
|
||||
role: "Engineer",
|
||||
title: "Frontend Engineer",
|
||||
status: "idle",
|
||||
reportsTo: null,
|
||||
connectorId: "codex_local",
|
||||
workspaceId,
|
||||
model: "gpt-5.4",
|
||||
capabilities: "Build frontend features",
|
||||
budgetMonthlyUsd: 200,
|
||||
});
|
||||
|
||||
const { enqueueWorkerRun, taskRunner } = createHarness(db);
|
||||
|
||||
const firstTaskId = db.saveTask({
|
||||
companyId,
|
||||
title: "Initial model task",
|
||||
description: "Use the first model for this run.",
|
||||
assigneeAgentId: agentId,
|
||||
workspaceId,
|
||||
priority: "high",
|
||||
status: "todo",
|
||||
});
|
||||
|
||||
const firstRun = taskRunner.queueTaskRun(firstTaskId, "manual", "board");
|
||||
expect(firstRun.ok).toBe(true);
|
||||
expect(enqueueWorkerRun).toHaveBeenCalledTimes(1);
|
||||
expect(enqueueWorkerRun.mock.calls[0]?.[0]?.model).toBe("gpt-5.4");
|
||||
|
||||
if (!firstRun.ok) {
|
||||
throw new Error("Expected first run to queue successfully.");
|
||||
}
|
||||
|
||||
db.finishRun({
|
||||
runId: firstRun.data,
|
||||
status: "succeeded",
|
||||
summary: "Done",
|
||||
errorMessage: null,
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
model: "gpt-5.4",
|
||||
sessionDisplayId: null,
|
||||
costUsd: null,
|
||||
unattributedCost: true,
|
||||
});
|
||||
|
||||
db.saveAgent({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "Engineer",
|
||||
role: "Engineer",
|
||||
title: "Frontend Engineer",
|
||||
status: "idle",
|
||||
reportsTo: null,
|
||||
connectorId: "codex_local",
|
||||
workspaceId,
|
||||
model: "gpt-5.3-codex",
|
||||
capabilities: "Build frontend features",
|
||||
budgetMonthlyUsd: 200,
|
||||
});
|
||||
|
||||
const secondTaskId = db.saveTask({
|
||||
companyId,
|
||||
title: "Updated model task",
|
||||
description: "Use the new model for this run.",
|
||||
assigneeAgentId: agentId,
|
||||
workspaceId,
|
||||
priority: "high",
|
||||
status: "todo",
|
||||
});
|
||||
|
||||
const secondRun = taskRunner.queueTaskRun(secondTaskId, "manual", "board");
|
||||
expect(secondRun.ok).toBe(true);
|
||||
expect(enqueueWorkerRun).toHaveBeenCalledTimes(2);
|
||||
expect(enqueueWorkerRun.mock.calls[1]?.[0]?.model).toBe("gpt-5.3-codex");
|
||||
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
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 { createAgentToken, initializeAgentTokenSigningKey } from "../../src/main/agent-auth";
|
||||
import { createApiServer } from "../../src/main/api-server";
|
||||
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 })));
|
||||
});
|
||||
|
||||
async function createHarness() {
|
||||
const profileDir = await createTempDir("agentcompany-recursive-org-");
|
||||
initializeAgentTokenSigningKey(profileDir);
|
||||
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: "Recursive Org Co",
|
||||
description: "Tests manager-driven recursive hires",
|
||||
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 taskId = db.saveTask({
|
||||
companyId,
|
||||
title: "Build the org",
|
||||
description: "CTO should hire the first IC under engineering.",
|
||||
assigneeAgentId: ctoId,
|
||||
workspaceId,
|
||||
priority: "high",
|
||||
status: "in_progress",
|
||||
});
|
||||
const run = db.createRun({ companyId, taskId, agentId: ctoId, workspaceId, connectorId: "codex_local" });
|
||||
const token = createAgentToken(ctoId, companyId, run.id);
|
||||
const server = createApiServer(db, () => {}, () => null);
|
||||
const port = await server.start();
|
||||
|
||||
return {
|
||||
db,
|
||||
server,
|
||||
companyId,
|
||||
workspaceId,
|
||||
ceoId,
|
||||
ctoId,
|
||||
baseUrl: `http://127.0.0.1:${port}`,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"X-Agent-Company-Run-Id": run.id,
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("recursive org execution", () => {
|
||||
it("manager can request a hire under themselves and the hierarchy reflects it after approval", async () => {
|
||||
const harness = await createHarness();
|
||||
try {
|
||||
const response = await fetch(`${harness.baseUrl}/api/companies/${harness.companyId}/agents`, {
|
||||
method: "POST",
|
||||
headers: harness.headers,
|
||||
body: JSON.stringify({
|
||||
name: "Engineering IC",
|
||||
role: "Engineer",
|
||||
title: "Frontend Engineer",
|
||||
department: "engineering",
|
||||
connectorId: "codex_local",
|
||||
workspaceId: harness.workspaceId,
|
||||
model: null,
|
||||
capabilities: "React, TypeScript, Tailwind, frontend delivery",
|
||||
budgetMonthlyUsd: 180,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
const body = await response.json() as { agentId: string; approvalId: string; autoApproved: boolean };
|
||||
expect(body.autoApproved).toBe(false);
|
||||
|
||||
const pendingHire = harness.db.getAgent(body.agentId);
|
||||
expect(pendingHire.status).toBe("pending_approval");
|
||||
expect(pendingHire.reportsTo).toBe(harness.ctoId);
|
||||
|
||||
const approval = harness.db.getApprovalRecord(body.approvalId);
|
||||
expect(approval.type).toBe("hire_agent");
|
||||
expect(approval.requestedByAgentId).toBe(harness.ctoId);
|
||||
expect(approval.relatedAgentId).toBe(body.agentId);
|
||||
|
||||
harness.db.decideApproval({
|
||||
approvalId: body.approvalId,
|
||||
state: "approved",
|
||||
decisionNote: "Approved by board",
|
||||
});
|
||||
|
||||
const approvedHire = harness.db.getAgent(body.agentId);
|
||||
expect(approvedHire.status).toBe("idle");
|
||||
expect(harness.db.getDirectReports(harness.ctoId, harness.companyId).map((agent) => agent.id)).toContain(body.agentId);
|
||||
expect(harness.db.getDirectReports(harness.ceoId, harness.companyId).map((agent) => agent.id)).not.toContain(body.agentId);
|
||||
expect(harness.db.getChainOfCommand(body.agentId).map((agent) => agent.id)).toEqual([harness.ctoId, harness.ceoId]);
|
||||
|
||||
const agentResponse = await fetch(`${harness.baseUrl}/api/agents/${body.agentId}`, {
|
||||
headers: harness.headers,
|
||||
});
|
||||
expect(agentResponse.status).toBe(200);
|
||||
const agentBody = await agentResponse.json() as { manager: { id: string } | null };
|
||||
expect(agentBody.manager?.id).toBe(harness.ctoId);
|
||||
|
||||
const meResponse = await fetch(`${harness.baseUrl}/api/agents/me`, {
|
||||
headers: harness.headers,
|
||||
});
|
||||
expect(meResponse.status).toBe(200);
|
||||
const meBody = await meResponse.json() as { directReports: Array<{ id: string }> };
|
||||
expect(meBody.directReports.map((agent) => agent.id)).toContain(body.agentId);
|
||||
} finally {
|
||||
harness.server.stop();
|
||||
harness.db.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("manager cannot route a hire outside their reporting branch", async () => {
|
||||
const harness = await createHarness();
|
||||
try {
|
||||
const cmoId = harness.db.saveAgent({
|
||||
companyId: harness.companyId,
|
||||
name: "CMO",
|
||||
role: "CMO",
|
||||
title: "Chief Marketing Officer",
|
||||
status: "active",
|
||||
reportsTo: harness.ceoId,
|
||||
connectorId: "codex_local",
|
||||
workspaceId: harness.workspaceId,
|
||||
model: null,
|
||||
capabilities: "Lead marketing",
|
||||
budgetMonthlyUsd: 220,
|
||||
department: "marketing",
|
||||
});
|
||||
|
||||
const response = await fetch(`${harness.baseUrl}/api/companies/${harness.companyId}/agents`, {
|
||||
method: "POST",
|
||||
headers: harness.headers,
|
||||
body: JSON.stringify({
|
||||
name: "Misrouted Hire",
|
||||
role: "Engineer",
|
||||
title: "Misrouted Engineer",
|
||||
department: "engineering",
|
||||
reportsTo: cmoId,
|
||||
connectorId: "codex_local",
|
||||
workspaceId: harness.workspaceId,
|
||||
model: null,
|
||||
capabilities: "Should not report outside the CTO branch",
|
||||
budgetMonthlyUsd: 180,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
const body = await response.json() as { error: string };
|
||||
expect(body.error).toContain("must report to you or a manager in your reporting chain");
|
||||
} finally {
|
||||
harness.server.stop();
|
||||
harness.db.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
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 { createAgentToken, initializeAgentTokenSigningKey } from "../../src/main/agent-auth";
|
||||
import { createApiServer } from "../../src/main/api-server";
|
||||
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 })));
|
||||
});
|
||||
|
||||
async function createHarness() {
|
||||
const profileDir = await createTempDir("agentcompany-api-events-");
|
||||
initializeAgentTokenSigningKey(profileDir);
|
||||
const db = new AppDatabase(profileDir);
|
||||
db.init();
|
||||
|
||||
const companyId = db.saveCompany({ name: "Orbit Foundry", description: "API test", status: "active" });
|
||||
const workspaceId = db.saveWorkspace({
|
||||
companyId,
|
||||
name: "Primary",
|
||||
localPath: profileDir,
|
||||
repoUrl: "",
|
||||
repoRef: "",
|
||||
isPrimary: true,
|
||||
});
|
||||
const agentId = db.saveAgent({
|
||||
companyId,
|
||||
name: "Helios",
|
||||
role: "CEO",
|
||||
title: "Lead agent",
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "codex_local",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Operate the company",
|
||||
budgetMonthlyUsd: 100,
|
||||
});
|
||||
const taskId = db.saveTask({
|
||||
companyId,
|
||||
title: "Ship the API",
|
||||
description: "Capture structured run events.",
|
||||
assigneeAgentId: agentId,
|
||||
workspaceId,
|
||||
priority: "high",
|
||||
status: "todo",
|
||||
});
|
||||
const run = db.createRun({ companyId, taskId, agentId, workspaceId, connectorId: "codex_local" });
|
||||
const token = createAgentToken(agentId, companyId, run.id);
|
||||
const server = createApiServer(db, () => {}, () => null);
|
||||
const port = await server.start();
|
||||
|
||||
return {
|
||||
db,
|
||||
server,
|
||||
runId: run.id,
|
||||
companyId,
|
||||
agentId,
|
||||
baseUrl: `http://127.0.0.1:${port}`,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"X-Agent-Company-Run-Id": run.id,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function readUntil(reader: ReadableStreamDefaultReader<Uint8Array>, matcher: (text: string) => boolean, timeoutMs = 4_000) {
|
||||
const decoder = new TextDecoder();
|
||||
let text = "";
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
text += decoder.decode(value, { stream: true });
|
||||
if (matcher(text)) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Timed out waiting for stream match. Received: ${text}`);
|
||||
}
|
||||
|
||||
describe("run event API", () => {
|
||||
it("returns persisted run events for the active run token", async () => {
|
||||
const harness = await createHarness();
|
||||
try {
|
||||
harness.db.appendRunEvent({
|
||||
companyId: harness.companyId,
|
||||
runId: harness.runId,
|
||||
agentId: harness.agentId,
|
||||
eventType: "run_status",
|
||||
level: "info",
|
||||
message: "Run started",
|
||||
payload: { status: "running" },
|
||||
});
|
||||
|
||||
const response = await fetch(`${harness.baseUrl}/api/runs/${harness.runId}/events?offset=0&limit=10`, {
|
||||
headers: harness.headers,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const body = await response.json() as {
|
||||
events: Array<{ eventType: string; message: string | null; payload: Record<string, unknown> | null }>;
|
||||
nextOffset: number;
|
||||
};
|
||||
expect(body.events).toHaveLength(1);
|
||||
expect(body.events[0]?.eventType).toBe("run_status");
|
||||
expect(body.events[0]?.message).toBe("Run started");
|
||||
expect(body.events[0]?.payload).toMatchObject({ status: "running" });
|
||||
expect(body.nextOffset).toBe(1);
|
||||
} finally {
|
||||
harness.server.stop();
|
||||
harness.db.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("streams new run events over SSE for the active run token", async () => {
|
||||
const harness = await createHarness();
|
||||
const controller = new AbortController();
|
||||
try {
|
||||
const response = await fetch(`${harness.baseUrl}/api/runs/${harness.runId}/events/stream`, {
|
||||
headers: harness.headers,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toContain("text/event-stream");
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
expect(reader).toBeDefined();
|
||||
|
||||
setTimeout(() => {
|
||||
harness.db.appendRunEvent({
|
||||
companyId: harness.companyId,
|
||||
runId: harness.runId,
|
||||
agentId: harness.agentId,
|
||||
eventType: "run_log",
|
||||
stream: "stdout",
|
||||
message: "hello from sse",
|
||||
});
|
||||
}, 100);
|
||||
|
||||
const text = await readUntil(reader!, (chunk) => chunk.includes("event: run-event") && chunk.includes("hello from sse"));
|
||||
expect(text).toContain("event: connected");
|
||||
expect(text).toContain("\"eventType\":\"run_log\"");
|
||||
expect(text).toContain("\"message\":\"hello from sse\"");
|
||||
} finally {
|
||||
controller.abort();
|
||||
harness.server.stop();
|
||||
harness.db.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
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 { createBudgetEnforcementService } from "../../src/main/budget-enforcement-service";
|
||||
import { createRunDispatch } from "../../src/main/orchestration/run-dispatch";
|
||||
import { createTaskRunner } from "../../src/main/orchestration/task-runner";
|
||||
|
||||
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 })));
|
||||
});
|
||||
|
||||
const logger = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
|
||||
function createOrchestrationHarness(db: AppDatabase) {
|
||||
const budgetService = createBudgetEnforcementService({
|
||||
db,
|
||||
logger,
|
||||
notify: vi.fn(),
|
||||
publishDomainChanged: vi.fn(),
|
||||
});
|
||||
|
||||
const deps = {
|
||||
db,
|
||||
logger,
|
||||
publishDomainChanged: vi.fn(),
|
||||
emitEvent: vi.fn(),
|
||||
notify: vi.fn(),
|
||||
createRunToken: () => "test-token",
|
||||
getApiPort: () => 3210,
|
||||
dispatchAutomation: vi.fn(),
|
||||
hasWorkerReady: () => true,
|
||||
enqueueWorkerRun: vi.fn(),
|
||||
isQuitting: () => false,
|
||||
listRuntimeSkillEntries: () => [],
|
||||
budgetService,
|
||||
checkpointEngine: { shouldPauseHeartbeat: () => false },
|
||||
};
|
||||
|
||||
const runDispatch = createRunDispatch(deps);
|
||||
const taskRunner = createTaskRunner(
|
||||
deps,
|
||||
runDispatch.resolveWorkspace,
|
||||
runDispatch.getRunEligibilityFailure,
|
||||
runDispatch.dispatchRunToWorker,
|
||||
(filter) => db.hasActiveRun(filter),
|
||||
);
|
||||
|
||||
return { deps, taskRunner };
|
||||
}
|
||||
|
||||
describe("connector model enforcement", () => {
|
||||
it("blocks queueTaskRun when an agent keeps a disabled explicit model", async () => {
|
||||
const profileDir = await createTempDir("agentcompany-model-enforcement-");
|
||||
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: "Runtime Enforcement Co",
|
||||
description: "Blocks runs when agent models are disabled",
|
||||
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: "gpt-5.4",
|
||||
capabilities: "Lead the company",
|
||||
budgetMonthlyUsd: 250,
|
||||
});
|
||||
const taskId = db.saveTask({
|
||||
companyId,
|
||||
title: "Run with invalid model",
|
||||
description: "This task should be blocked because the agent model is disabled.",
|
||||
assigneeAgentId: agentId,
|
||||
workspaceId,
|
||||
priority: "high",
|
||||
status: "todo",
|
||||
});
|
||||
|
||||
db.setDisabledModels("codex_local", [{ id: "gpt-5.4", label: "gpt-5.4" }]);
|
||||
|
||||
const { taskRunner } = createOrchestrationHarness(db);
|
||||
const result = taskRunner.queueTaskRun(taskId, "manual", "board");
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) {
|
||||
throw new Error("Expected queueTaskRun to reject the disabled model.");
|
||||
}
|
||||
expect(result.error.code).toBe("CONNECTOR_MODEL_NOT_ALLOWED");
|
||||
expect(db.listSnapshot().runs).toHaveLength(0);
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("fails recovered queued runs when the persisted agent model becomes invalid", async () => {
|
||||
const profileDir = await createTempDir("agentcompany-model-recovery-");
|
||||
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: "Recovered Run Co",
|
||||
description: "Rejects invalid queued runs during recovery",
|
||||
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: "gpt-5.4",
|
||||
capabilities: "Lead the company",
|
||||
budgetMonthlyUsd: 250,
|
||||
});
|
||||
const taskId = db.saveTask({
|
||||
companyId,
|
||||
title: "Recover queued run",
|
||||
description: "This queued run should fail on recovery because the model is disabled.",
|
||||
assigneeAgentId: agentId,
|
||||
workspaceId,
|
||||
priority: "high",
|
||||
status: "todo",
|
||||
});
|
||||
|
||||
const run = db.createRun({
|
||||
companyId,
|
||||
taskId,
|
||||
agentId,
|
||||
workspaceId,
|
||||
connectorId: "codex_local",
|
||||
});
|
||||
const checkoutSuccess = db.checkoutTask(taskId, run.id);
|
||||
expect(checkoutSuccess).toBe(true);
|
||||
|
||||
db.setDisabledModels("codex_local", [{ id: "gpt-5.4", label: "gpt-5.4" }]);
|
||||
|
||||
const { taskRunner } = createOrchestrationHarness(db);
|
||||
taskRunner.resumeQueuedRuns();
|
||||
|
||||
const recoveredRun = db.listSnapshot().runs.find((entry) => entry.id === run.id);
|
||||
expect(recoveredRun?.status).toBe("failed");
|
||||
expect(recoveredRun?.errorMessage).toContain("Connector model is disabled or invalid");
|
||||
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
@@ -27,7 +27,7 @@ vi.mock("electron", () => ({
|
||||
import { AppDatabase } from "../../src/main/database";
|
||||
import { createBudgetEnforcementService } from "../../src/main/budget-enforcement-service";
|
||||
import { createSessionManagementService } from "../../src/main/session-management-service";
|
||||
import { validateTaskCreation, checkCapabilityMatch } from "../../src/main/task-quality-gates";
|
||||
import { validateTaskCreation, checkCapabilityMatch as _checkCapabilityMatch } from "../../src/main/task-quality-gates";
|
||||
import type { ConnectorId } from "../../src/shared/types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
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 { createAgentToken, initializeAgentTokenSigningKey } from "../../src/main/agent-auth";
|
||||
import { createApiServer } from "../../src/main/api-server";
|
||||
import { AppDatabase } from "../../src/main/database";
|
||||
import { processRunCompletion } from "../../src/main/automation/automation-run-completion";
|
||||
|
||||
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 })));
|
||||
});
|
||||
|
||||
async function createHarness() {
|
||||
const profileDir = await createTempDir("agentcompany-doc-provenance-");
|
||||
initializeAgentTokenSigningKey(profileDir);
|
||||
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: "Document Provenance Co",
|
||||
description: "Tests document revision provenance through run completion",
|
||||
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,
|
||||
});
|
||||
const taskId = db.saveTask({
|
||||
companyId,
|
||||
title: "Refresh the market report",
|
||||
description: "Update the report with new findings and submit it for review.",
|
||||
assigneeAgentId: agentId,
|
||||
workspaceId,
|
||||
priority: "high",
|
||||
status: "in_progress",
|
||||
});
|
||||
db.setTaskType(taskId, "research");
|
||||
|
||||
const existingDocumentId = db.saveDocument({
|
||||
companyId,
|
||||
type: "knowledge_article",
|
||||
title: "Market report",
|
||||
content: "Initial report body",
|
||||
authorAgentId: agentId,
|
||||
projectId: null,
|
||||
goalId: null,
|
||||
status: "draft",
|
||||
});
|
||||
|
||||
const run = db.createRun({
|
||||
companyId,
|
||||
taskId,
|
||||
agentId,
|
||||
workspaceId,
|
||||
connectorId: "codex_local",
|
||||
});
|
||||
const token = createAgentToken(agentId, companyId, run.id);
|
||||
const server = createApiServer(db, () => {}, () => null);
|
||||
const port = await server.start();
|
||||
|
||||
return {
|
||||
db,
|
||||
server,
|
||||
run,
|
||||
companyId,
|
||||
taskId,
|
||||
existingDocumentId,
|
||||
baseUrl: `http://127.0.0.1:${port}`,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"X-Agent-Company-Run-Id": run.id,
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("document run provenance", () => {
|
||||
it("keeps sourceRunId on revised documents so run completion still counts them as deliverables", async () => {
|
||||
const harness = await createHarness();
|
||||
try {
|
||||
const patchResponse = await fetch(
|
||||
`${harness.baseUrl}/api/companies/${harness.companyId}/documents/${harness.existingDocumentId}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: harness.headers,
|
||||
body: JSON.stringify({
|
||||
content: "Updated report with new evidence and recommendations.",
|
||||
changeSummary: "Add latest market evidence",
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(patchResponse.status).toBe(200);
|
||||
const updatedDocument = harness.db.queryAll<{ sourceRunId: string | null }>(
|
||||
"select source_run_id as sourceRunId from documents where id = ? limit 1",
|
||||
harness.existingDocumentId,
|
||||
)[0];
|
||||
expect(updatedDocument?.sourceRunId).toBe(harness.run.id);
|
||||
|
||||
const persistedRun = harness.db.listSnapshot().runs.find((entry) => entry.id === harness.run.id);
|
||||
if (!persistedRun) {
|
||||
throw new Error("Expected persisted run record for provenance test.");
|
||||
}
|
||||
|
||||
processRunCompletion({
|
||||
db: harness.db,
|
||||
logger: { info: () => {}, warn: () => {}, error: () => {} },
|
||||
publishDomainChanged: () => {},
|
||||
notify: vi.fn(),
|
||||
wakeAgentIfPossible: vi.fn().mockReturnValue(null),
|
||||
}, {
|
||||
dispatchAutomation: vi.fn(),
|
||||
handleTaskStatusChange: vi.fn(),
|
||||
}, persistedRun, {
|
||||
status: "succeeded",
|
||||
summary: "Updated the market report",
|
||||
errorMessage: null,
|
||||
costUsd: null,
|
||||
}, () => false);
|
||||
|
||||
const task = harness.db.getTask(harness.taskId);
|
||||
expect(task.status).toBe("in_review");
|
||||
} finally {
|
||||
harness.server.stop();
|
||||
harness.db.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("increments document version across multiple edits and rejects invalid reviewers without partial mutation", async () => {
|
||||
const harness = await createHarness();
|
||||
try {
|
||||
const firstPatch = await fetch(
|
||||
`${harness.baseUrl}/api/companies/${harness.companyId}/documents/${harness.existingDocumentId}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: harness.headers,
|
||||
body: JSON.stringify({
|
||||
content: "First revision of the report",
|
||||
changeSummary: "Initial revision",
|
||||
}),
|
||||
},
|
||||
);
|
||||
expect(firstPatch.status).toBe(200);
|
||||
|
||||
const revisionState = harness.db.queryAll<{ version: number; latestRevisionId: string | null }>(
|
||||
"select version, latest_revision_id as latestRevisionId from documents where id = ? limit 1",
|
||||
harness.existingDocumentId,
|
||||
)[0];
|
||||
expect(revisionState?.version).toBe(2);
|
||||
expect(revisionState?.latestRevisionId).not.toBeNull();
|
||||
|
||||
const invalidReviewerPatch = await fetch(
|
||||
`${harness.baseUrl}/api/companies/${harness.companyId}/documents/${harness.existingDocumentId}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: harness.headers,
|
||||
body: JSON.stringify({
|
||||
reviewerAgentId: "00000000-0000-0000-0000-000000000000",
|
||||
content: "This write should be rejected before any mutation occurs",
|
||||
changeSummary: "Invalid reviewer should block this patch",
|
||||
}),
|
||||
},
|
||||
);
|
||||
expect(invalidReviewerPatch.status).toBe(400);
|
||||
|
||||
const rejectedState = harness.db.queryAll<{ version: number; content: string; revisionCount: number }>(
|
||||
`select d.version as version, d.content as content,
|
||||
(select count(*) from document_revisions where document_id = d.id and company_id = d.company_id) as revisionCount
|
||||
from documents d where d.id = ? limit 1`,
|
||||
harness.existingDocumentId,
|
||||
)[0];
|
||||
expect(rejectedState?.version).toBe(2);
|
||||
expect(rejectedState?.content).toBe("First revision of the report");
|
||||
expect(rejectedState?.revisionCount).toBe(1);
|
||||
|
||||
const secondPatch = await fetch(
|
||||
`${harness.baseUrl}/api/companies/${harness.companyId}/documents/${harness.existingDocumentId}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: harness.headers,
|
||||
body: JSON.stringify({
|
||||
content: "Second revision of the report",
|
||||
changeSummary: "Follow-up revision",
|
||||
}),
|
||||
},
|
||||
);
|
||||
expect(secondPatch.status).toBe(200);
|
||||
|
||||
const finalState = harness.db.queryAll<{ version: number; revisionCount: number }>(
|
||||
`select d.version as version,
|
||||
(select count(*) from document_revisions where document_id = d.id and company_id = d.company_id) as revisionCount
|
||||
from documents d where d.id = ? limit 1`,
|
||||
harness.existingDocumentId,
|
||||
)[0];
|
||||
expect(finalState?.version).toBe(3);
|
||||
expect(finalState?.revisionCount).toBe(2);
|
||||
} finally {
|
||||
harness.server.stop();
|
||||
harness.db.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not stamp sourceRunId for metadata-only document patches", async () => {
|
||||
const harness = await createHarness();
|
||||
try {
|
||||
const metadataPatch = await fetch(
|
||||
`${harness.baseUrl}/api/companies/${harness.companyId}/documents/${harness.existingDocumentId}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: harness.headers,
|
||||
body: JSON.stringify({
|
||||
title: "Renamed market report",
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(metadataPatch.status).toBe(200);
|
||||
const updatedDocument = harness.db.queryAll<{ sourceRunId: string | null; title: string }>(
|
||||
"select source_run_id as sourceRunId, title from documents where id = ? limit 1",
|
||||
harness.existingDocumentId,
|
||||
)[0];
|
||||
expect(updatedDocument?.sourceRunId).toBeNull();
|
||||
expect(updatedDocument?.title).toBe("Renamed market report");
|
||||
} finally {
|
||||
harness.server.stop();
|
||||
harness.db.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("creates deliverable review approvals that stay linked to the originating task", async () => {
|
||||
const harness = await createHarness();
|
||||
try {
|
||||
harness.db.saveCompany({
|
||||
id: harness.companyId,
|
||||
name: "Document Provenance Co",
|
||||
description: "Tests document review task linkage",
|
||||
status: "active",
|
||||
reviewDeliverables: true,
|
||||
});
|
||||
|
||||
const createResponse = await fetch(
|
||||
`${harness.baseUrl}/api/companies/${harness.companyId}/documents`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: harness.headers,
|
||||
body: JSON.stringify({
|
||||
type: "prd",
|
||||
title: "Run-linked PRD",
|
||||
content: "Initial draft that should trigger deliverable review.",
|
||||
status: "draft",
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(createResponse.status).toBe(201);
|
||||
const approvals = harness.db.listSnapshot().approvals.filter((approval) => approval.type === "deliverable_review");
|
||||
expect(approvals).toHaveLength(1);
|
||||
expect(approvals[0]?.relatedTaskId).toBe(harness.taskId);
|
||||
} finally {
|
||||
harness.server.stop();
|
||||
harness.db.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,303 @@
|
||||
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 { createOnboardingService } from "../../src/main/onboarding-service";
|
||||
import { buildRunPayload } from "../../src/main/orchestration/turn-context";
|
||||
import { getConnectorDefinition } from "../../src/main/connectors";
|
||||
|
||||
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 createNoopLogger() {
|
||||
return {
|
||||
info: () => {},
|
||||
warn: () => {},
|
||||
error: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
function createBootstrapHarness(db: AppDatabase) {
|
||||
const noop = () => {};
|
||||
return createOnboardingService({
|
||||
db,
|
||||
seedDefaultAutomationRules: noop,
|
||||
publishDomainChanged: noop,
|
||||
queueTaskRun: () => ({ ok: true, data: "run-1" }),
|
||||
});
|
||||
}
|
||||
|
||||
function buildKickoffPayload(db: AppDatabase, summary: { companyId: string; leadAgentId: string | null; taskId: string | null; workspaceId: string | null }) {
|
||||
if (!summary.leadAgentId || !summary.taskId) {
|
||||
throw new Error("Missing lead agent or task in onboarding summary.");
|
||||
}
|
||||
|
||||
const agent = db.getAgent(summary.leadAgentId);
|
||||
const task = db.getTask(summary.taskId);
|
||||
const connector = db.getConnector(agent.connectorId);
|
||||
const workspace = summary.workspaceId ? db.getWorkspace(summary.workspaceId) : null;
|
||||
const company = db.listSnapshot().companies.find((entry) => entry.id === summary.companyId);
|
||||
|
||||
if (!company) {
|
||||
throw new Error(`Company ${summary.companyId} not found in snapshot.`);
|
||||
}
|
||||
|
||||
return buildRunPayload({
|
||||
runId: "run-1",
|
||||
companyId: summary.companyId,
|
||||
agent,
|
||||
task,
|
||||
connector,
|
||||
workspace,
|
||||
workspaceMode: "exclusive",
|
||||
companyName: company.name,
|
||||
companyDescription: company.description,
|
||||
snapshot: db.listSnapshot(),
|
||||
trigger: "bootstrap",
|
||||
}, {
|
||||
db,
|
||||
logger: createNoopLogger(),
|
||||
getApiPort: () => 3210,
|
||||
createRunToken: () => "test-token",
|
||||
listRuntimeSkillEntries: () => [],
|
||||
});
|
||||
}
|
||||
|
||||
describe("onboarding model pool integration", () => {
|
||||
it("persists an explicit CEO model and excludes disabled native models from runtime prompt context", async () => {
|
||||
const profileDir = await createTempDir("agentcompany-onboarding-models-");
|
||||
const workspaceDir = await createTempDir("agentcompany-onboarding-workspace-");
|
||||
const db = new AppDatabase(profileDir);
|
||||
db.init();
|
||||
|
||||
const codexDefinition = getConnectorDefinition("codex_local");
|
||||
const enabledModel = codexDefinition.module.models?.[0]?.id;
|
||||
const disabledModel = codexDefinition.module.models?.[1]?.id;
|
||||
|
||||
if (!enabledModel || !disabledModel) {
|
||||
throw new Error("codex_local requires at least two models for this integration test.");
|
||||
}
|
||||
|
||||
db.updateConnectorHealth({
|
||||
id: "codex_local",
|
||||
status: "ready",
|
||||
authState: "authenticated",
|
||||
version: "test",
|
||||
lastError: null,
|
||||
});
|
||||
db.setDisabledModels("codex_local", [{ id: disabledModel, label: disabledModel }]);
|
||||
|
||||
const summary = createBootstrapHarness(db).bootstrapOnboarding({
|
||||
companyName: "Model Governance Co",
|
||||
companyDescription: "Tests onboarding model governance",
|
||||
workspacePath: workspaceDir,
|
||||
autoApproveHires: true,
|
||||
reviewDeliverables: false,
|
||||
executionMode: "ai_ceo",
|
||||
goalTitle: "Launch runtime-safe company",
|
||||
goalDescription: "Ensure onboarding connector models flow into runtime decisions.",
|
||||
connectorId: "codex_local",
|
||||
connectorModel: enabledModel,
|
||||
});
|
||||
|
||||
if (!summary.leadAgentId) {
|
||||
throw new Error("Expected lead agent id from onboarding bootstrap.");
|
||||
}
|
||||
|
||||
const agent = db.getAgent(summary.leadAgentId);
|
||||
expect(agent.connectorId).toBe("codex_local");
|
||||
expect(agent.model).toBe(enabledModel);
|
||||
expect(db.getDisabledModels("codex_local")).toEqual([{ id: disabledModel, label: disabledModel }]);
|
||||
expect(db.getCurrentCompanyId()).toBe(summary.companyId);
|
||||
|
||||
const payload = buildKickoffPayload(db, summary);
|
||||
expect(payload.model).toBe(enabledModel);
|
||||
expect(payload.systemPrompt).not.toBeNull();
|
||||
const systemPrompt = payload.systemPrompt ?? "";
|
||||
expect(systemPrompt).toContain("Enabled models:");
|
||||
expect(systemPrompt).toContain(enabledModel);
|
||||
const codexConnectorLine = systemPrompt
|
||||
.split("\n")
|
||||
.find((line) => line.includes("**Codex**"));
|
||||
expect(codexConnectorLine).toBeDefined();
|
||||
expect(codexConnectorLine).not.toContain(`, ${disabledModel},`);
|
||||
expect(codexConnectorLine).not.toContain(` ${disabledModel}.`);
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("uses compat connector csv models minus disabled entries in runtime prompt context", async () => {
|
||||
const profileDir = await createTempDir("agentcompany-onboarding-compat-");
|
||||
const workspaceDir = await createTempDir("agentcompany-onboarding-compat-workspace-");
|
||||
const db = new AppDatabase(profileDir);
|
||||
db.init();
|
||||
|
||||
db.saveConnector({
|
||||
id: "claude_compatible",
|
||||
command: "claude",
|
||||
model: "model-alpha,model-beta,model-gamma",
|
||||
envBindingText: "",
|
||||
notes: "Custom provider",
|
||||
});
|
||||
db.updateConnectorHealth({
|
||||
id: "claude_compatible",
|
||||
status: "ready",
|
||||
authState: "authenticated",
|
||||
version: "test",
|
||||
lastError: null,
|
||||
});
|
||||
db.setDisabledModels("claude_compatible", [{ id: "model-beta", label: "model-beta" }]);
|
||||
|
||||
const summary = createBootstrapHarness(db).bootstrapOnboarding({
|
||||
companyName: "Compat Governance Co",
|
||||
companyDescription: "Tests compat model governance",
|
||||
workspacePath: workspaceDir,
|
||||
autoApproveHires: true,
|
||||
reviewDeliverables: false,
|
||||
executionMode: "ai_ceo",
|
||||
goalTitle: "Launch compat company",
|
||||
goalDescription: "Ensure compat model pools survive onboarding and runtime prompt assembly.",
|
||||
connectorId: "claude_compatible",
|
||||
connectorModel: "model-alpha",
|
||||
});
|
||||
|
||||
const payload = buildKickoffPayload(db, summary);
|
||||
expect(payload.systemPrompt).not.toBeNull();
|
||||
const systemPrompt = payload.systemPrompt ?? "";
|
||||
expect(systemPrompt).toContain("Enabled models: model-alpha, model-gamma.");
|
||||
expect(systemPrompt).not.toContain("model-beta");
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("stores a null CEO model when onboarding leaves the connector model at default", async () => {
|
||||
const profileDir = await createTempDir("agentcompany-onboarding-default-");
|
||||
const workspaceDir = await createTempDir("agentcompany-onboarding-default-workspace-");
|
||||
const db = new AppDatabase(profileDir);
|
||||
db.init();
|
||||
|
||||
db.updateConnectorHealth({
|
||||
id: "codex_local",
|
||||
status: "ready",
|
||||
authState: "authenticated",
|
||||
version: "test",
|
||||
lastError: null,
|
||||
});
|
||||
|
||||
const summary = createBootstrapHarness(db).bootstrapOnboarding({
|
||||
companyName: "Default Model Co",
|
||||
companyDescription: "Tests default connector model behavior",
|
||||
workspacePath: workspaceDir,
|
||||
autoApproveHires: true,
|
||||
reviewDeliverables: false,
|
||||
executionMode: "ai_ceo",
|
||||
goalTitle: "Launch default-mode company",
|
||||
goalDescription: "Leave the CEO model unset so runtime falls back to connector defaults.",
|
||||
connectorId: "codex_local",
|
||||
connectorModel: "",
|
||||
});
|
||||
|
||||
if (!summary.leadAgentId) {
|
||||
throw new Error("Expected lead agent id from onboarding bootstrap.");
|
||||
}
|
||||
|
||||
const agent = db.getAgent(summary.leadAgentId);
|
||||
expect(agent.model).toBeNull();
|
||||
|
||||
const payload = buildKickoffPayload(db, summary);
|
||||
const defaultModel = getConnectorDefinition("codex_local").defaultModel;
|
||||
expect(payload.model).toBe(defaultModel);
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("reroutes default runtime selection to the first enabled model when the connector default is disabled", async () => {
|
||||
const profileDir = await createTempDir("agentcompany-onboarding-reroute-");
|
||||
const workspaceDir = await createTempDir("agentcompany-onboarding-reroute-workspace-");
|
||||
const db = new AppDatabase(profileDir);
|
||||
db.init();
|
||||
|
||||
const codexDefinition = getConnectorDefinition("codex_local");
|
||||
const defaultModel = codexDefinition.defaultModel;
|
||||
const fallbackModel = codexDefinition.module.models?.find((model) => model.id !== defaultModel)?.id;
|
||||
|
||||
if (!defaultModel || !fallbackModel) {
|
||||
throw new Error("codex_local requires a default and fallback model for reroute testing.");
|
||||
}
|
||||
|
||||
db.updateConnectorHealth({
|
||||
id: "codex_local",
|
||||
status: "ready",
|
||||
authState: "authenticated",
|
||||
version: "test",
|
||||
lastError: null,
|
||||
});
|
||||
db.setDisabledModels("codex_local", [{ id: defaultModel, label: defaultModel }]);
|
||||
|
||||
const summary = createBootstrapHarness(db).bootstrapOnboarding({
|
||||
companyName: "Default Reroute Co",
|
||||
companyDescription: "Tests runtime reroute when the connector default is disabled",
|
||||
workspacePath: workspaceDir,
|
||||
autoApproveHires: true,
|
||||
reviewDeliverables: false,
|
||||
executionMode: "ai_ceo",
|
||||
goalTitle: "Launch rerouted company",
|
||||
goalDescription: "Ensure runtime picks the first enabled model when connector default is disabled.",
|
||||
connectorId: "codex_local",
|
||||
connectorModel: "",
|
||||
});
|
||||
|
||||
const payload = buildKickoffPayload(db, summary);
|
||||
expect(payload.model).toBe(fallbackModel);
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("rejects an explicit onboarding model that is disabled for the selected connector", async () => {
|
||||
const profileDir = await createTempDir("agentcompany-onboarding-reject-");
|
||||
const workspaceDir = await createTempDir("agentcompany-onboarding-reject-workspace-");
|
||||
const db = new AppDatabase(profileDir);
|
||||
db.init();
|
||||
|
||||
const codexDefinition = getConnectorDefinition("codex_local");
|
||||
const disabledModel = codexDefinition.module.models?.[0]?.id;
|
||||
|
||||
if (!disabledModel) {
|
||||
throw new Error("codex_local requires a model to validate onboarding rejection.");
|
||||
}
|
||||
|
||||
db.updateConnectorHealth({
|
||||
id: "codex_local",
|
||||
status: "ready",
|
||||
authState: "authenticated",
|
||||
version: "test",
|
||||
lastError: null,
|
||||
});
|
||||
db.setDisabledModels("codex_local", [{ id: disabledModel, label: disabledModel }]);
|
||||
|
||||
expect(() => createBootstrapHarness(db).bootstrapOnboarding({
|
||||
companyName: "Rejected Model Co",
|
||||
companyDescription: "Tests invalid onboarding model rejection",
|
||||
workspacePath: workspaceDir,
|
||||
autoApproveHires: true,
|
||||
reviewDeliverables: false,
|
||||
executionMode: "ai_ceo",
|
||||
goalTitle: "Reject disabled model bootstrap",
|
||||
goalDescription: "Invalid explicit models should fail before the CEO is created.",
|
||||
connectorId: "codex_local",
|
||||
connectorModel: disabledModel,
|
||||
})).toThrow(`Codex does not allow model "${disabledModel}".`);
|
||||
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,227 @@
|
||||
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 { randomUUID } from "node:crypto";
|
||||
import { AppDatabase } from "../../src/main/database";
|
||||
import { buildTaskPrompt } from "../../src/main/prompt-builder";
|
||||
|
||||
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("skill-aware capability registry", () => {
|
||||
it("autoAssignTask prefers an agent whose assigned skill matches the task, even when capability text is generic", async () => {
|
||||
const profileDir = await createTempDir("agentcompany-skill-assign-");
|
||||
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: "Skill Match Co",
|
||||
description: "Tests skill-aware assignment",
|
||||
status: "active",
|
||||
});
|
||||
const workspaceId = db.saveWorkspace({
|
||||
companyId,
|
||||
name: "Primary",
|
||||
localPath: profileDir,
|
||||
repoUrl: "",
|
||||
repoRef: "",
|
||||
isPrimary: true,
|
||||
});
|
||||
|
||||
db.saveAgent({
|
||||
companyId,
|
||||
name: "Generic Engineer",
|
||||
role: "Engineer",
|
||||
title: "Engineer",
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "codex_local",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "General software delivery",
|
||||
budgetMonthlyUsd: 200,
|
||||
});
|
||||
const styledEngineerId = db.saveAgent({
|
||||
companyId,
|
||||
name: "Frontend Specialist",
|
||||
role: "Engineer",
|
||||
title: "Frontend Specialist",
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "codex_local",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "General software delivery",
|
||||
budgetMonthlyUsd: 200,
|
||||
});
|
||||
|
||||
const skillId = randomUUID();
|
||||
const now = new Date().toISOString();
|
||||
db.upsertCompanySkill({
|
||||
id: skillId,
|
||||
companyId,
|
||||
key: "frontend/react-ui",
|
||||
slug: "react-ui",
|
||||
name: "React UI",
|
||||
description: "Build React components with hooks, Tailwind, and accessible layouts",
|
||||
markdown: "# React UI\nUse when building React components with hooks and Tailwind.",
|
||||
sourceType: "local_path",
|
||||
sourceLocator: null,
|
||||
sourceRef: null,
|
||||
trustLevel: "markdown_only",
|
||||
compatibility: "compatible",
|
||||
fileInventory: "[]",
|
||||
metadata: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
db.assignSkillToAgent(styledEngineerId, skillId);
|
||||
|
||||
const taskId = db.saveTask({
|
||||
companyId,
|
||||
title: "Build React dashboard widget",
|
||||
description: "Create an accessible React component with hooks and Tailwind styling.",
|
||||
workspaceId,
|
||||
priority: "high",
|
||||
status: "todo",
|
||||
});
|
||||
|
||||
const assignedId = db.autoAssignTask(taskId, companyId);
|
||||
expect(assignedId).toBe(styledEngineerId);
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("injects the company capability registry into the task prompt", async () => {
|
||||
const profileDir = await createTempDir("agentcompany-capability-prompt-");
|
||||
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: "Registry Co",
|
||||
description: "Tests capability registry prompt injection",
|
||||
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: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "codex_local",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Lead the company",
|
||||
budgetMonthlyUsd: 250,
|
||||
});
|
||||
|
||||
const skillId = randomUUID();
|
||||
const now = new Date().toISOString();
|
||||
db.upsertCompanySkill({
|
||||
id: skillId,
|
||||
companyId,
|
||||
key: "frontend/react-ui",
|
||||
slug: "react-ui",
|
||||
name: "React UI",
|
||||
description: "Use when building React interfaces",
|
||||
markdown: "# React UI\nUse when building React interfaces.",
|
||||
sourceType: "local_path",
|
||||
sourceLocator: null,
|
||||
sourceRef: null,
|
||||
trustLevel: "markdown_only",
|
||||
compatibility: "compatible",
|
||||
fileInventory: "[]",
|
||||
metadata: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
const snapshot = db.listSnapshot();
|
||||
const prompt = buildTaskPrompt({
|
||||
agent: db.getAgent(agentId),
|
||||
apiUrl: "http://127.0.0.1:3210",
|
||||
apiKey: "token",
|
||||
runId: "run-1",
|
||||
task: null,
|
||||
companyName: "Registry Co",
|
||||
companyDescription: "Tests capability registry prompt injection",
|
||||
goals: [],
|
||||
projects: [],
|
||||
directReports: [],
|
||||
chainOfCommand: [],
|
||||
assignedTasks: [],
|
||||
allAgents: snapshot.agents.filter((entry) => entry.companyId === companyId),
|
||||
pendingApprovals: [],
|
||||
tasksAwaitingReview: [],
|
||||
wakeReason: "assignment",
|
||||
allTasks: [],
|
||||
recentComments: [],
|
||||
socialAccounts: [],
|
||||
allConnectors: snapshot.connectors
|
||||
.filter((connector) => connector.id === "codex_local")
|
||||
.map((connector) => ({ id: connector.id, label: connector.label, status: connector.status, enabledModels: ["gpt-5.4"] })),
|
||||
companySkillCatalog: db.listCompanySkills(companyId).map((skill) => ({
|
||||
name: skill.name,
|
||||
slug: skill.slug,
|
||||
description: skill.description,
|
||||
})),
|
||||
companyWorkspaces: snapshot.workspaces
|
||||
.filter((workspace) => workspace.companyId === companyId)
|
||||
.map((workspace) => ({ name: workspace.name, localPath: workspace.localPath })),
|
||||
companyDocuments: [],
|
||||
skillEntries: [
|
||||
{
|
||||
name: "React Standards",
|
||||
whenToUse: "building React UI",
|
||||
allowedTools: ["read", "edit"],
|
||||
effort: "high",
|
||||
contextMode: "inline",
|
||||
},
|
||||
],
|
||||
skillNames: ["React Standards"],
|
||||
});
|
||||
|
||||
expect(prompt).toContain("# Company Capability Registry");
|
||||
expect(prompt).toContain("React UI (`react-ui`)");
|
||||
expect(prompt).toContain("Primary:");
|
||||
expect(prompt).toContain("Ready Connectors");
|
||||
expect(prompt).toContain("Your domain skills:");
|
||||
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,253 @@
|
||||
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 { createThreadIntegration } from "../../src/main/thread-integration";
|
||||
import { createCheckpointEngine } from "../../src/main/checkpoint-engine";
|
||||
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 createNoopLogger() {
|
||||
return {
|
||||
info: () => {},
|
||||
warn: () => {},
|
||||
error: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
function createScaffold(db: AppDatabase) {
|
||||
db.updateConnectorHealth({
|
||||
id: "codex_local",
|
||||
status: "ready",
|
||||
authState: "authenticated",
|
||||
version: "test",
|
||||
lastError: null,
|
||||
});
|
||||
|
||||
const companyId = db.saveCompany({
|
||||
name: "Thread Wait Co",
|
||||
description: "Tests cofounder wait states and replies",
|
||||
status: "active",
|
||||
executionMode: "ai_cofounder",
|
||||
});
|
||||
const workspaceId = db.saveWorkspace({
|
||||
companyId,
|
||||
name: "Primary",
|
||||
localPath: "/tmp/thread-wait",
|
||||
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 engineerId = db.saveAgent({
|
||||
companyId,
|
||||
name: "Engineer",
|
||||
role: "Engineer",
|
||||
title: "Lead Engineer",
|
||||
status: "idle",
|
||||
reportsTo: ceoId,
|
||||
connectorId: "codex_local",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Ship features",
|
||||
budgetMonthlyUsd: 180,
|
||||
});
|
||||
const taskId = db.saveTask({
|
||||
companyId,
|
||||
title: "Ship the feature",
|
||||
description: "Implement the approved deliverable and wait for co-founder review.",
|
||||
assigneeAgentId: engineerId,
|
||||
workspaceId,
|
||||
priority: "high",
|
||||
status: "in_progress",
|
||||
});
|
||||
|
||||
return { companyId, workspaceId, ceoId, engineerId, taskId };
|
||||
}
|
||||
|
||||
describe("thread wait + heartbeat integration", () => {
|
||||
it("pauses the assignee heartbeat on successful run completion until a human reply resolves the wait state", async () => {
|
||||
const profileDir = await createTempDir("agentcompany-thread-wait-");
|
||||
const db = new AppDatabase(profileDir);
|
||||
db.init();
|
||||
|
||||
const { companyId, engineerId, taskId } = createScaffold(db);
|
||||
const notifications: Array<{ title: string; body: string }> = [];
|
||||
|
||||
const threadIntegration = createThreadIntegration({
|
||||
db,
|
||||
logger: createNoopLogger(),
|
||||
notify: (notification) => {
|
||||
notifications.push(notification);
|
||||
},
|
||||
});
|
||||
const checkpointEngine = createCheckpointEngine({
|
||||
db,
|
||||
logger: createNoopLogger(),
|
||||
threadIntegration,
|
||||
publishDomainChanged: () => {},
|
||||
notify: () => {},
|
||||
});
|
||||
|
||||
threadIntegration.onTaskCreated(taskId, companyId);
|
||||
threadIntegration.onRunFinished("run-1", taskId, engineerId, companyId, "succeeded", null);
|
||||
|
||||
const thread = db.listThreads({
|
||||
companyId,
|
||||
type: "task",
|
||||
relatedEntityType: "task",
|
||||
relatedEntityId: taskId,
|
||||
limit: 1,
|
||||
})[0];
|
||||
|
||||
if (!thread) {
|
||||
throw new Error("Expected a task thread to exist for the completed run.");
|
||||
}
|
||||
|
||||
let waitStates = db.listThreadWaitStates(companyId).filter((entry) => entry.threadId === thread.id);
|
||||
expect(waitStates).toHaveLength(1);
|
||||
expect(waitStates[0]?.waitingFor).toBe("user_reply");
|
||||
expect(checkpointEngine.shouldPauseHeartbeat(companyId, engineerId)).toBe(true);
|
||||
|
||||
const otherAgentId = db.saveAgent({
|
||||
companyId,
|
||||
name: "Marketer",
|
||||
role: "CMO",
|
||||
title: "Chief Marketing Officer",
|
||||
status: "idle",
|
||||
reportsTo: null,
|
||||
connectorId: "codex_local",
|
||||
workspaceId: db.getTask(taskId).workspaceId ?? null,
|
||||
model: null,
|
||||
capabilities: "Drive marketing work",
|
||||
budgetMonthlyUsd: 180,
|
||||
});
|
||||
expect(checkpointEngine.shouldPauseHeartbeat(companyId, otherAgentId)).toBe(false);
|
||||
|
||||
expect(notifications).toHaveLength(1);
|
||||
expect(notifications[0]?.body).toContain("@Board review needed");
|
||||
|
||||
dispatchAgentMessage({
|
||||
db,
|
||||
onHumanMessage: (currentCompanyId, toAgentId, threadId, waitStateId) =>
|
||||
threadIntegration.onHumanMessage(currentCompanyId, toAgentId, threadId, waitStateId),
|
||||
}, {
|
||||
companyId,
|
||||
fromAgentId: HUMAN_SENDER_ID,
|
||||
fromHumanUser: true,
|
||||
channel: "direct",
|
||||
toAgentId: engineerId,
|
||||
threadId: thread.id,
|
||||
body: "Board reviewed this deliverable. Continue.",
|
||||
});
|
||||
|
||||
waitStates = db.listThreadWaitStates(companyId).filter((entry) => entry.threadId === thread.id);
|
||||
expect(waitStates).toHaveLength(0);
|
||||
expect(threadIntegration.hasUnresolvedWaitStatesForAgent(companyId, engineerId)).toBe(false);
|
||||
expect(checkpointEngine.shouldPauseHeartbeat(companyId, engineerId)).toBe(false);
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("resolves approval-linked wait states only when the approval is actually decided", async () => {
|
||||
const profileDir = await createTempDir("agentcompany-approval-wait-");
|
||||
const db = new AppDatabase(profileDir);
|
||||
db.init();
|
||||
|
||||
const { companyId, engineerId, taskId } = createScaffold(db);
|
||||
const threadIntegration = createThreadIntegration({
|
||||
db,
|
||||
logger: createNoopLogger(),
|
||||
});
|
||||
const checkpointEngine = createCheckpointEngine({
|
||||
db,
|
||||
logger: createNoopLogger(),
|
||||
threadIntegration,
|
||||
publishDomainChanged: () => {},
|
||||
notify: () => {},
|
||||
});
|
||||
|
||||
threadIntegration.onTaskCreated(taskId, companyId);
|
||||
const thread = db.listThreads({
|
||||
companyId,
|
||||
type: "task",
|
||||
relatedEntityType: "task",
|
||||
relatedEntityId: taskId,
|
||||
limit: 1,
|
||||
})[0];
|
||||
|
||||
if (!thread) {
|
||||
throw new Error("Expected a task thread to exist for approval resolution testing.");
|
||||
}
|
||||
|
||||
const approvalId = db.requestApproval({
|
||||
companyId,
|
||||
relatedTaskId: taskId,
|
||||
requestedByAgentId: engineerId,
|
||||
type: "draft_review",
|
||||
payloadSummary: "Review the draft",
|
||||
impactSummary: "Board approval is required before this work can continue.",
|
||||
payloadJson: "{}",
|
||||
});
|
||||
|
||||
threadIntegration.onApprovalCreated(approvalId, companyId, taskId, "draft_review", "Draft review needed");
|
||||
let waitStates = db.listThreadWaitStates(companyId).filter((entry) => entry.relatedApprovalId === approvalId);
|
||||
expect(waitStates).toHaveLength(1);
|
||||
expect(waitStates[0]?.waitingFor).toBe("user_approval");
|
||||
expect(checkpointEngine.shouldPauseHeartbeat(companyId, engineerId)).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: engineerId,
|
||||
threadId: thread.id,
|
||||
body: "Board left a comment but did not decide approval yet.",
|
||||
});
|
||||
|
||||
waitStates = db.listThreadWaitStates(companyId).filter((entry) => entry.relatedApprovalId === approvalId);
|
||||
expect(waitStates).toHaveLength(1);
|
||||
expect(checkpointEngine.shouldPauseHeartbeat(companyId, engineerId)).toBe(true);
|
||||
|
||||
db.decideApproval({
|
||||
approvalId,
|
||||
state: "approved",
|
||||
decisionNote: "Approved by board",
|
||||
});
|
||||
threadIntegration.onApprovalResolved(approvalId, companyId, "approved");
|
||||
waitStates = db.listThreadWaitStates(companyId).filter((entry) => entry.relatedApprovalId === approvalId);
|
||||
expect(waitStates).toHaveLength(0);
|
||||
expect(checkpointEngine.shouldPauseHeartbeat(companyId, engineerId)).toBe(false);
|
||||
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
@@ -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