Promote AgentCompany-New to the repository root and retire the old app
The legacy application - its src, tests, landing site, promo assets, docs, release tooling, CI workflows, and root configs - leaves the tree; it stays reachable in history. Everything under AgentCompany-New moves up unchanged: same package name, so the production userData/database path is untouched, and e2e/build scripts already resolved paths relative to the project. The developer cred.txt stays gitignored at its new location. Full verify:ci passes from the new root.
This commit is contained in:
1 parent
2350f744e3
commit
07d0b4587d
1904 files changed
+1133
-282075
No files matched your search
@@ -1,117 +0,0 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { AppDatabase } from "../../src/main/database";
|
||||
|
||||
vi.mock("electron", () => ({
|
||||
safeStorage: {
|
||||
isEncryptionAvailable: () => false,
|
||||
getSelectedStorageBackend: () => "basic_text",
|
||||
encryptString: (value: string) => Buffer.from(value, "utf8"),
|
||||
decryptString: (value: Buffer) => value.toString("utf8"),
|
||||
},
|
||||
}));
|
||||
|
||||
type RawDb = {
|
||||
db: {
|
||||
prepare: (sql: string) => {
|
||||
run: (...args: unknown[]) => void;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
function createCompany(db: AppDatabase): string {
|
||||
return db.core.company.saveCompany({
|
||||
name: "Projection Co",
|
||||
description: "Active run projection contract",
|
||||
status: "active",
|
||||
});
|
||||
}
|
||||
|
||||
function createWorkspace(db: AppDatabase, companyId: string, localPath: string): string {
|
||||
return db.core.workspaces.saveWorkspace({
|
||||
companyId,
|
||||
name: "Primary",
|
||||
localPath,
|
||||
repoUrl: "",
|
||||
repoRef: "",
|
||||
isPrimary: true,
|
||||
});
|
||||
}
|
||||
|
||||
function createAgent(db: AppDatabase, companyId: string, workspaceId: string): string {
|
||||
return db.core.agents.saveAgent({
|
||||
companyId,
|
||||
name: "Projection Agent",
|
||||
role: "Engineer",
|
||||
title: "Engineer",
|
||||
status: "idle",
|
||||
reportsTo: null,
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: "gpt-5.4",
|
||||
capabilities: "Tests active run projections",
|
||||
budgetMonthlyUsd: 100,
|
||||
});
|
||||
}
|
||||
|
||||
describe("active run projection", () => {
|
||||
let tempDir: string;
|
||||
let db: AppDatabase;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "agentcompany-active-run-projection-"));
|
||||
db = new AppDatabase(tempDir);
|
||||
db.init();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
db.close();
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("ignores terminal run leases when projecting activeRunId", () => {
|
||||
const companyId = createCompany(db);
|
||||
const workspaceId = createWorkspace(db, companyId, tempDir);
|
||||
const agentId = createAgent(db, companyId, workspaceId);
|
||||
const taskId = db.core.tasks.saveTask({
|
||||
companyId,
|
||||
title: "Recoverable task",
|
||||
description: "A stale terminal lease must not hide this task from recovery.",
|
||||
assigneeAgentId: agentId,
|
||||
workspaceId,
|
||||
priority: "high",
|
||||
status: "todo",
|
||||
});
|
||||
const run = db.core.runs.createRun({
|
||||
companyId,
|
||||
taskId,
|
||||
agentId,
|
||||
workspaceId: null,
|
||||
connectorId: "openai-codex",
|
||||
});
|
||||
db.core.runs.finishRun({
|
||||
runId: run.id,
|
||||
status: "succeeded",
|
||||
summary: "Done",
|
||||
errorMessage: null,
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
model: null,
|
||||
unattributedCost: true,
|
||||
});
|
||||
const now = new Date().toISOString();
|
||||
(db as unknown as RawDb).db
|
||||
.prepare(
|
||||
`insert into task_leases (
|
||||
task_id, company_id, run_id, agent_id, acquired_at, renewed_at, expires_at, created_at, updated_at
|
||||
) values (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(taskId, companyId, run.id, agentId, now, now, now, now, now);
|
||||
|
||||
expect(db.core.entity.getTask(taskId).activeRunId).toBeNull();
|
||||
expect(db.core.tasks.listAgentTasks(agentId, companyId, ["todo"])[0]?.activeRunId).toBeNull();
|
||||
expect(db.projection.snapshot.listSnapshot({ companyId }).tasks.find((task) => task.id === taskId)?.activeRunId).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,78 +0,0 @@
|
||||
import { createHmac } from "node:crypto";
|
||||
import { mkdtemp, readFile, rm, writeFile } 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,
|
||||
validateAgentToken,
|
||||
} from "../../src/main/agent-auth";
|
||||
import { FatalError } from "../../src/main/errors";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
async function createTempDir() {
|
||||
const dir = await mkdtemp(join(tmpdir(), "agentcompany-agent-auth-"));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
async function createSignedRawPayloadToken(profileDir: string, rawPayload: string): Promise<string> {
|
||||
const key = await readFile(join(profileDir, "agent-api-signing.key"));
|
||||
const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url");
|
||||
const body = Buffer.from(rawPayload).toString("base64url");
|
||||
const signature = createHmac("sha256", key)
|
||||
.update(`${header}.${body}`)
|
||||
.digest("base64url");
|
||||
return `${header}.${body}.${signature}`;
|
||||
}
|
||||
|
||||
describe("agent token signing key", () => {
|
||||
it("keeps existing run tokens valid across profile reinitialization", async () => {
|
||||
const profileDir = await createTempDir();
|
||||
initializeAgentTokenSigningKey(profileDir);
|
||||
const token = createAgentToken("agent-1", "company-1", "run-1");
|
||||
|
||||
initializeAgentTokenSigningKey(profileDir);
|
||||
|
||||
expect(validateAgentToken(token)).toMatchObject({
|
||||
agentId: "agent-1",
|
||||
companyId: "company-1",
|
||||
runId: "run-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("treats a corrupt signing key as fatal state", async () => {
|
||||
const profileDir = await createTempDir();
|
||||
await writeFile(join(profileDir, "agent-api-signing.key"), Buffer.from("too-short"));
|
||||
|
||||
expect(() => initializeAgentTokenSigningKey(profileDir)).toThrow(FatalError);
|
||||
});
|
||||
|
||||
it("logs malformed signed token context without token fingerprints", async () => {
|
||||
const profileDir = await createTempDir();
|
||||
initializeAgentTokenSigningKey(profileDir);
|
||||
const rawPayload = "{malformed-token-body";
|
||||
const token = await createSignedRawPayloadToken(profileDir, rawPayload);
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
expect(validateAgentToken(token, {
|
||||
runId: "run-1",
|
||||
companyId: "company-1",
|
||||
requestId: "req-1",
|
||||
})).toBeNull();
|
||||
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
"[agent-auth] token shape invalid: reason=payload_json_parse_failed runId=run-1 companyId=company-1 requestId=req-1",
|
||||
);
|
||||
const logLine = String(warn.mock.calls[0]?.[0] ?? "");
|
||||
expect(logLine).not.toContain(rawPayload);
|
||||
expect(logLine).not.toContain(token.slice(0, 8));
|
||||
});
|
||||
});
|
||||
@@ -1,112 +0,0 @@
|
||||
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("agent budget policy synchronization", () => {
|
||||
it("updates the canonical agent-scope policy instead of creating duplicate active policies", async () => {
|
||||
const profileDir = await createTempDir("agentcompany-budget-sync-");
|
||||
const db = new AppDatabase(profileDir);
|
||||
db.init();
|
||||
|
||||
const companyId = db.core.company.saveCompany({
|
||||
name: "Budget Sync Co",
|
||||
description: "Budget sync regression coverage",
|
||||
status: "active",
|
||||
});
|
||||
db.core.connectors.updateConnectorHealth({
|
||||
id: "openai-codex",
|
||||
status: "ready",
|
||||
authState: "authenticated",
|
||||
version: "test",
|
||||
lastError: null,
|
||||
});
|
||||
const workspaceId = db.core.workspaces.saveWorkspace({
|
||||
companyId,
|
||||
name: "Primary",
|
||||
localPath: profileDir,
|
||||
repoUrl: "",
|
||||
repoRef: "",
|
||||
isPrimary: true,
|
||||
});
|
||||
const agentId = db.core.agents.saveAgent({
|
||||
companyId,
|
||||
name: "Engineer",
|
||||
role: "Engineer",
|
||||
title: "Backend Engineer",
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Build backend services",
|
||||
budgetMonthlyUsd: 100,
|
||||
department: "engineering",
|
||||
});
|
||||
|
||||
const firstPolicyId = db.core.budget.saveBudgetPolicy({
|
||||
companyId,
|
||||
scopeType: "agent",
|
||||
scopeId: agentId,
|
||||
metric: "billed_cents",
|
||||
windowKind: "calendar_month_utc",
|
||||
amount: 10_000,
|
||||
warnPercent: 80,
|
||||
hardStopEnabled: true,
|
||||
notifyEnabled: true,
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
db.core.agents.saveAgent({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "Engineer",
|
||||
role: "Engineer",
|
||||
title: "Backend Engineer",
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Build backend services",
|
||||
budgetMonthlyUsd: 250,
|
||||
department: "engineering",
|
||||
});
|
||||
|
||||
const secondPolicyId = db.core.budget.saveBudgetPolicy({
|
||||
companyId,
|
||||
scopeType: "agent",
|
||||
scopeId: agentId,
|
||||
metric: "billed_cents",
|
||||
windowKind: "calendar_month_utc",
|
||||
amount: 25_000,
|
||||
warnPercent: 80,
|
||||
hardStopEnabled: true,
|
||||
notifyEnabled: true,
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
const agentPolicies = db.core.budget.listBudgetPolicies(companyId).filter(
|
||||
(policy) => policy.scopeType === "agent" && policy.scopeId === agentId,
|
||||
);
|
||||
expect(secondPolicyId).toBe(firstPolicyId);
|
||||
expect(agentPolicies).toHaveLength(1);
|
||||
expect(agentPolicies[0]?.amount).toBe(25_000);
|
||||
expect(db.core.budget.getActivePoliciesForRun(companyId, agentId, null).map((policy) => policy.amount)).toEqual([25_000]);
|
||||
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { DatabaseConnection } from "../../src/main/database/connection";
|
||||
import { OrganizationService } from "../../src/main/services/organization-service";
|
||||
import { ChatService } from "../../src/main/services/chat-service";
|
||||
import { dispatchAgentTool, type AgentToolContext } from "../../src/main/services/agent-tools";
|
||||
|
||||
function call(name: string, args: Record<string, unknown>) {
|
||||
return { id: `call_${randomUUID()}`, name, arguments: args };
|
||||
}
|
||||
|
||||
interface Harness {
|
||||
connection: DatabaseConnection;
|
||||
organization: OrganizationService;
|
||||
chat: ChatService;
|
||||
orgId: string;
|
||||
employeeId: string;
|
||||
ceoId: string;
|
||||
danaId: string;
|
||||
ctx: AgentToolContext;
|
||||
}
|
||||
|
||||
function harness(options: { enableProvider: boolean }): Harness {
|
||||
const connection = new DatabaseConnection(join(tmpdir(), `ac-dm-${randomUUID()}.db`));
|
||||
const organization = new OrganizationService(connection.db);
|
||||
let orgId = "";
|
||||
let ceoId = "";
|
||||
let danaId = "";
|
||||
connection.transaction(() => {
|
||||
orgId = organization.createOrganization({ organizationName: "OrbitOps", employeeName: "Yang", mission: "m", goal: "g" });
|
||||
const providerId = (connection.db.prepare("select id from providers where org_id = ? and provider_key = 'openai'").get(orgId) as { id: string }).id;
|
||||
if (options.enableProvider) {
|
||||
connection.db.prepare("update providers set enabled = 1 where id = ? and org_id = ?").run(providerId, orgId);
|
||||
}
|
||||
ceoId = organization.createAgent({
|
||||
orgId, name: "CEO", role: "CEO", department: "Executive",
|
||||
modelProviderId: providerId, modelId: "gpt-5.4", budgetCents: 25000, managerAgentId: null,
|
||||
});
|
||||
danaId = organization.createAgent({
|
||||
orgId, name: "Dana", role: "Engineer", department: "Engineering",
|
||||
modelProviderId: providerId, modelId: "gpt-5.4", budgetCents: 1000, managerAgentId: ceoId,
|
||||
});
|
||||
});
|
||||
const employeeId = (connection.db.prepare("select id from employees where org_id = ?").get(orgId) as { id: string }).id;
|
||||
const chat = new ChatService(connection.db);
|
||||
return {
|
||||
connection,
|
||||
organization,
|
||||
chat,
|
||||
orgId,
|
||||
employeeId,
|
||||
ceoId,
|
||||
danaId,
|
||||
ctx: {
|
||||
orgId,
|
||||
agent: { id: ceoId, modelProviderId: null, modelId: null },
|
||||
taskProjectId: null,
|
||||
taskGoalId: null,
|
||||
workspace: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function directSpaces(h: Harness): Array<{ id: string }> {
|
||||
return h.connection.db.prepare("select id from chat_spaces where org_id = ? and space_type = 'direct'")
|
||||
.all(h.orgId) as Array<{ id: string }>;
|
||||
}
|
||||
|
||||
describe("agent direct messages", () => {
|
||||
test("a DM gets its own conversation and later messages reuse it", () => {
|
||||
const h = harness({ enableProvider: false });
|
||||
const deps = { db: h.connection.db, organization: h.organization, chat: h.chat };
|
||||
|
||||
const first = dispatchAgentTool(deps, h.ctx, call("send_message", { to: "Dana", body: "How is the booking API going?" }));
|
||||
expect(first).toContain("direct message to Dana");
|
||||
const threadId = /thread_id="([^"]+)"/.exec(first)?.[1];
|
||||
expect(threadId).toBeTruthy();
|
||||
|
||||
dispatchAgentTool(deps, h.ctx, call("send_message", { to: "Dana", body: "Also, ping me when the spec lands.", thread_id: threadId }));
|
||||
|
||||
const spaces = directSpaces(h);
|
||||
expect(spaces).toHaveLength(1);
|
||||
const members = h.connection.db.prepare("select principal_type, principal_id from chat_memberships where space_id = ?")
|
||||
.all(spaces[0].id) as Array<{ principal_type: string; principal_id: string }>;
|
||||
expect(members).toHaveLength(2);
|
||||
expect(members.map((m) => m.principal_id).sort()).toEqual([h.ceoId, h.danaId].sort());
|
||||
|
||||
const messages = h.connection.db.prepare("select space_id from chat_messages where org_id = ?").all(h.orgId) as Array<{ space_id: string }>;
|
||||
expect(messages).toHaveLength(2);
|
||||
expect(messages.every((m) => m.space_id === spaces[0].id)).toBe(true);
|
||||
});
|
||||
|
||||
test("a direct message wakes the counterpart agent without an @mention", () => {
|
||||
const h = harness({ enableProvider: true });
|
||||
const deps = { db: h.connection.db, organization: h.organization, chat: h.chat };
|
||||
|
||||
dispatchAgentTool(deps, h.ctx, call("send_message", { to: "Dana", body: "Take a look at the failing deploy when you can." }));
|
||||
|
||||
const runs = h.connection.db.prepare(
|
||||
"select count(*) as count from task_runs where org_id = ? and agent_id = ? and trigger = 'chat_mention'",
|
||||
).get(h.orgId, h.danaId) as { count: number };
|
||||
expect(runs.count).toBe(1);
|
||||
});
|
||||
|
||||
test("an employee DM wakes the agent like any messenger would", () => {
|
||||
const h = harness({ enableProvider: true });
|
||||
const spaceId = h.chat.ensureDirectSpace(h.orgId, { type: "employee", id: h.employeeId }, { type: "agent", id: h.danaId });
|
||||
h.chat.sendMessage({
|
||||
orgId: h.orgId,
|
||||
spaceId,
|
||||
threadId: null,
|
||||
senderType: "employee",
|
||||
senderId: h.employeeId,
|
||||
bodyMd: "Dana, what is the launch status?",
|
||||
attachmentFileIds: [],
|
||||
});
|
||||
const runs = h.connection.db.prepare(
|
||||
"select count(*) as count from task_runs where org_id = ? and agent_id = ? and trigger = 'chat_mention'",
|
||||
).get(h.orgId, h.danaId) as { count: number };
|
||||
expect(runs.count).toBe(1);
|
||||
});
|
||||
|
||||
test("task assignments go to the manager's direct conversation with the assignee", () => {
|
||||
const h = harness({ enableProvider: true });
|
||||
const deps = { db: h.connection.db, organization: h.organization, chat: h.chat };
|
||||
|
||||
const result = dispatchAgentTool(deps, h.ctx, call("create_task", {
|
||||
title: "Ship booking API",
|
||||
description: "Design and ship the booking API v1.",
|
||||
assignee_agent_name: "Dana",
|
||||
}));
|
||||
expect(result).toContain("assigned to Dana");
|
||||
|
||||
const spaces = directSpaces(h);
|
||||
expect(spaces).toHaveLength(1);
|
||||
const assignment = h.connection.db.prepare("select space_id, body_md from chat_messages where org_id = ?").get(h.orgId) as { space_id: string; body_md: string };
|
||||
expect(assignment.space_id).toBe(spaces[0].id);
|
||||
expect(assignment.body_md).toContain("take task_id=");
|
||||
});
|
||||
|
||||
test("send_message requires exactly one destination", () => {
|
||||
const h = harness({ enableProvider: false });
|
||||
const deps = { db: h.connection.db, organization: h.organization, chat: h.chat };
|
||||
expect(dispatchAgentTool(deps, h.ctx, call("send_message", { body: "hello" }))).toContain("ERROR");
|
||||
expect(dispatchAgentTool(deps, h.ctx, call("send_message", { to: "Dana", channel: "general", body: "hello" }))).toContain("ERROR");
|
||||
});
|
||||
|
||||
test("read_messages can read a direct conversation by teammate name", () => {
|
||||
const h = harness({ enableProvider: false });
|
||||
const deps = { db: h.connection.db, organization: h.organization, chat: h.chat };
|
||||
dispatchAgentTool(deps, h.ctx, call("send_message", { to: "Dana", body: "Morning - priorities for today are in the doc." }));
|
||||
const readBack = dispatchAgentTool(deps, h.ctx, call("read_messages", { with: "Dana" }));
|
||||
expect(readBack).toContain("priorities for today");
|
||||
expect(readBack).toContain("[CEO]");
|
||||
});
|
||||
});
|
||||
@@ -1,79 +0,0 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { AppDatabase } from "../../src/main/database";
|
||||
|
||||
beforeAll(() => {
|
||||
process.env.AGENTCOMPANY_ALLOW_INSECURE_VAULT = "true";
|
||||
});
|
||||
|
||||
vi.mock("electron", () => ({
|
||||
safeStorage: {
|
||||
isEncryptionAvailable: () => false,
|
||||
getSelectedStorageBackend: () => "basic_text",
|
||||
encryptString: (value: string) => Buffer.from(value, "utf8"),
|
||||
decryptString: (value: Buffer) => value.toString("utf8"),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("agent hierarchy cycle guard", () => {
|
||||
let tempDir: string;
|
||||
let db: AppDatabase;
|
||||
let companyId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "agentcompany-hierarchy-"));
|
||||
db = new AppDatabase(tempDir);
|
||||
db.init();
|
||||
companyId = db.core.company.saveCompany({
|
||||
name: "Hierarchy Guard Co",
|
||||
description: "Tests reporting relationships.",
|
||||
status: "active",
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
db.close();
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("rejects direct self-reporting", () => {
|
||||
expect(() => saveAgent("agent-self", "agent-self")).toThrow("Agent reporting relationship would create a cycle.");
|
||||
});
|
||||
|
||||
it("rejects assigning a manager to one of its descendants", () => {
|
||||
saveAgent("ceo", null);
|
||||
saveAgent("cto", "ceo");
|
||||
saveAgent("engineer", "cto");
|
||||
|
||||
expect(() => saveAgent("ceo", "engineer")).toThrow("Agent reporting relationship would create a cycle.");
|
||||
});
|
||||
|
||||
it("allows acyclic hierarchy updates", () => {
|
||||
saveAgent("ceo", null);
|
||||
saveAgent("cto", "ceo");
|
||||
saveAgent("engineer", "cto");
|
||||
|
||||
saveAgent("engineer", "ceo");
|
||||
|
||||
expect(db.core.entity.getAgent("engineer").reportsTo).toBe("ceo");
|
||||
});
|
||||
|
||||
function saveAgent(id: string, reportsTo: string | null): string {
|
||||
return db.core.agents.saveAgent({
|
||||
id,
|
||||
companyId,
|
||||
name: id,
|
||||
role: "Engineer",
|
||||
title: id,
|
||||
status: "active",
|
||||
reportsTo,
|
||||
connectorId: "openai-codex",
|
||||
workspaceId: null,
|
||||
model: null,
|
||||
capabilities: "Maintains hierarchy.",
|
||||
budgetMonthlyUsd: 100,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,98 +0,0 @@
|
||||
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 { getHireRoutingValidationError, resolveHireReportsTo } 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("hire routing guards", () => {
|
||||
it("defaults same-department hires to the requester and infers department leads for cross-department hires", async () => {
|
||||
const profileDir = await createTempDir("agentcompany-hire-manager-");
|
||||
const db = new AppDatabase(profileDir);
|
||||
db.init();
|
||||
|
||||
const companyId = db.core.company.saveCompany({
|
||||
name: "Routing Co",
|
||||
description: "Hire routing regression coverage",
|
||||
status: "active",
|
||||
});
|
||||
db.core.connectors.updateConnectorHealth({
|
||||
id: "openai-codex",
|
||||
status: "ready",
|
||||
authState: "authenticated",
|
||||
version: "test",
|
||||
lastError: null,
|
||||
});
|
||||
const workspaceId = db.core.workspaces.saveWorkspace({
|
||||
companyId,
|
||||
name: "Primary",
|
||||
localPath: profileDir,
|
||||
repoUrl: "",
|
||||
repoRef: "",
|
||||
isPrimary: true,
|
||||
});
|
||||
const ceoId = db.core.agents.saveAgent({
|
||||
companyId,
|
||||
name: "CEO",
|
||||
role: "CEO",
|
||||
title: "Chief Executive Officer",
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Lead the company",
|
||||
budgetMonthlyUsd: 250,
|
||||
department: "executive",
|
||||
});
|
||||
const hrId = db.core.agents.saveAgent({
|
||||
companyId,
|
||||
name: "HR Lead",
|
||||
role: "HR Lead",
|
||||
title: "Head of HR",
|
||||
status: "active",
|
||||
reportsTo: ceoId,
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Run hiring operations",
|
||||
budgetMonthlyUsd: 180,
|
||||
department: "hr",
|
||||
});
|
||||
const ctoId = db.core.agents.saveAgent({
|
||||
companyId,
|
||||
name: "CTO",
|
||||
role: "CTO",
|
||||
title: "Chief Technology Officer",
|
||||
status: "active",
|
||||
reportsTo: ceoId,
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Lead engineering",
|
||||
budgetMonthlyUsd: 220,
|
||||
department: "engineering",
|
||||
});
|
||||
|
||||
expect(resolveHireReportsTo(db, companyId, hrId, undefined, "hr")).toBe(hrId);
|
||||
expect(resolveHireReportsTo(db, companyId, hrId, undefined, "engineering")).toBe(ctoId);
|
||||
expect(getHireRoutingValidationError(db, companyId, hrId, hrId, "engineering")).toBe(
|
||||
"Selected manager must belong to the same department as the new hire, unless they are a top-level executive.",
|
||||
);
|
||||
expect(getHireRoutingValidationError(db, companyId, ctoId, ctoId, "engineering")).toBeNull();
|
||||
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
@@ -1,37 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { buildAgentHooks } from "../../src/main/agent-runtime/agent-hooks";
|
||||
import type { WorkerRpcClient } from "../../src/main/worker/entry/rpc";
|
||||
|
||||
function createRpc(response: { ok: true; result: unknown } | { ok: false; error: string }) {
|
||||
return {
|
||||
invoke: vi.fn(async () => response),
|
||||
} as unknown as WorkerRpcClient & { invoke: ReturnType<typeof vi.fn> };
|
||||
}
|
||||
|
||||
describe("agent hook RPC policy", () => {
|
||||
it("fails beforeToolCall closed when main cannot answer approval policy", async () => {
|
||||
const rpc = createRpc({ ok: false, error: "RuntimeBridge unavailable" });
|
||||
const hooks = buildAgentHooks({
|
||||
runId: "11111111-1111-4111-8111-111111111111",
|
||||
rpc,
|
||||
});
|
||||
|
||||
await expect(
|
||||
hooks.beforeToolCall({
|
||||
toolCall: { name: "bash", id: "tool-call-1" },
|
||||
args: { path: "report.md" },
|
||||
} as unknown as Parameters<typeof hooks.beforeToolCall>[0]),
|
||||
).rejects.toThrow("beforeToolCall RPC failed: RuntimeBridge unavailable");
|
||||
});
|
||||
|
||||
it("installs no per-tool artifact projection hook", () => {
|
||||
const rpc = createRpc({ ok: true, result: { block: false, reason: null } });
|
||||
const hooks = buildAgentHooks({
|
||||
runId: "11111111-1111-4111-8111-111111111111",
|
||||
rpc,
|
||||
});
|
||||
|
||||
expect(Object.keys(hooks).sort()).toEqual(["beforeToolCall", "transformContext"]);
|
||||
expect("afterToolCall" in hooks).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { DatabaseConnection } from "../../src/main/database/connection";
|
||||
import { OrganizationService } from "../../src/main/services/organization-service";
|
||||
import { ChatService } from "../../src/main/services/chat-service";
|
||||
import { dispatchAgentTool, type AgentToolContext } from "../../src/main/services/agent-tools";
|
||||
|
||||
function call(name: string, args: Record<string, unknown>) {
|
||||
return { id: `call_${randomUUID()}`, name, arguments: args };
|
||||
}
|
||||
|
||||
describe("agent messaging", () => {
|
||||
test("agents post to a department channel and reply in the same thread", () => {
|
||||
const connection = new DatabaseConnection(join(tmpdir(), `ac-msg-${randomUUID()}.db`));
|
||||
const organization = new OrganizationService(connection.db);
|
||||
let orgId = "";
|
||||
let ceoId = "";
|
||||
let providerId = "";
|
||||
connection.transaction(() => {
|
||||
orgId = organization.createOrganization({ organizationName: "OrbitOps", employeeName: "Yang", mission: "m", goal: "g" });
|
||||
providerId = (connection.db.prepare("select id from providers where org_id = ? and provider_key = 'openai'").get(orgId) as { id: string }).id;
|
||||
ceoId = organization.createAgent({
|
||||
orgId, name: "CEO", role: "CEO", department: "Executive",
|
||||
modelProviderId: providerId, modelId: "gpt-5.4", budgetCents: 25000, managerAgentId: null,
|
||||
});
|
||||
});
|
||||
const deps = { db: connection.db, organization, chat: new ChatService(connection.db) };
|
||||
const ctx: AgentToolContext = {
|
||||
orgId, agent: { id: ceoId, modelProviderId: null, modelId: null },
|
||||
taskProjectId: null, taskGoalId: null, workspace: null,
|
||||
};
|
||||
|
||||
const first = dispatchAgentTool(deps, ctx, call("send_message", { channel: "engineering", body: "Assigning the booking API to Dana." }));
|
||||
const threadId = /thread_id="([^"]+)"/.exec(first)?.[1];
|
||||
expect(threadId).toBeTruthy();
|
||||
const second = dispatchAgentTool(deps, ctx, call("send_message", { channel: "engineering", body: "Update: API shipped.", thread_id: threadId }));
|
||||
expect(second).toContain("#engineering");
|
||||
|
||||
const engineering = connection.db.prepare("select id from chat_spaces where org_id = ? and name = 'engineering'").get(orgId) as { id: string };
|
||||
const messages = connection.db.prepare("select space_id, thread_id, sender_type, sender_id from chat_messages where org_id = ?")
|
||||
.all(orgId) as Array<{ space_id: string; thread_id: string; sender_type: string; sender_id: string }>;
|
||||
expect(messages).toHaveLength(2);
|
||||
expect(messages.every((m) => m.space_id === engineering.id)).toBe(true);
|
||||
expect(messages.every((m) => m.sender_type === "agent" && m.sender_id === ceoId)).toBe(true);
|
||||
// Both messages share the one thread — the reply joined the assignment thread.
|
||||
expect(new Set(messages.map((m) => m.thread_id)).size).toBe(1);
|
||||
|
||||
// Addressing a teammate comes back as presence — who can reply — not a receipt id.
|
||||
connection.transaction(() => organization.createAgent({
|
||||
orgId, name: "Dana", role: "Engineer", department: "Engineering",
|
||||
modelProviderId: null, modelId: null, budgetCents: 1000, managerAgentId: ceoId,
|
||||
}));
|
||||
const addressed = dispatchAgentTool(deps, ctx, call("send_message", { channel: "engineering", body: "@Dana can you take the booking API?" }));
|
||||
expect(addressed).toContain("Dana was mentioned in this thread");
|
||||
expect(addressed).not.toContain("Dana can see it and reply here");
|
||||
expect(addressed).not.toContain("message_id=");
|
||||
expect(connection.db.prepare("select count(*) as count from task_runs where org_id = ? and trigger = 'chat_mention'")
|
||||
.get(orgId)).toEqual({ count: 0 });
|
||||
|
||||
connection.close();
|
||||
});
|
||||
|
||||
test("reports reply readiness when a mention wakes a teammate run", () => {
|
||||
const connection = new DatabaseConnection(join(tmpdir(), `ac-msg-${randomUUID()}.db`));
|
||||
const organization = new OrganizationService(connection.db);
|
||||
let orgId = "";
|
||||
let ceoId = "";
|
||||
let danaId = "";
|
||||
let providerId = "";
|
||||
connection.transaction(() => {
|
||||
orgId = organization.createOrganization({ organizationName: "OrbitOps", employeeName: "Yang", mission: "m", goal: "g" });
|
||||
providerId = (connection.db.prepare("select id from providers where org_id = ? and provider_key = 'openai'").get(orgId) as { id: string }).id;
|
||||
connection.db.prepare("update providers set enabled = 1 where id = ? and org_id = ?").run(providerId, orgId);
|
||||
ceoId = organization.createAgent({
|
||||
orgId, name: "CEO", role: "CEO", department: "Executive",
|
||||
modelProviderId: providerId, modelId: "gpt-5.4", budgetCents: 25000, managerAgentId: null,
|
||||
});
|
||||
danaId = organization.createAgent({
|
||||
orgId, name: "Dana", role: "Engineer", department: "Engineering",
|
||||
modelProviderId: providerId, modelId: "gpt-5.4", budgetCents: 1000, managerAgentId: ceoId,
|
||||
});
|
||||
});
|
||||
const deps = { db: connection.db, organization, chat: new ChatService(connection.db) };
|
||||
const ctx: AgentToolContext = {
|
||||
orgId, agent: { id: ceoId, modelProviderId: null, modelId: null },
|
||||
taskProjectId: null, taskGoalId: null, workspace: null,
|
||||
};
|
||||
|
||||
const result = dispatchAgentTool(deps, ctx, call("send_message", { channel: "engineering", body: "@Dana can you take this?" }));
|
||||
|
||||
expect(result).toContain("Dana can see it and reply here");
|
||||
expect(connection.db.prepare("select count(*) as count from task_runs where org_id = ? and agent_id = ? and trigger = 'chat_mention'")
|
||||
.get(orgId, danaId)).toEqual({ count: 1 });
|
||||
connection.close();
|
||||
});
|
||||
});
|
||||
@@ -1,159 +0,0 @@
|
||||
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 { canFileCeoStrategy, 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;
|
||||
}
|
||||
|
||||
async function setupCompany(prefix: string) {
|
||||
const profileDir = await createTempDir(prefix);
|
||||
const db = new AppDatabase(profileDir);
|
||||
db.init();
|
||||
|
||||
const companyId = db.core.company.saveCompany({
|
||||
name: "CEO Strategy Co",
|
||||
description: "Tests canFileCeoStrategy orthogonality",
|
||||
status: "active",
|
||||
});
|
||||
const workspaceId = db.core.workspaces.saveWorkspace({
|
||||
companyId,
|
||||
name: "Primary",
|
||||
localPath: profileDir,
|
||||
repoUrl: "",
|
||||
repoRef: "",
|
||||
isPrimary: true,
|
||||
});
|
||||
|
||||
return { db, companyId, workspaceId };
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe("canFileCeoStrategy", () => {
|
||||
it("returns true for a genuine CEO who reports to the board", async () => {
|
||||
const { db, companyId, workspaceId } = await setupCompany("agentcompany-ceo-strategy-real-");
|
||||
|
||||
const ceoId = db.core.agents.saveAgent({
|
||||
companyId,
|
||||
name: "Ada",
|
||||
role: "CEO",
|
||||
title: "Chief Executive Officer",
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Lead the company",
|
||||
budgetMonthlyUsd: 250,
|
||||
});
|
||||
|
||||
expect(canFileCeoStrategy(db, companyId, ceoId)).toBe(true);
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("returns false for a CEO-titled agent with a virtual superior", async () => {
|
||||
const { db, companyId, workspaceId } = await setupCompany("agentcompany-ceo-strategy-virtual-");
|
||||
|
||||
const boardId = db.core.agents.saveAgent({
|
||||
companyId,
|
||||
name: "Board Proxy",
|
||||
role: "Board",
|
||||
title: "Board Representative",
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Hold CEO accountable",
|
||||
budgetMonthlyUsd: 100,
|
||||
});
|
||||
const ceoId = db.core.agents.saveAgent({
|
||||
companyId,
|
||||
name: "Ada",
|
||||
role: "CEO",
|
||||
title: "Chief Executive Officer",
|
||||
status: "active",
|
||||
reportsTo: boardId,
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Lead the company",
|
||||
budgetMonthlyUsd: 250,
|
||||
});
|
||||
|
||||
expect(canFileCeoStrategy(db, companyId, ceoId)).toBe(false);
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("returns false for a VP with no manager (role is not CEO)", async () => {
|
||||
const { db, companyId, workspaceId } = await setupCompany("agentcompany-ceo-strategy-vp-");
|
||||
|
||||
const vpId = db.core.agents.saveAgent({
|
||||
companyId,
|
||||
name: "Kai",
|
||||
role: "VP Product",
|
||||
title: "Vice President of Product",
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Own product strategy",
|
||||
budgetMonthlyUsd: 220,
|
||||
department: "product",
|
||||
});
|
||||
|
||||
expect(canFileCeoStrategy(db, companyId, vpId)).toBe(false);
|
||||
expect(canRequestHire(db, companyId, vpId)).toBe(true);
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("returns false for a plain IC", async () => {
|
||||
const { db, companyId, workspaceId } = await setupCompany("agentcompany-ceo-strategy-ic-");
|
||||
|
||||
const ceoId = db.core.agents.saveAgent({
|
||||
companyId,
|
||||
name: "Ada",
|
||||
role: "CEO",
|
||||
title: "Chief Executive Officer",
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Lead the company",
|
||||
budgetMonthlyUsd: 250,
|
||||
});
|
||||
const icId = db.core.agents.saveAgent({
|
||||
companyId,
|
||||
name: "Eve",
|
||||
role: "Engineer",
|
||||
title: "Software Engineer",
|
||||
status: "active",
|
||||
reportsTo: ceoId,
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Build features",
|
||||
budgetMonthlyUsd: 180,
|
||||
department: "engineering",
|
||||
});
|
||||
|
||||
expect(canFileCeoStrategy(db, companyId, icId)).toBe(false);
|
||||
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
@@ -1,249 +0,0 @@
|
||||
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.core.company.saveCompany({
|
||||
name: "Org Permissions Co",
|
||||
description: "Tests recursive org authority",
|
||||
status: "active",
|
||||
});
|
||||
const workspaceId = db.core.workspaces.saveWorkspace({
|
||||
companyId,
|
||||
name: "Primary",
|
||||
localPath: profileDir,
|
||||
repoUrl: "",
|
||||
repoRef: "",
|
||||
isPrimary: true,
|
||||
});
|
||||
|
||||
const ceoId = db.core.agents.saveAgent({
|
||||
companyId,
|
||||
name: "CEO",
|
||||
role: "CEO",
|
||||
title: "Chief Executive Officer",
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Lead the company",
|
||||
budgetMonthlyUsd: 250,
|
||||
});
|
||||
const ctoId = db.core.agents.saveAgent({
|
||||
companyId,
|
||||
name: "CTO",
|
||||
role: "CTO",
|
||||
title: "Chief Technology Officer",
|
||||
status: "active",
|
||||
reportsTo: ceoId,
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Lead engineering",
|
||||
budgetMonthlyUsd: 220,
|
||||
department: "engineering",
|
||||
});
|
||||
const engineerId = db.core.agents.saveAgent({
|
||||
companyId,
|
||||
name: "Engineer",
|
||||
role: "Engineer",
|
||||
title: "Software Engineer",
|
||||
status: "active",
|
||||
reportsTo: ceoId,
|
||||
connectorId: "openai-codex",
|
||||
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.core.company.saveCompany({
|
||||
name: "Org Permission Override Co",
|
||||
description: "Tests permission-based org authority",
|
||||
status: "active",
|
||||
});
|
||||
const workspaceId = db.core.workspaces.saveWorkspace({
|
||||
companyId,
|
||||
name: "Primary",
|
||||
localPath: profileDir,
|
||||
repoUrl: "",
|
||||
repoRef: "",
|
||||
isPrimary: true,
|
||||
});
|
||||
const ceoId = db.core.agents.saveAgent({
|
||||
companyId,
|
||||
name: "CEO",
|
||||
role: "CEO",
|
||||
title: "Chief Executive Officer",
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Lead the company",
|
||||
budgetMonthlyUsd: 250,
|
||||
});
|
||||
const agentId = db.core.agents.saveAgent({
|
||||
companyId,
|
||||
name: "Operations Lead",
|
||||
role: "Specialist",
|
||||
title: "Operations Specialist",
|
||||
status: "active",
|
||||
reportsTo: ceoId,
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Coordinate operations",
|
||||
budgetMonthlyUsd: 180,
|
||||
department: "operations",
|
||||
});
|
||||
|
||||
expect(canManageOrganization(db, companyId, agentId)).toBe(false);
|
||||
db.core.agents.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.core.company.saveCompany({
|
||||
name: "Scoped Authority Co",
|
||||
description: "Tests that branch growth does not unlock org-wide admin powers",
|
||||
status: "active",
|
||||
});
|
||||
const workspaceId = db.core.workspaces.saveWorkspace({
|
||||
companyId,
|
||||
name: "Primary",
|
||||
localPath: profileDir,
|
||||
repoUrl: "",
|
||||
repoRef: "",
|
||||
isPrimary: true,
|
||||
});
|
||||
const ceoId = db.core.agents.saveAgent({
|
||||
companyId,
|
||||
name: "CEO",
|
||||
role: "CEO",
|
||||
title: "Chief Executive Officer",
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Lead the company",
|
||||
budgetMonthlyUsd: 250,
|
||||
});
|
||||
const ctoId = db.core.agents.saveAgent({
|
||||
companyId,
|
||||
name: "CTO",
|
||||
role: "CTO",
|
||||
title: "Chief Technology Officer",
|
||||
status: "active",
|
||||
reportsTo: ceoId,
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Lead engineering",
|
||||
budgetMonthlyUsd: 220,
|
||||
department: "engineering",
|
||||
});
|
||||
const cmoId = db.core.agents.saveAgent({
|
||||
companyId,
|
||||
name: "CMO",
|
||||
role: "CMO",
|
||||
title: "Chief Marketing Officer",
|
||||
status: "active",
|
||||
reportsTo: ceoId,
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Lead marketing",
|
||||
budgetMonthlyUsd: 220,
|
||||
department: "marketing",
|
||||
});
|
||||
const ctoReportId = db.core.agents.saveAgent({
|
||||
companyId,
|
||||
name: "Engineer",
|
||||
role: "Engineer",
|
||||
title: "Frontend Engineer",
|
||||
status: "active",
|
||||
reportsTo: ctoId,
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Build frontend features",
|
||||
budgetMonthlyUsd: 180,
|
||||
department: "engineering",
|
||||
});
|
||||
|
||||
const cmoTaskId = db.core.tasks.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.core.entity.getTask(cmoTaskId).assigneeAgentId).toBe(cmoId);
|
||||
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,234 @@
|
||||
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { DatabaseConnection } from "../../src/main/database/connection";
|
||||
import { OrganizationService } from "../../src/main/services/organization-service";
|
||||
import { ChatService } from "../../src/main/services/chat-service";
|
||||
import { runAgentLoop } from "../../src/main/services/agent-run-loop";
|
||||
import { canonicalMentionHandle } from "../../src/shared/mention-handles";
|
||||
import type { AgentToolContext } from "../../src/main/services/agent-tools";
|
||||
import type { AgentTurnResult, ToolCall } from "../../src/main/services/provider-tool-turn";
|
||||
|
||||
interface ScriptedTurn {
|
||||
text: string;
|
||||
toolCalls: ToolCall[];
|
||||
}
|
||||
|
||||
function fakeProvider(script: ScriptedTurn[]) {
|
||||
let index = 0;
|
||||
return {
|
||||
runToolTurn: async (): Promise<AgentTurnResult & { requestId: string }> => {
|
||||
const turn = script[index] ?? { text: "done", toolCalls: [] };
|
||||
index += 1;
|
||||
return { text: turn.text, toolCalls: turn.toolCalls, rawResponseJson: "{}", requestId: `req_${index}` };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function call(name: string, args: Record<string, unknown>): ToolCall {
|
||||
return { id: `call_${randomUUID()}`, name, arguments: args };
|
||||
}
|
||||
|
||||
describe("agent run loop (real tool dispatch)", () => {
|
||||
test("the CEO hires, creates a task, writes a deliverable, then converges", async () => {
|
||||
const connection = new DatabaseConnection(join(tmpdir(), `ac-loop-${randomUUID()}.db`));
|
||||
const organization = new OrganizationService(connection.db);
|
||||
const workspaceRoot = join(tmpdir(), `ac-ws-${randomUUID()}`);
|
||||
mkdirSync(workspaceRoot, { recursive: true });
|
||||
|
||||
let orgId = "";
|
||||
let ceoId = "";
|
||||
let providerId = "";
|
||||
const workspaceId = "wsp_test";
|
||||
connection.transaction(() => {
|
||||
orgId = organization.createOrganization({
|
||||
organizationName: "OrbitOps", employeeName: "Yang", mission: "Run an AI company.", goal: "Win customers.",
|
||||
});
|
||||
providerId = (connection.db.prepare("select id from providers where org_id = ? and provider_key = 'openai'").get(orgId) as { id: string }).id;
|
||||
connection.db.prepare("update providers set enabled = 1 where id = ?").run(providerId);
|
||||
connection.db.prepare(`
|
||||
insert into workspaces(id, org_id, name, local_path, repo_url, repo_ref, project_id, is_primary, created_at, updated_at)
|
||||
values (?, ?, 'Default Workspace', ?, '', '', null, 1, datetime('now'), datetime('now'))
|
||||
`).run(workspaceId, orgId, workspaceRoot);
|
||||
ceoId = organization.createAgent({
|
||||
orgId, name: "CEO", role: "CEO", title: "Chief Executive Officer", department: "Executive",
|
||||
modelProviderId: providerId, modelId: "gpt-5.4", budgetCents: 25000, managerAgentId: null,
|
||||
roleLevel: "c_suite", workspaceId,
|
||||
});
|
||||
});
|
||||
|
||||
const toolContext: AgentToolContext = {
|
||||
orgId,
|
||||
agent: { id: ceoId, modelProviderId: providerId, modelId: "gpt-5.4" },
|
||||
taskProjectId: null,
|
||||
taskGoalId: null,
|
||||
workspace: { id: workspaceId, localPath: workspaceRoot },
|
||||
};
|
||||
|
||||
const script: ScriptedTurn[] = [
|
||||
{ text: "Staffing the team.", toolCalls: [
|
||||
call("hire_agent", { name: "Alex Rivera", role: "Growth Marketer", department: "Marketing" }),
|
||||
call("hire_agent", { name: "Sam Lee", role: "Operations Lead", department: "Operations" }),
|
||||
] },
|
||||
{ text: "Delegating.", toolCalls: [
|
||||
call("create_task", { title: "Build the booking site", description: "Ship a landing + booking flow.", assignee_agent_name: "Alex Rivera" }),
|
||||
] },
|
||||
{ text: "Writing the plan.", toolCalls: [
|
||||
call("write_deliverable", { path: "plan/operating-plan.md", content: "# Operating Plan\n\n1. Validate demand.\n2. Launch booking site.\n" }),
|
||||
call("send_message", { channel: "general", body: "Team staffed and the operating plan is live. Booking site is next." }),
|
||||
] },
|
||||
{ text: "Team staffed, work delegated, plan written.", toolCalls: [] },
|
||||
];
|
||||
|
||||
let toolResultNotifications = 0;
|
||||
const result = await runAgentLoop(
|
||||
{
|
||||
providers: fakeProvider(script),
|
||||
tools: { db: connection.db, organization, chat: new ChatService(connection.db) },
|
||||
onToolResults: () => { toolResultNotifications += 1; },
|
||||
},
|
||||
{ orgId, providerId, workspaceId, instructions: "You are the CEO.", initialUserTurn: "Start the company.", toolContext },
|
||||
);
|
||||
|
||||
expect(result.iterations).toBe(4);
|
||||
expect(result.toolCallCount).toBe(5);
|
||||
expect(result.finalText).toContain("Team staffed");
|
||||
// The UI is told about new data after every tool-bearing iteration, not only at the end.
|
||||
expect(toolResultNotifications).toBe(3);
|
||||
|
||||
const agentNames = (connection.db.prepare("select name from agents where org_id = ? order by name").all(orgId) as Array<{ name: string }>).map((r) => r.name);
|
||||
expect(agentNames).toEqual(["Alex Rivera", "CEO", "Sam Lee"]);
|
||||
|
||||
const task = connection.db.prepare("select title, assignee_agent_id from tasks where org_id = ?").get(orgId) as { title: string; assignee_agent_id: string } | undefined;
|
||||
expect(task?.title).toBe("Build the booking site");
|
||||
const alexId = (connection.db.prepare("select id from agents where org_id = ? and name = 'Alex Rivera'").get(orgId) as { id: string }).id;
|
||||
expect(task?.assignee_agent_id).toBe(alexId);
|
||||
|
||||
const deliverablePath = join(workspaceRoot, "plan", "operating-plan.md");
|
||||
expect(existsSync(deliverablePath)).toBe(true);
|
||||
expect(readFileSync(deliverablePath, "utf8")).toContain("Operating Plan");
|
||||
|
||||
// The assignment message and the CEO's coordination message both land in chat.
|
||||
const messages = connection.db.prepare(
|
||||
"select sender_type, sender_id, body_md, space_id from chat_messages where org_id = ?",
|
||||
).all(orgId) as Array<{ sender_type: string; sender_id: string; body_md: string; space_id: string }>;
|
||||
expect(messages).toHaveLength(2);
|
||||
expect(messages).toContainEqual(expect.objectContaining({ sender_type: "agent", sender_id: ceoId, body_md: expect.stringContaining(`@${canonicalMentionHandle("Alex Rivera")}`) }));
|
||||
expect(messages).toContainEqual(expect.objectContaining({ sender_type: "agent", sender_id: ceoId, body_md: expect.stringContaining("operating plan is live") }));
|
||||
|
||||
connection.close();
|
||||
});
|
||||
|
||||
test("rejects deliverable paths that escape the workspace", async () => {
|
||||
const connection = new DatabaseConnection(join(tmpdir(), `ac-loop2-${randomUUID()}.db`));
|
||||
const organization = new OrganizationService(connection.db);
|
||||
const workspaceRoot = join(tmpdir(), `ac-ws2-${randomUUID()}`);
|
||||
mkdirSync(workspaceRoot, { recursive: true });
|
||||
let orgId = "";
|
||||
let ceoId = "";
|
||||
let providerId = "";
|
||||
connection.transaction(() => {
|
||||
orgId = organization.createOrganization({ organizationName: "OrbitOps", employeeName: "Yang", mission: "m", goal: "g" });
|
||||
providerId = (connection.db.prepare("select id from providers where org_id = ? and provider_key = 'openai'").get(orgId) as { id: string }).id;
|
||||
ceoId = organization.createAgent({ orgId, name: "CEO", role: "CEO", department: "Executive", modelProviderId: providerId, modelId: "gpt-5.4", budgetCents: 25000, managerAgentId: null });
|
||||
});
|
||||
const ctx: AgentToolContext = {
|
||||
orgId, agent: { id: ceoId, modelProviderId: providerId, modelId: "gpt-5.4" },
|
||||
taskProjectId: null, taskGoalId: null, workspace: { id: "wsp", localPath: workspaceRoot },
|
||||
};
|
||||
const script: ScriptedTurn[] = [
|
||||
{ text: "escape", toolCalls: [call("write_deliverable", { path: "../escape.txt", content: "x" })] },
|
||||
{ text: "done", toolCalls: [] },
|
||||
];
|
||||
const result = await runAgentLoop(
|
||||
{ providers: fakeProvider(script), tools: { db: connection.db, organization, chat: new ChatService(connection.db) } },
|
||||
{ orgId, providerId, workspaceId: null, instructions: "i", initialUserTurn: "u", toolContext: ctx },
|
||||
);
|
||||
// The tool returns an error string to the model; nothing is written outside the root.
|
||||
expect(existsSync(join(workspaceRoot, "..", "escape.txt"))).toBe(false);
|
||||
expect(result.toolCallCount).toBe(1);
|
||||
connection.close();
|
||||
});
|
||||
|
||||
test("delegating a task opens an assignment thread for the assignee", async () => {
|
||||
const connection = new DatabaseConnection(join(tmpdir(), `ac-loop-assign-${randomUUID()}.db`));
|
||||
const organization = new OrganizationService(connection.db);
|
||||
let orgId = "";
|
||||
let ceoId = "";
|
||||
let providerId = "";
|
||||
connection.transaction(() => {
|
||||
orgId = organization.createOrganization({
|
||||
organizationName: "OrbitOps", employeeName: "Yang", mission: "m", goal: "g",
|
||||
});
|
||||
providerId = (connection.db.prepare("select id from providers where org_id = ? and provider_key = 'openai'").get(orgId) as { id: string }).id;
|
||||
connection.db.prepare("update providers set enabled = 1 where id = ?").run(providerId);
|
||||
ceoId = organization.createAgent({
|
||||
orgId, name: "CEO", role: "CEO", department: "Executive",
|
||||
modelProviderId: providerId, modelId: "gpt-5.4", budgetCents: 25000, managerAgentId: null,
|
||||
});
|
||||
});
|
||||
const ctx: AgentToolContext = {
|
||||
orgId, agent: { id: ceoId, modelProviderId: providerId, modelId: "gpt-5.4" },
|
||||
taskProjectId: null, taskGoalId: null, workspace: null,
|
||||
};
|
||||
const script: ScriptedTurn[] = [
|
||||
{ text: "staff", toolCalls: [call("hire_agent", { name: "Alex Rivera", role: "Engineer", department: "Engineering" })] },
|
||||
{ text: "delegate", toolCalls: [call("create_task", { title: "Build the booking API", description: "Ship the booking API.", assignee_agent_name: "Alex Rivera" })] },
|
||||
{ text: "done", toolCalls: [] },
|
||||
];
|
||||
|
||||
await runAgentLoop(
|
||||
{ providers: fakeProvider(script), tools: { db: connection.db, organization, chat: new ChatService(connection.db) } },
|
||||
{ orgId, providerId, workspaceId: null, instructions: "i", initialUserTurn: "u", toolContext: ctx },
|
||||
);
|
||||
|
||||
const alex = connection.db.prepare("select id from agents where org_id = ? and name = 'Alex Rivera'").get(orgId) as { id: string };
|
||||
const assignment = connection.db.prepare(`
|
||||
select m.body_md, m.thread_id, s.id as space_id, s.space_type
|
||||
from chat_messages m
|
||||
join chat_spaces s on s.id = m.space_id
|
||||
where m.org_id = ?
|
||||
`).get(orgId) as { body_md: string; thread_id: string; space_id: string; space_type: string } | undefined;
|
||||
// Assignments are 1:1 hand-offs, so they live in the manager's direct
|
||||
// conversation with the assignee instead of a shared channel.
|
||||
expect(assignment).toMatchObject({ space_type: "direct" });
|
||||
const dmMembers = connection.db.prepare("select principal_id from chat_memberships where space_id = ?")
|
||||
.all(assignment?.space_id ?? "") as Array<{ principal_id: string }>;
|
||||
expect(dmMembers.map((m) => m.principal_id).sort()).toEqual([ceoId, alex.id].sort());
|
||||
expect(assignment?.body_md).toContain(`@${canonicalMentionHandle("Alex Rivera")}`);
|
||||
expect(assignment?.body_md).toContain("Build the booking API");
|
||||
|
||||
const wake = connection.db.prepare(`
|
||||
select state, trigger, secret_scope_json
|
||||
from task_runs
|
||||
where org_id = ? and agent_id = ? and trigger = 'chat_mention'
|
||||
`).get(orgId, alex.id) as { state: string; trigger: string; secret_scope_json: string } | undefined;
|
||||
expect(wake).toMatchObject({ state: "queued", trigger: "chat_mention" });
|
||||
expect(JSON.parse(wake?.secret_scope_json ?? "{}")).toMatchObject({ sourceThreadId: assignment?.thread_id });
|
||||
connection.close();
|
||||
});
|
||||
|
||||
test("a first turn with no text and no tool calls fails the run instead of completing a no-op", async () => {
|
||||
const connection = new DatabaseConnection(join(tmpdir(), `ac-loop3-${randomUUID()}.db`));
|
||||
const organization = new OrganizationService(connection.db);
|
||||
let orgId = "";
|
||||
let ceoId = "";
|
||||
let providerId = "";
|
||||
connection.transaction(() => {
|
||||
orgId = organization.createOrganization({ organizationName: "OrbitOps", employeeName: "Yang", mission: "m", goal: "g" });
|
||||
providerId = (connection.db.prepare("select id from providers where org_id = ? and provider_key = 'openai'").get(orgId) as { id: string }).id;
|
||||
ceoId = organization.createAgent({ orgId, name: "CEO", role: "CEO", department: "Executive", modelProviderId: providerId, modelId: "gpt-5.4", budgetCents: 25000, managerAgentId: null });
|
||||
});
|
||||
const ctx: AgentToolContext = {
|
||||
orgId, agent: { id: ceoId, modelProviderId: providerId, modelId: "gpt-5.4" },
|
||||
taskProjectId: null, taskGoalId: null, workspace: null,
|
||||
};
|
||||
await expect(runAgentLoop(
|
||||
{ providers: fakeProvider([{ text: "", toolCalls: [] }]), tools: { db: connection.db, organization, chat: new ChatService(connection.db) } },
|
||||
{ orgId, providerId, workspaceId: null, instructions: "i", initialUserTurn: "u", toolContext: ctx },
|
||||
)).rejects.toThrow(/empty turn/);
|
||||
connection.close();
|
||||
});
|
||||
});
|
||||
@@ -1,59 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { hireRequestSchema } from "../../src/shared/contracts/schemas-agents";
|
||||
import { getHireNameValidationError } from "../../src/main/agent-permissions";
|
||||
|
||||
const baseHireRequest = {
|
||||
companyId: "00000000-0000-0000-0000-000000000000",
|
||||
requestedByAgentId: null,
|
||||
name: "Alice",
|
||||
role: "Engineer",
|
||||
title: "Backend Engineer",
|
||||
department: "engineering" as const,
|
||||
reportsTo: null,
|
||||
connectorId: "openai-codex" as const,
|
||||
workspaceId: null,
|
||||
model: null,
|
||||
metadataJson: "{}",
|
||||
capabilities: "Build backend services",
|
||||
budgetMonthlyUsd: 120,
|
||||
};
|
||||
|
||||
describe("agent input schemas", () => {
|
||||
it("rejects whitespace-only hire names", () => {
|
||||
expect(() =>
|
||||
hireRequestSchema.parse({
|
||||
...baseHireRequest,
|
||||
name: " ",
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("trims semantic hire fields before persistence", () => {
|
||||
const parsed = hireRequestSchema.parse({
|
||||
...baseHireRequest,
|
||||
name: " Alice ",
|
||||
role: " Engineer ",
|
||||
title: " Backend Engineer ",
|
||||
});
|
||||
|
||||
expect(parsed.name).toBe("Alice");
|
||||
expect(parsed.role).toBe("Engineer");
|
||||
expect(parsed.title).toBe("Backend Engineer");
|
||||
});
|
||||
|
||||
it("rejects role-like names for non-executive hires", () => {
|
||||
expect(getHireNameValidationError({
|
||||
name: "Backend Engineer",
|
||||
role: "Backend Engineer",
|
||||
title: "Backend Engineer",
|
||||
})).toBe("Hire name must be a real display name, not just the role.");
|
||||
});
|
||||
|
||||
it("allows executive acronyms when they are the intended display name", () => {
|
||||
expect(getHireNameValidationError({
|
||||
name: "CTO",
|
||||
role: "CTO",
|
||||
title: "Chief Technology Officer",
|
||||
})).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,20 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { decodeBodyToUtf8 } from "../../src/main/api-server/server-core";
|
||||
|
||||
describe("API request body encoding", () => {
|
||||
it("decodes valid UTF-8 JSON", () => {
|
||||
const raw = Buffer.from(JSON.stringify({ text: "中文" }), "utf8");
|
||||
|
||||
expect(decodeBodyToUtf8(raw)).toBe("{\"text\":\"中文\"}");
|
||||
});
|
||||
|
||||
it("rejects GBK bytes instead of repairing them", () => {
|
||||
const raw = Buffer.from([
|
||||
0x7b, 0x22, 0x74, 0x65, 0x78, 0x74, 0x22, 0x3a, 0x22,
|
||||
0xd6, 0xd0, 0xce, 0xc4,
|
||||
0x22, 0x7d,
|
||||
]);
|
||||
|
||||
expect(() => decodeBodyToUtf8(raw)).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,30 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createApiServer, type ApiServerEventCallbacks } from "../../src/main/api-server";
|
||||
import type { AppDatabase } from "../../src/main/database";
|
||||
import { createTestApiServerCallbacks } from "../support/api-server-callbacks";
|
||||
|
||||
describe("api server callback contract", () => {
|
||||
it("fails during construction when the callback object is missing", () => {
|
||||
expect(() => {
|
||||
createApiServer({} as AppDatabase, undefined as unknown as ApiServerEventCallbacks);
|
||||
}).toThrow("Missing AgentApiServer callbacks.");
|
||||
});
|
||||
|
||||
it("fails during construction when a required callback is missing", () => {
|
||||
const callbacks: Partial<ApiServerEventCallbacks> = createTestApiServerCallbacks();
|
||||
delete callbacks.onTaskCreated;
|
||||
|
||||
expect(() => {
|
||||
createApiServer({} as AppDatabase, callbacks as ApiServerEventCallbacks);
|
||||
}).toThrow("Missing AgentApiServer callback: onTaskCreated");
|
||||
});
|
||||
|
||||
it("constructs the server when every callback is explicit", () => {
|
||||
const callbacks = createTestApiServerCallbacks();
|
||||
const server = createApiServer({} as AppDatabase, callbacks);
|
||||
|
||||
expect(server.callbacks.onTaskCreated).toBe(callbacks.onTaskCreated);
|
||||
expect(server.onDomainChanged).toBe(callbacks.onDomainChanged);
|
||||
expect(server.onWakeAgent).toBe(callbacks.onWakeAgent);
|
||||
});
|
||||
});
|
||||
@@ -1,30 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { logAppError, setFatalErrorRouteForTesting } from "../../src/main/errors";
|
||||
import type { AppError } from "../../src/main/errors";
|
||||
|
||||
describe("AppError fatal route", () => {
|
||||
afterEach(() => {
|
||||
setFatalErrorRouteForTesting(null);
|
||||
});
|
||||
|
||||
it("routes fatal errors through the process-level handler", () => {
|
||||
const logger = { error: vi.fn(), warn: vi.fn() };
|
||||
const routed: AppError[] = [];
|
||||
const sentinel = new Error("fatal route called");
|
||||
setFatalErrorRouteForTesting((error) => {
|
||||
routed.push(error);
|
||||
throw sentinel;
|
||||
});
|
||||
|
||||
expect(() => logAppError(logger, new Error("sqlite corrupt"), {
|
||||
kind: "fatal",
|
||||
code: "db.sqlite.corrupt",
|
||||
message: "SQLite profile is corrupt",
|
||||
context: { profile: "test" },
|
||||
})).toThrow(sentinel);
|
||||
|
||||
expect(routed[0]?.kind).toBe("fatal");
|
||||
expect(routed[0]?.code).toBe("db.sqlite.corrupt");
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,131 +0,0 @@
|
||||
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.core.connectors.updateConnectorHealth({
|
||||
id: "openai-codex",
|
||||
status: "ready",
|
||||
authState: "authenticated",
|
||||
version: "test",
|
||||
lastError: null,
|
||||
});
|
||||
|
||||
const companyId = db.core.company.saveCompany({
|
||||
name: "Hire Routing Co",
|
||||
description: "Tests recursive hire wake routing",
|
||||
status: "active",
|
||||
});
|
||||
const workspaceId = db.core.workspaces.saveWorkspace({
|
||||
companyId,
|
||||
name: "Primary",
|
||||
localPath: profileDir,
|
||||
repoUrl: "",
|
||||
repoRef: "",
|
||||
isPrimary: true,
|
||||
});
|
||||
const ceoId = db.core.agents.saveAgent({
|
||||
companyId,
|
||||
name: "CEO",
|
||||
role: "CEO",
|
||||
title: "Chief Executive Officer",
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Lead the company",
|
||||
budgetMonthlyUsd: 250,
|
||||
});
|
||||
const ctoId = db.core.agents.saveAgent({
|
||||
companyId,
|
||||
name: "CTO",
|
||||
role: "CTO",
|
||||
title: "Chief Technology Officer",
|
||||
status: "active",
|
||||
reportsTo: ceoId,
|
||||
connectorId: "openai-codex",
|
||||
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: "openai-codex",
|
||||
workspaceId,
|
||||
model: null,
|
||||
metadataJson: "{}",
|
||||
capabilities: "React, TypeScript, Tailwind",
|
||||
budgetMonthlyUsd: 180,
|
||||
payloadSummary: "Hire new engineer",
|
||||
impactSummary: "Need another IC under the CTO",
|
||||
payloadJson: "{}",
|
||||
});
|
||||
|
||||
db.core.approvals.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,
|
||||
queueBrowserActionExecution: () => {},
|
||||
isQuitting: () => false,
|
||||
}, {
|
||||
dispatchAutomation,
|
||||
handleTaskStatusChange: vi.fn(),
|
||||
runWorkflowPipeline: async () => {},
|
||||
trackTimer: vi.fn(),
|
||||
}, approvalId, companyId, "approved");
|
||||
|
||||
expect(db.core.entity.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();
|
||||
});
|
||||
});
|
||||
@@ -1,262 +0,0 @@
|
||||
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";
|
||||
import { createHeartbeat } from "../../src/main/orchestration/heartbeat";
|
||||
|
||||
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.each(["rejected", "revision_requested"] as const)(
|
||||
"persists high-importance knowledge when the board returns %s on a deliverable",
|
||||
async (decision) => {
|
||||
const profileDir = await createTempDir("agentcompany-review-feedback-");
|
||||
const db = new AppDatabase(profileDir);
|
||||
db.init();
|
||||
|
||||
db.core.connectors.updateConnectorHealth({
|
||||
id: "openai-codex",
|
||||
status: "ready",
|
||||
authState: "authenticated",
|
||||
version: "test",
|
||||
lastError: null,
|
||||
});
|
||||
|
||||
const companyId = db.core.company.saveCompany({
|
||||
name: "Review Feedback Co",
|
||||
description: "Tests review feedback knowledge writeback",
|
||||
status: "active",
|
||||
});
|
||||
const workspaceId = db.core.workspaces.saveWorkspace({
|
||||
companyId,
|
||||
name: "Primary",
|
||||
localPath: profileDir,
|
||||
repoUrl: "",
|
||||
repoRef: "",
|
||||
isPrimary: true,
|
||||
});
|
||||
const agentId = db.core.agents.saveAgent({
|
||||
companyId,
|
||||
name: "Research Lead",
|
||||
role: "Researcher",
|
||||
title: "Research Lead",
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Research and synthesis",
|
||||
budgetMonthlyUsd: 180,
|
||||
department: "research",
|
||||
});
|
||||
const taskId = db.core.tasks.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.core.documents.saveDocument({
|
||||
companyId,
|
||||
type: "knowledge_article",
|
||||
title: "Market report",
|
||||
content: "Draft report body",
|
||||
authorAgentId: agentId,
|
||||
status: "in_review",
|
||||
});
|
||||
const approvalId = db.core.approvals.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.core.approvals.decideApproval({
|
||||
approvalId,
|
||||
state: decision,
|
||||
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,
|
||||
queueBrowserActionExecution: () => {},
|
||||
isQuitting: () => false,
|
||||
}, {
|
||||
dispatchAutomation: vi.fn(),
|
||||
handleTaskStatusChange: vi.fn(),
|
||||
runWorkflowPipeline: async () => {},
|
||||
trackTimer: vi.fn(),
|
||||
}, approvalId, companyId, decision);
|
||||
|
||||
const knowledgeEntries = db.projection.snapshot.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(decision);
|
||||
expect(knowledgeEntries[0]?.content).toContain("stronger competitor evidence");
|
||||
expect(wakeAgentIfPossible).toHaveBeenCalledWith(agentId, companyId, "revision_needed");
|
||||
expect(wakeAgentIfPossible).toHaveBeenCalledTimes(1);
|
||||
expect(db.core.entity.getTask(taskId).status).toBe("todo");
|
||||
const document = db.projection.snapshot.listSnapshot().documents.find((entry) => entry.id === documentId);
|
||||
expect(document?.status).toBe("draft");
|
||||
|
||||
db.close();
|
||||
},
|
||||
);
|
||||
|
||||
it("makes a revision request immediately checkoutable by the assignee heartbeat", async () => {
|
||||
const profileDir = await createTempDir("agentcompany-review-checkout-");
|
||||
const db = new AppDatabase(profileDir);
|
||||
db.init();
|
||||
|
||||
db.core.connectors.saveConnector({
|
||||
id: "openai-codex",
|
||||
model: "gpt-5.4",
|
||||
envBindingText: "",
|
||||
notes: "Test connector",
|
||||
});
|
||||
|
||||
const companyId = db.core.company.saveCompany({
|
||||
name: "Review Checkout Co",
|
||||
description: "Tests review revision checkout",
|
||||
status: "active",
|
||||
});
|
||||
const workspaceId = db.core.workspaces.saveWorkspace({
|
||||
companyId,
|
||||
name: "Primary",
|
||||
localPath: profileDir,
|
||||
repoUrl: "",
|
||||
repoRef: "",
|
||||
isPrimary: true,
|
||||
});
|
||||
const agentId = db.core.agents.saveAgent({
|
||||
companyId,
|
||||
name: "Research Lead",
|
||||
role: "Researcher",
|
||||
title: "Research Lead",
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: "gpt-5.4",
|
||||
capabilities: "Research and synthesis",
|
||||
budgetMonthlyUsd: 180,
|
||||
department: "research",
|
||||
});
|
||||
const taskId = db.core.tasks.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.core.documents.saveDocument({
|
||||
companyId,
|
||||
type: "knowledge_article",
|
||||
title: "Market report",
|
||||
content: "Draft report body",
|
||||
authorAgentId: agentId,
|
||||
status: "in_review",
|
||||
});
|
||||
const approvalId = db.core.approvals.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.core.approvals.decideApproval({
|
||||
approvalId,
|
||||
state: "revision_requested",
|
||||
decisionNote: "Add stronger competitor evidence and a clearer recommendation.",
|
||||
});
|
||||
|
||||
const dispatchRunToWorker = vi.fn();
|
||||
const heartbeat = createHeartbeat(
|
||||
{
|
||||
db,
|
||||
logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
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: {
|
||||
autoCloseExpiredIncidents: vi.fn(() => 0),
|
||||
evaluateBudgetGate: vi.fn(() => ({ allowed: true, blockedBy: [] })),
|
||||
} as never,
|
||||
checkpointEngine: { shouldPauseHeartbeat: () => false },
|
||||
},
|
||||
() => ({
|
||||
workspace: null,
|
||||
lockMode: "none",
|
||||
source: "none",
|
||||
observationPath: profileDir,
|
||||
}),
|
||||
() => null,
|
||||
dispatchRunToWorker,
|
||||
(filter) => db.core.runs.hasActiveRun(filter),
|
||||
);
|
||||
|
||||
handleApprovalResolved({
|
||||
db,
|
||||
logger: { info: () => {}, warn: () => {}, error: () => {} },
|
||||
publishDomainChanged: () => {},
|
||||
notify: vi.fn(),
|
||||
wakeAgentIfPossible: heartbeat.wakeAgentIfPossible,
|
||||
queueBrowserActionExecution: () => {},
|
||||
isQuitting: () => false,
|
||||
}, {
|
||||
dispatchAutomation: vi.fn(),
|
||||
handleTaskStatusChange: vi.fn(),
|
||||
runWorkflowPipeline: async () => {},
|
||||
trackTimer: vi.fn(),
|
||||
}, approvalId, companyId, "revision_requested");
|
||||
|
||||
const task = db.core.entity.getTask(taskId);
|
||||
expect(task.status).toBe("in_progress");
|
||||
expect(db.core.runs.hasActiveRun({ companyId, taskId, agentId })).toBe(true);
|
||||
expect(dispatchRunToWorker).toHaveBeenCalledTimes(1);
|
||||
expect(dispatchRunToWorker).toHaveBeenCalledWith(expect.objectContaining({
|
||||
companyId,
|
||||
task: expect.objectContaining({ id: taskId }),
|
||||
trigger: "revision_needed",
|
||||
}));
|
||||
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
@@ -1,66 +0,0 @@
|
||||
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 type { AppDatabase } from "../../src/main/database";
|
||||
import {
|
||||
captureBaselineIfRequired,
|
||||
reconcileWorkspaceArtifacts,
|
||||
type ArtifactProjectorRunContext,
|
||||
} from "../../src/main/agent-runtime/artifact-projector";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
async function createTempDir(prefix: string): Promise<string> {
|
||||
const dir = await mkdtemp(join(tmpdir(), prefix));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
function createLogger() {
|
||||
return {
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe("artifact projector error taxonomy", () => {
|
||||
it("classifies unreadable workspace baseline capture failures", async () => {
|
||||
const logger = createLogger();
|
||||
const parent = await createTempDir("agentcompany-missing-workspace-parent-");
|
||||
const workspacePath = join(parent, "missing-workspace");
|
||||
|
||||
const baseline = captureBaselineIfRequired(workspacePath, logger);
|
||||
|
||||
expect(baseline).toBeNull();
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
"[artifact.fs.workspace_stat_failed] Failed to stat workspace before artifact baseline capture.",
|
||||
{ workspacePath },
|
||||
expect.stringContaining("cause="),
|
||||
);
|
||||
expect(logger.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips completion reconciliation when the run has no change source", async () => {
|
||||
const workspacePath = await createTempDir("agentcompany-shared-artifacts-");
|
||||
const ctx: ArtifactProjectorRunContext = {
|
||||
runId: "run-shared",
|
||||
agentId: "agent-shared",
|
||||
companyId: "company-shared",
|
||||
taskId: "task-shared",
|
||||
workspaceId: null,
|
||||
workspacePath,
|
||||
workspaceMode: "none",
|
||||
logger: createLogger(),
|
||||
workspaceBaseline: null,
|
||||
};
|
||||
|
||||
expect(reconcileWorkspaceArtifacts({} as AppDatabase, ctx)).toEqual([
|
||||
{ kind: "skipped", reason: "no_baseline" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,595 +0,0 @@
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("electron", () => ({
|
||||
safeStorage: {
|
||||
isEncryptionAvailable: () => false,
|
||||
getSelectedStorageBackend: () => "basic_text",
|
||||
encryptString: (value: string) => Buffer.from(value, "utf8"),
|
||||
decryptString: (value: Buffer) => value.toString("utf8"),
|
||||
},
|
||||
}));
|
||||
|
||||
import { AppDatabase, STALE_LOCK_TIMEOUT_MS } from "../../src/main/database";
|
||||
|
||||
type RawDb = {
|
||||
db: {
|
||||
prepare: (sql: string) => {
|
||||
run: (...args: unknown[]) => void;
|
||||
get: (...args: unknown[]) => unknown;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type TaskLeaseRow = {
|
||||
task_id: string;
|
||||
run_id: string;
|
||||
expires_at: string;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createTestCompany(db: AppDatabase): string {
|
||||
return db.core.company.saveCompany({
|
||||
name: "Test Company",
|
||||
description: "Atomic checkout tests",
|
||||
status: "active",
|
||||
});
|
||||
}
|
||||
|
||||
function createTestConnector(db: AppDatabase): void {
|
||||
db.core.connectors.saveConnector({
|
||||
id: "openai-codex",
|
||||
model: "gpt-5",
|
||||
envBindingText: "",
|
||||
notes: "Test connector",
|
||||
});
|
||||
}
|
||||
|
||||
function createTestWorkspace(db: AppDatabase, companyId: string): string {
|
||||
return db.core.workspaces.saveWorkspace({
|
||||
companyId,
|
||||
name: "Test Workspace",
|
||||
localPath: "/tmp/test-workspace",
|
||||
repoUrl: "https://github.com/example/test",
|
||||
repoRef: "main",
|
||||
isPrimary: true,
|
||||
});
|
||||
}
|
||||
|
||||
function createTestAgent(db: AppDatabase, companyId: string, workspaceId: string, name = "Agent"): string {
|
||||
return db.core.agents.saveAgent({
|
||||
companyId,
|
||||
name,
|
||||
role: "IC",
|
||||
title: name,
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Executes tasks",
|
||||
budgetMonthlyUsd: 100,
|
||||
});
|
||||
}
|
||||
|
||||
function createTestTask(db: AppDatabase, companyId: string, agentId: string | null, workspaceId: string): string {
|
||||
return db.core.tasks.saveTask({
|
||||
companyId,
|
||||
title: "Test Task",
|
||||
description: "A test task for checkout",
|
||||
assigneeAgentId: agentId,
|
||||
workspaceId,
|
||||
priority: "medium",
|
||||
status: "todo",
|
||||
});
|
||||
}
|
||||
|
||||
function getTaskLease(db: AppDatabase, taskId: string): TaskLeaseRow | null {
|
||||
return (
|
||||
((db as unknown as RawDb).db
|
||||
.prepare("SELECT task_id, run_id, expires_at FROM task_leases WHERE task_id = ?")
|
||||
.get(taskId) as TaskLeaseRow | undefined) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("CAS checkout", () => {
|
||||
let tempDir: string;
|
||||
let db: AppDatabase;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "agentcompany-checkout-"));
|
||||
db = new AppDatabase(tempDir);
|
||||
db.init();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
db.close();
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("CAS checkout succeeds for first claimer", () => {
|
||||
const companyId = createTestCompany(db);
|
||||
createTestConnector(db);
|
||||
const workspaceId = createTestWorkspace(db, companyId);
|
||||
const agentId = createTestAgent(db, companyId, workspaceId);
|
||||
const taskId = createTestTask(db, companyId, null, workspaceId);
|
||||
|
||||
const { id: runId } = db.core.runs.createRun({
|
||||
companyId,
|
||||
taskId,
|
||||
agentId,
|
||||
workspaceId,
|
||||
connectorId: "openai-codex",
|
||||
});
|
||||
|
||||
const result = db.core.tasks.checkoutTask(taskId, runId);
|
||||
expect(result).toBe(true);
|
||||
|
||||
const task = db.core.entity.getTask(taskId);
|
||||
expect(task.status).toBe("in_progress");
|
||||
});
|
||||
|
||||
it("createRun does not claim a task before checkout", () => {
|
||||
const companyId = createTestCompany(db);
|
||||
createTestConnector(db);
|
||||
const workspaceId = createTestWorkspace(db, companyId);
|
||||
const agentId = createTestAgent(db, companyId, workspaceId);
|
||||
const taskId = createTestTask(db, companyId, null, workspaceId);
|
||||
|
||||
db.core.runs.createRun({
|
||||
companyId,
|
||||
taskId,
|
||||
agentId,
|
||||
workspaceId,
|
||||
connectorId: "openai-codex",
|
||||
});
|
||||
|
||||
const task = db.core.entity.getTask(taskId);
|
||||
expect(task.activeRunId).toBeNull();
|
||||
expect(task.status).toBe("todo");
|
||||
});
|
||||
|
||||
it("checkout rejects a missing run without mutating task state", () => {
|
||||
const companyId = createTestCompany(db);
|
||||
createTestConnector(db);
|
||||
const workspaceId = createTestWorkspace(db, companyId);
|
||||
const taskId = createTestTask(db, companyId, null, workspaceId);
|
||||
const missingRunId = randomUUID();
|
||||
|
||||
expect(() => db.core.tasks.checkoutTask(taskId, missingRunId)).toThrow(/Run not found/);
|
||||
|
||||
const task = db.core.entity.getTask(taskId);
|
||||
expect(task.activeRunId).toBeNull();
|
||||
expect(task.status).toBe("todo");
|
||||
expect(getTaskLease(db, taskId)).toBeNull();
|
||||
});
|
||||
|
||||
it("failed checkout keeps a taskless run unbound", () => {
|
||||
const companyId = createTestCompany(db);
|
||||
createTestConnector(db);
|
||||
const workspaceId = createTestWorkspace(db, companyId);
|
||||
const agentId = createTestAgent(db, companyId, workspaceId);
|
||||
const taskId = createTestTask(db, companyId, null, workspaceId);
|
||||
|
||||
(db as unknown as RawDb).db.prepare("UPDATE tasks SET status = 'in_progress' WHERE id = ?").run(taskId);
|
||||
const { id: runId } = db.core.runs.createRun({
|
||||
companyId,
|
||||
taskId: null,
|
||||
agentId,
|
||||
workspaceId,
|
||||
connectorId: "openai-codex",
|
||||
});
|
||||
|
||||
expect(db.core.tasks.checkoutTask(taskId, runId)).toBe(false);
|
||||
expect(db.core.runs.getRun(runId).taskId).toBeNull();
|
||||
expect(getTaskLease(db, taskId)).toBeNull();
|
||||
expect(db.core.runs.hasActiveRun({ companyId, taskId })).toBe(false);
|
||||
});
|
||||
|
||||
it("createTaskRun atomically creates the run and claims the task row lock", () => {
|
||||
const companyId = createTestCompany(db);
|
||||
createTestConnector(db);
|
||||
const workspaceId = createTestWorkspace(db, companyId);
|
||||
const agentId = createTestAgent(db, companyId, workspaceId);
|
||||
const taskId = createTestTask(db, companyId, null, workspaceId);
|
||||
|
||||
const run = db.core.runs.createTaskRun({
|
||||
companyId,
|
||||
taskId,
|
||||
agentId,
|
||||
workspaceId,
|
||||
connectorId: "openai-codex",
|
||||
});
|
||||
|
||||
expect(run).not.toBeNull();
|
||||
const task = db.core.entity.getTask(taskId);
|
||||
expect(task.status).toBe("in_progress");
|
||||
expect(task.activeRunId).toBe(run?.id);
|
||||
expect(getTaskLease(db, taskId)?.run_id).toBe(run?.id);
|
||||
});
|
||||
|
||||
it("createTaskRun rolls back the run row when checkout loses the race", () => {
|
||||
const companyId = createTestCompany(db);
|
||||
createTestConnector(db);
|
||||
const workspaceId = createTestWorkspace(db, companyId);
|
||||
const agentId = createTestAgent(db, companyId, workspaceId);
|
||||
const taskId = createTestTask(db, companyId, null, workspaceId);
|
||||
|
||||
const firstRun = db.core.runs.createTaskRun({
|
||||
companyId,
|
||||
taskId,
|
||||
agentId,
|
||||
workspaceId,
|
||||
connectorId: "openai-codex",
|
||||
});
|
||||
expect(firstRun).not.toBeNull();
|
||||
|
||||
const losingRunId = randomUUID();
|
||||
const secondRun = db.core.runs.createTaskRun({
|
||||
id: losingRunId,
|
||||
companyId,
|
||||
taskId,
|
||||
agentId,
|
||||
workspaceId,
|
||||
connectorId: "openai-codex",
|
||||
});
|
||||
|
||||
expect(secondRun).toBeNull();
|
||||
const stored = (db as unknown as RawDb).db
|
||||
.prepare("SELECT id FROM runs WHERE id = ?")
|
||||
.get(losingRunId);
|
||||
expect(stored).toBeUndefined();
|
||||
});
|
||||
|
||||
it("CAS checkout fails for second claimer", () => {
|
||||
const companyId = createTestCompany(db);
|
||||
createTestConnector(db);
|
||||
const workspaceId = createTestWorkspace(db, companyId);
|
||||
const agentId = createTestAgent(db, companyId, workspaceId);
|
||||
const taskId = createTestTask(db, companyId, null, workspaceId);
|
||||
|
||||
const { id: runId1 } = db.core.runs.createRun({
|
||||
companyId,
|
||||
taskId,
|
||||
agentId,
|
||||
workspaceId,
|
||||
connectorId: "openai-codex",
|
||||
});
|
||||
const { id: runId2 } = db.core.runs.createRun({
|
||||
companyId,
|
||||
taskId,
|
||||
agentId,
|
||||
workspaceId,
|
||||
connectorId: "openai-codex",
|
||||
});
|
||||
|
||||
expect(db.core.tasks.checkoutTask(taskId, runId1)).toBe(true);
|
||||
expect(db.core.tasks.checkoutTask(taskId, runId2)).toBe(false);
|
||||
});
|
||||
|
||||
it("CAS checkout rejects already-in-progress tasks", () => {
|
||||
const companyId = createTestCompany(db);
|
||||
createTestConnector(db);
|
||||
const workspaceId = createTestWorkspace(db, companyId);
|
||||
const agentId = createTestAgent(db, companyId, workspaceId);
|
||||
const taskId = createTestTask(db, companyId, null, workspaceId);
|
||||
|
||||
// Manually set task to in_progress
|
||||
(db as unknown as RawDb).db.prepare("UPDATE tasks SET status = 'in_progress' WHERE id = ?").run(taskId);
|
||||
|
||||
const { id: runId } = db.core.runs.createRun({
|
||||
companyId,
|
||||
taskId,
|
||||
agentId,
|
||||
workspaceId,
|
||||
connectorId: "openai-codex",
|
||||
});
|
||||
|
||||
expect(db.core.tasks.checkoutTask(taskId, runId)).toBe(false);
|
||||
});
|
||||
|
||||
it("lock release in finishRun clears the task lease", () => {
|
||||
const companyId = createTestCompany(db);
|
||||
createTestConnector(db);
|
||||
const workspaceId = createTestWorkspace(db, companyId);
|
||||
const agentId = createTestAgent(db, companyId, workspaceId);
|
||||
const taskId = createTestTask(db, companyId, null, workspaceId);
|
||||
|
||||
const { id: runId } = db.core.runs.createRun({
|
||||
companyId,
|
||||
taskId,
|
||||
agentId,
|
||||
workspaceId,
|
||||
connectorId: "openai-codex",
|
||||
});
|
||||
|
||||
db.core.tasks.checkoutTask(taskId, runId);
|
||||
db.core.runs.markRunStatus(runId, "running", "");
|
||||
db.core.runs.finishRun({
|
||||
runId,
|
||||
status: "succeeded",
|
||||
summary: "Done",
|
||||
errorMessage: null,
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
model: null,
|
||||
unattributedCost: false,
|
||||
});
|
||||
|
||||
expect(getTaskLease(db, taskId)).toBeNull();
|
||||
});
|
||||
|
||||
it("lock release in cancelRunForDeletion clears the task lease", () => {
|
||||
const companyId = createTestCompany(db);
|
||||
createTestConnector(db);
|
||||
const workspaceId = createTestWorkspace(db, companyId);
|
||||
const agentId = createTestAgent(db, companyId, workspaceId);
|
||||
const taskId = createTestTask(db, companyId, null, workspaceId);
|
||||
|
||||
const { id: runId } = db.core.runs.createRun({
|
||||
companyId,
|
||||
taskId,
|
||||
agentId,
|
||||
workspaceId,
|
||||
connectorId: "openai-codex",
|
||||
});
|
||||
|
||||
db.core.tasks.checkoutTask(taskId, runId);
|
||||
db.core.runs.markRunStatus(runId, "running", "");
|
||||
|
||||
// Deleting the agent triggers cancelRunForDeletion for active runs
|
||||
db.core.agents.deleteAgent(agentId, companyId);
|
||||
|
||||
expect(getTaskLease(db, taskId)).toBeNull();
|
||||
});
|
||||
|
||||
it("stale lock recovery resets tasks older than 15 minutes", () => {
|
||||
const companyId = createTestCompany(db);
|
||||
createTestConnector(db);
|
||||
const workspaceId = createTestWorkspace(db, companyId);
|
||||
const agentId = createTestAgent(db, companyId, workspaceId);
|
||||
const taskId = createTestTask(db, companyId, null, workspaceId);
|
||||
|
||||
const { id: runId } = db.core.runs.createRun({
|
||||
companyId,
|
||||
taskId,
|
||||
agentId,
|
||||
workspaceId,
|
||||
connectorId: "openai-codex",
|
||||
});
|
||||
|
||||
db.core.tasks.checkoutTask(taskId, runId);
|
||||
|
||||
// Backdate the lease expiry to 20 minutes ago.
|
||||
const backdated = new Date(Date.now() - 20 * 60 * 1000).toISOString();
|
||||
(db as unknown as RawDb).db
|
||||
.prepare("UPDATE task_leases SET expires_at = ? WHERE task_id = ?")
|
||||
.run(backdated, taskId);
|
||||
|
||||
const recovered = db.recoverStaleLocks();
|
||||
|
||||
expect(recovered.length).toBe(1);
|
||||
expect(recovered[0].taskId).toBe(taskId);
|
||||
expect(recovered[0].companyId).toBe(companyId);
|
||||
|
||||
const task = db.core.entity.getTask(taskId);
|
||||
expect(task.status).toBe("todo");
|
||||
|
||||
expect(getTaskLease(db, taskId)).toBeNull();
|
||||
});
|
||||
|
||||
it("stale lock recovery ignores tasks within 15 minute window", () => {
|
||||
const companyId = createTestCompany(db);
|
||||
createTestConnector(db);
|
||||
const workspaceId = createTestWorkspace(db, companyId);
|
||||
const agentId = createTestAgent(db, companyId, workspaceId);
|
||||
const taskId = createTestTask(db, companyId, null, workspaceId);
|
||||
|
||||
const { id: runId } = db.core.runs.createRun({
|
||||
companyId,
|
||||
taskId,
|
||||
agentId,
|
||||
workspaceId,
|
||||
connectorId: "openai-codex",
|
||||
});
|
||||
|
||||
db.core.tasks.checkoutTask(taskId, runId);
|
||||
|
||||
const recovered = db.recoverStaleLocks();
|
||||
expect(recovered.length).toBe(0);
|
||||
|
||||
const task = db.core.entity.getTask(taskId);
|
||||
expect(task.status).toBe("in_progress");
|
||||
});
|
||||
|
||||
it("STALE_LOCK_TIMEOUT_MS is 15 minutes", () => {
|
||||
expect(STALE_LOCK_TIMEOUT_MS).toBe(15 * 60 * 1000);
|
||||
});
|
||||
|
||||
it("failed dispatch (run never started) rolls task back to 'todo' so heartbeat can retry", () => {
|
||||
// Regression: workspace-lock contention calls finishRun('failed') on a
|
||||
// run that never reached 'running'. The lease must clear and the task
|
||||
// status must return to 'todo' so the heartbeat retry path can reclaim it.
|
||||
const companyId = createTestCompany(db);
|
||||
createTestConnector(db);
|
||||
const workspaceId = createTestWorkspace(db, companyId);
|
||||
const agentId = createTestAgent(db, companyId, workspaceId);
|
||||
const taskId = createTestTask(db, companyId, null, workspaceId);
|
||||
|
||||
const { id: runId } = db.core.runs.createRun({
|
||||
companyId,
|
||||
taskId,
|
||||
agentId,
|
||||
workspaceId,
|
||||
connectorId: "openai-codex",
|
||||
});
|
||||
db.core.tasks.checkoutTask(taskId, runId);
|
||||
expect(db.core.entity.getTask(taskId).status).toBe("in_progress");
|
||||
|
||||
// Never call markRunStatus('running', ...) — this simulates the
|
||||
// "workspace already locked" bail-out inside dispatchRunToWorker.
|
||||
db.core.runs.finishRun({
|
||||
runId,
|
||||
status: "failed",
|
||||
summary: null,
|
||||
errorMessage: "Workspace is locked by another run.",
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
model: null,
|
||||
unattributedCost: true,
|
||||
});
|
||||
|
||||
expect(db.core.entity.getTask(taskId).status).toBe("todo");
|
||||
|
||||
// And the next CAS checkout must succeed — proving the heartbeat
|
||||
// retry path is unblocked.
|
||||
const { id: retryRunId } = db.core.runs.createRun({
|
||||
companyId,
|
||||
taskId,
|
||||
agentId,
|
||||
workspaceId,
|
||||
connectorId: "openai-codex",
|
||||
});
|
||||
expect(db.core.tasks.checkoutTask(taskId, retryRunId)).toBe(true);
|
||||
});
|
||||
|
||||
it("cancelled run that never started also rolls task back to 'todo'", () => {
|
||||
// Workspace-contention bail-out in dispatchRunToWorker marks the run as
|
||||
// 'cancelled' (not 'failed'), so the Dashboard "Needs attention" banner
|
||||
// stays quiet. The rollback path must handle cancelled too, or the task
|
||||
// would stick in 'in_progress' just like the original 'failed' bug.
|
||||
const companyId = createTestCompany(db);
|
||||
createTestConnector(db);
|
||||
const workspaceId = createTestWorkspace(db, companyId);
|
||||
const agentId = createTestAgent(db, companyId, workspaceId);
|
||||
const taskId = createTestTask(db, companyId, null, workspaceId);
|
||||
|
||||
const { id: runId } = db.core.runs.createRun({
|
||||
companyId,
|
||||
taskId,
|
||||
agentId,
|
||||
workspaceId,
|
||||
connectorId: "openai-codex",
|
||||
});
|
||||
db.core.tasks.checkoutTask(taskId, runId);
|
||||
db.core.runs.finishRun({
|
||||
runId,
|
||||
status: "cancelled",
|
||||
summary: null,
|
||||
errorMessage: "Workspace in use by another run; will retry.",
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
model: null,
|
||||
unattributedCost: true,
|
||||
});
|
||||
|
||||
expect(db.core.entity.getTask(taskId).status).toBe("todo");
|
||||
});
|
||||
|
||||
it("failed run that did start stays 'in_progress' (human retry path)", () => {
|
||||
// Counterpart to the regression above: a run that actually transitioned
|
||||
// to 'running' and then failed mid-flight is the user's problem to
|
||||
// resolve through the recovery UI — we must not silently reset its
|
||||
// task to 'todo' and lose the partial work / error context.
|
||||
const companyId = createTestCompany(db);
|
||||
createTestConnector(db);
|
||||
const workspaceId = createTestWorkspace(db, companyId);
|
||||
const agentId = createTestAgent(db, companyId, workspaceId);
|
||||
const taskId = createTestTask(db, companyId, null, workspaceId);
|
||||
|
||||
const { id: runId } = db.core.runs.createRun({
|
||||
companyId,
|
||||
taskId,
|
||||
agentId,
|
||||
workspaceId,
|
||||
connectorId: "openai-codex",
|
||||
});
|
||||
db.core.tasks.checkoutTask(taskId, runId);
|
||||
db.core.runs.markRunStatus(runId, "running", "");
|
||||
db.core.runs.finishRun({
|
||||
runId,
|
||||
status: "failed",
|
||||
summary: null,
|
||||
errorMessage: "Worker crashed",
|
||||
exitCode: 1,
|
||||
signal: null,
|
||||
model: null,
|
||||
unattributedCost: false,
|
||||
});
|
||||
|
||||
expect(db.core.entity.getTask(taskId).status).toBe("in_progress");
|
||||
});
|
||||
|
||||
it("advanceTaskStatus('in_review') rejects a CEO self-review that has no reviewer", () => {
|
||||
// Regression guard for the CEO dead-letter case: a task whose
|
||||
// assignee is a top-level agent (reports_to IS NULL) and that is
|
||||
// not flagged for user review has no reachable reviewer. Previous
|
||||
// versions silently resurrected these tasks at every DB init; the
|
||||
// root fix rejects the transition at source so callers either
|
||||
// escalate to the human or leave the task in a reclaimable state.
|
||||
const companyId = createTestCompany(db);
|
||||
createTestConnector(db);
|
||||
const workspaceId = createTestWorkspace(db, companyId);
|
||||
const ceoId = createTestAgent(db, companyId, workspaceId, "CEO");
|
||||
const taskId = createTestTask(db, companyId, ceoId, workspaceId);
|
||||
|
||||
expect(() => db.core.tasks.advanceTaskStatus(taskId, "in_review")).toThrow(/no reviewer/);
|
||||
// Task status must remain unchanged after the rejected transition.
|
||||
expect(db.core.entity.getTask(taskId).status).toBe("todo");
|
||||
});
|
||||
|
||||
it("advanceTaskStatus('in_review') accepts a CEO assignee flagged requires_user_review=1", () => {
|
||||
// The same task becomes legitimate when the human is the reviewer.
|
||||
const companyId = createTestCompany(db);
|
||||
createTestConnector(db);
|
||||
const workspaceId = createTestWorkspace(db, companyId);
|
||||
const ceoId = createTestAgent(db, companyId, workspaceId, "CEO");
|
||||
const taskId = createTestTask(db, companyId, ceoId, workspaceId);
|
||||
db.core.tasks.setTaskRequiresUserReview(taskId, true);
|
||||
|
||||
db.core.tasks.advanceTaskStatus(taskId, "in_review");
|
||||
expect(db.core.entity.getTask(taskId).status).toBe("in_review");
|
||||
});
|
||||
|
||||
it("interruptStaleRuns surfaces shutdown-era running runs without startup task rewrites", () => {
|
||||
// `interruptStaleRuns` is the ONLY mutation the startup path may
|
||||
// perform: any run at 'running' on boot means a crash, so the run
|
||||
// flips to 'interrupted' and the task surfaces as 'blocked' for
|
||||
// operator attention. No other reconciliation runs at init; the
|
||||
// task-status machine (see guardInReviewHasReviewer) and the
|
||||
// runtime `finishRun` guards are now responsible for every other
|
||||
// data-repair concern.
|
||||
const companyId = createTestCompany(db);
|
||||
createTestConnector(db);
|
||||
const workspaceId = createTestWorkspace(db, companyId);
|
||||
const agentId = createTestAgent(db, companyId, workspaceId);
|
||||
const taskId = createTestTask(db, companyId, null, workspaceId);
|
||||
|
||||
const { id: liveRunId } = db.core.runs.createRun({
|
||||
companyId,
|
||||
taskId,
|
||||
agentId,
|
||||
workspaceId,
|
||||
connectorId: "openai-codex",
|
||||
});
|
||||
db.core.tasks.checkoutTask(taskId, liveRunId);
|
||||
db.core.runs.markRunStatus(liveRunId, "running", "");
|
||||
|
||||
db.close();
|
||||
db = new AppDatabase(tempDir);
|
||||
db.init();
|
||||
|
||||
expect(db.lastInterruptedCount).toBe(1);
|
||||
expect(db.core.entity.getTask(taskId).status).toBe("blocked");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { DatabaseConnection } from "../../src/main/database/connection";
|
||||
import { AuditService } from "../../src/main/services/audit-service";
|
||||
import { ChatService } from "../../src/main/services/chat-service";
|
||||
import { OrganizationService } from "../../src/main/services/organization-service";
|
||||
import { SnapshotService } from "../../src/main/services/snapshot-service";
|
||||
|
||||
function createConnection(): DatabaseConnection {
|
||||
return new DatabaseConnection(join(tmpdir(), `agentcompany-new-audit-${randomUUID()}.db`));
|
||||
}
|
||||
|
||||
describe("audit service", () => {
|
||||
test("keeps core audit events immutable", () => {
|
||||
const connection = createConnection();
|
||||
const organization = new OrganizationService(connection.db);
|
||||
let auditId = "";
|
||||
|
||||
connection.transaction(() => {
|
||||
const orgId = organization.createOrganization({
|
||||
organizationName: "Acme Operations",
|
||||
employeeName: "Dana Lee",
|
||||
mission: "Coordinate agents with auditable approvals.",
|
||||
goal: "Ship a verified control plane.",
|
||||
});
|
||||
auditId = (connection.db.prepare("select id from audit_events where org_id = ? limit 1").get(orgId) as { id: string }).id;
|
||||
});
|
||||
|
||||
expect(() => connection.db.prepare("update audit_events set action = ? where id = ?")
|
||||
.run("changed", auditId)).toThrow("Audit events are immutable.");
|
||||
expect(() => connection.db.prepare("delete from audit_events where id = ?")
|
||||
.run(auditId)).toThrow("Audit events are immutable.");
|
||||
connection.close();
|
||||
});
|
||||
|
||||
test("creates thread scoped replay runs from immutable audit events", () => {
|
||||
const connection = createConnection();
|
||||
const organization = new OrganizationService(connection.db);
|
||||
const chat = new ChatService(connection.db);
|
||||
const audit = new AuditService(connection.db);
|
||||
const snapshot = new SnapshotService(connection.db);
|
||||
let orgId = "";
|
||||
let employeeId = "";
|
||||
let threadId = "";
|
||||
|
||||
connection.transaction(() => {
|
||||
orgId = organization.createOrganization({
|
||||
organizationName: "Acme Operations",
|
||||
employeeName: "Dana Lee",
|
||||
mission: "Coordinate agents with auditable approvals.",
|
||||
goal: "Ship a verified control plane.",
|
||||
});
|
||||
employeeId = (connection.db.prepare("select id from employees where org_id = ?").get(orgId) as { id: string }).id;
|
||||
const spaceId = chat.createSpace({
|
||||
orgId,
|
||||
spaceType: "project",
|
||||
name: "release",
|
||||
topic: "Release coordination",
|
||||
visibility: "workspace",
|
||||
});
|
||||
const messageId = chat.sendMessage({
|
||||
orgId,
|
||||
spaceId,
|
||||
threadId: null,
|
||||
senderType: "employee",
|
||||
senderId: employeeId,
|
||||
bodyMd: "/escalate review release plan",
|
||||
attachmentFileIds: [],
|
||||
});
|
||||
threadId = (connection.db.prepare("select thread_id from chat_messages where id = ?").get(messageId) as { thread_id: string }).thread_id;
|
||||
audit.createThreadReplay({ orgId, threadId, createdByEmployeeId: employeeId });
|
||||
});
|
||||
|
||||
const state = snapshot.load();
|
||||
const run = state.auditReplayRuns[0];
|
||||
if (!run) throw new Error("Audit replay run was not created.");
|
||||
const replay = JSON.parse(run.replayJson) as { events: Array<{ entityType: string; action: string }> };
|
||||
expect(run.threadId).toBe(threadId);
|
||||
expect(run.eventDigest).toHaveLength(64);
|
||||
expect(run.eventCount).toBe(replay.events.length);
|
||||
expect(replay.events).toContainEqual(expect.objectContaining({ entityType: "chat_message", action: "sent" }));
|
||||
expect(replay.events).toContainEqual(expect.objectContaining({ entityType: "approval", action: "requested" }));
|
||||
expect(replay.events.some((event) => event.entityType === "audit_replay_run")).toBe(false);
|
||||
expect(state.auditEvents.some((event) => event.entityType === "audit_replay_run" && event.action === "created")).toBe(true);
|
||||
connection.close();
|
||||
});
|
||||
});
|
||||
@@ -1,48 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { AUTOMATION_ACTION_HANDLERS } from "../../src/main/automation/automation-actions";
|
||||
import {
|
||||
ACTION_SCHEMAS,
|
||||
AUTOMATION_ACTIONS,
|
||||
WORKFLOW_ACTIONS,
|
||||
isAutomationAction,
|
||||
parseActionConfig,
|
||||
} from "../../src/main/automation/automation-action-schemas";
|
||||
import { normalizeWorkflowSteps } from "../../src/main/automation/automation-workflow";
|
||||
|
||||
describe("automation action registry", () => {
|
||||
it("drives schemas, workflow validation, and execution handlers from one action set", () => {
|
||||
const actionNames = [...AUTOMATION_ACTIONS].sort();
|
||||
expect([...WORKFLOW_ACTIONS].sort()).toEqual(actionNames);
|
||||
expect(Object.keys(ACTION_SCHEMAS).sort()).toEqual(actionNames);
|
||||
expect(Object.keys(AUTOMATION_ACTION_HANDLERS).sort()).toEqual(actionNames);
|
||||
|
||||
for (const action of AUTOMATION_ACTIONS) {
|
||||
expect(isAutomationAction(action)).toBe(true);
|
||||
expect(AUTOMATION_ACTION_HANDLERS[action]).toEqual(expect.any(Function));
|
||||
expect(() => parseActionConfig(action, "{}")).not.toThrow();
|
||||
expect(normalizeWorkflowSteps([{ action, configJson: "{}" }])[0]).toMatchObject({
|
||||
action,
|
||||
status: "pending",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects unsupported workflow actions before execution", () => {
|
||||
expect(isAutomationAction("unknown_action")).toBe(false);
|
||||
expect(() => normalizeWorkflowSteps([{ action: "unknown_action" }])).toThrow(
|
||||
'Workflow step 1 has unsupported action "unknown_action".',
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps automation execution table-driven", () => {
|
||||
const dispatchSource = readFileSync(join(process.cwd(), "src/main/automation/automation-actions.ts"), "utf8");
|
||||
const handlerSource = readFileSync(join(process.cwd(), "src/main/automation/automation-action-handlers.ts"), "utf8");
|
||||
|
||||
expect(dispatchSource).not.toContain("switch (action)");
|
||||
expect(handlerSource).not.toContain("switch (action)");
|
||||
expect(dispatchSource).toContain("AUTOMATION_ACTION_HANDLERS");
|
||||
expect(handlerSource).toContain("satisfies Record<AutomationAction, AutomationActionHandler>");
|
||||
});
|
||||
});
|
||||
@@ -1,133 +0,0 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { AppDatabase } from "../../src/main/database";
|
||||
import { createBrowserActionService } from "../../src/main/browser-action-service";
|
||||
import { recoverScheduledPosts } from "../../src/main/bootstrap/recover-scheduled-posts";
|
||||
import type { BrowserManager } from "../../src/main/browser-manager";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
async function createTempDir(prefix: string) {
|
||||
const dir = await mkdtemp(join(tmpdir(), prefix));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
async function nextTick(): Promise<void> {
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
}
|
||||
|
||||
async function delay(ms: number): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function seedBrowserAction(db: AppDatabase, profileDir: string, input?: {
|
||||
payloadJson?: string;
|
||||
approvalId?: string | null;
|
||||
}): { companyId: string; actionId: string } {
|
||||
const companyId = db.core.company.saveCompany({
|
||||
name: "Browser Queue Co",
|
||||
description: "Queue contract test",
|
||||
status: "active",
|
||||
});
|
||||
const workspaceId = db.core.workspaces.saveWorkspace({
|
||||
companyId,
|
||||
name: "Primary",
|
||||
localPath: profileDir,
|
||||
repoUrl: "",
|
||||
repoRef: "",
|
||||
isPrimary: true,
|
||||
});
|
||||
const agentId = db.core.agents.saveAgent({
|
||||
companyId,
|
||||
name: "CEO",
|
||||
role: "CEO",
|
||||
title: "Chief Executive Officer",
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Lead the company",
|
||||
budgetMonthlyUsd: 250,
|
||||
});
|
||||
const socialAccountId = db.core.social.saveSocialAccount({
|
||||
companyId,
|
||||
platform: "twitter",
|
||||
accountName: "agentcompany",
|
||||
status: "ready",
|
||||
requireApproval: false,
|
||||
});
|
||||
const actionId = db.core.social.saveBrowserAction({
|
||||
companyId,
|
||||
socialAccountId,
|
||||
agentId,
|
||||
actionType: "post",
|
||||
payloadJson: input?.payloadJson ?? JSON.stringify({ content: "hello" }),
|
||||
status: "queued",
|
||||
approvalId: input?.approvalId ?? null,
|
||||
});
|
||||
return { companyId, actionId };
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe("browser action queue", () => {
|
||||
it("marks queued actions failed and records activity when dispatch fails before execution", async () => {
|
||||
const profileDir = await createTempDir("agentcompany-browser-queue-");
|
||||
const db = new AppDatabase(profileDir);
|
||||
db.init();
|
||||
const { companyId, actionId } = seedBrowserAction(db, profileDir, {
|
||||
approvalId: "missing-approval",
|
||||
});
|
||||
let published = 0;
|
||||
const service = createBrowserActionService({
|
||||
db,
|
||||
browserManagerRef: { current: null as BrowserManager | null },
|
||||
publishDomainChanged: () => { published += 1; },
|
||||
});
|
||||
|
||||
service.queueBrowserActionExecution(actionId, companyId);
|
||||
await nextTick();
|
||||
|
||||
const action = db.core.social.getBrowserAction(actionId, companyId);
|
||||
expect(action?.status).toBe("failed");
|
||||
expect(action?.errorMessage).toContain("missing-approval");
|
||||
const activity = db.projection.snapshot.listSnapshot({ companyId }).activity;
|
||||
expect(activity.some((entry) => entry.action === "browser.action_dispatch_failed" && entry.entityId === actionId)).toBe(true);
|
||||
expect(published).toBeGreaterThan(0);
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("startup recovery schedules queued browser actions through the queue wrapper", async () => {
|
||||
const profileDir = await createTempDir("agentcompany-browser-recovery-");
|
||||
const db = new AppDatabase(profileDir);
|
||||
db.init();
|
||||
const scheduledAt = new Date(Date.now() + 40).toISOString();
|
||||
const { companyId, actionId } = seedBrowserAction(db, profileDir, {
|
||||
payloadJson: JSON.stringify({ content: "scheduled", scheduledAt }),
|
||||
});
|
||||
const calls: Array<{ actionId: string; companyId: string }> = [];
|
||||
|
||||
const timers = recoverScheduledPosts({
|
||||
db,
|
||||
logger: { info: () => {}, warn: () => {} },
|
||||
isQuittingRef: { current: false },
|
||||
getBrowserActionService: () => ({
|
||||
queueBrowserActionExecution: (queuedActionId, queuedCompanyId) => {
|
||||
calls.push({ actionId: queuedActionId, companyId: queuedCompanyId });
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
await delay(80);
|
||||
for (const timer of timers) clearTimeout(timer);
|
||||
|
||||
expect(calls).toEqual([{ actionId, companyId }]);
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
@@ -1,92 +0,0 @@
|
||||
import type { Session } from "electron";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
assertAuthenticatedPage,
|
||||
assertAuthenticatedSession,
|
||||
} from "../../src/main/browser/browser-action-auth";
|
||||
import { shouldCaptureSuccessScreenshot } from "../../src/main/browser/browser-screenshot-policy";
|
||||
import { getPlatformSelectors } from "../../src/main/browser/platform-selectors";
|
||||
|
||||
function fakeSession(cookiesByOrigin: Record<string, unknown[]>): Pick<Session, "cookies"> {
|
||||
return {
|
||||
cookies: {
|
||||
get: async ({ url }) => cookiesByOrigin[url ?? ""] ?? [],
|
||||
},
|
||||
} as Pick<Session, "cookies">;
|
||||
}
|
||||
|
||||
describe("browser action runtime guards", () => {
|
||||
it("fails social actions before execution when the account session has no cookies", async () => {
|
||||
await expect(
|
||||
assertAuthenticatedSession(fakeSession({}), "twitter", "post"),
|
||||
).rejects.toMatchObject({
|
||||
code: "browser.not_logged_in",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects guest cookies for platforms with explicit auth cookies", async () => {
|
||||
await expect(
|
||||
assertAuthenticatedSession(
|
||||
fakeSession({ "https://x.com": [{ name: "guest_id", value: "present" }] }),
|
||||
"twitter",
|
||||
"post",
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
code: "browser.not_logged_in",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts a persisted platform cookie before executing actions", async () => {
|
||||
await expect(
|
||||
assertAuthenticatedSession(
|
||||
fakeSession({ "https://x.com": [{ name: "auth_token", value: "present" }] }),
|
||||
"twitter",
|
||||
"post",
|
||||
),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("treats login redirects as an unauthenticated page state", async () => {
|
||||
await expect(
|
||||
assertAuthenticatedPage(() => "https://x.com/i/flow/login", "twitter", "post"),
|
||||
).rejects.toMatchObject({
|
||||
code: "browser.not_logged_in",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps platform selectors versioned outside the executor", () => {
|
||||
const twitter = getPlatformSelectors("twitter");
|
||||
const linkedin = getPlatformSelectors("linkedin");
|
||||
|
||||
expect(twitter.editor).toContain("[data-testid='tweetTextarea_0']");
|
||||
expect(twitter.submit).toContain("[data-testid='tweetButton']");
|
||||
expect(linkedin.composerOpen).toContain("button.share-box-feed-entry__trigger");
|
||||
expect(linkedin.editor).toContain(".ql-editor[contenteditable='true']");
|
||||
});
|
||||
|
||||
it("captures successful screenshots only for explicit screenshot requests", () => {
|
||||
expect(shouldCaptureSuccessScreenshot({
|
||||
id: "post-1",
|
||||
socialAccountId: "social-1",
|
||||
platform: "twitter",
|
||||
actionType: "post",
|
||||
payload: { content: "hello" },
|
||||
})).toBe(false);
|
||||
|
||||
expect(shouldCaptureSuccessScreenshot({
|
||||
id: "screenshot-1",
|
||||
socialAccountId: "social-1",
|
||||
platform: "twitter",
|
||||
actionType: "screenshot",
|
||||
payload: {},
|
||||
})).toBe(true);
|
||||
|
||||
expect(shouldCaptureSuccessScreenshot({
|
||||
id: "post-2",
|
||||
socialAccountId: "social-1",
|
||||
platform: "twitter",
|
||||
actionType: "post",
|
||||
payload: { content: "hello", captureSuccessScreenshot: true },
|
||||
})).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,134 +0,0 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { BrowserAction, BrowserActionResult } from "../../src/main/browser-manager";
|
||||
import { BrowserManager } from "../../src/main/browser-manager";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
runAction: vi.fn(),
|
||||
fromPartition: vi.fn(),
|
||||
logWarn: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("electron", () => ({
|
||||
BrowserWindow: vi.fn(),
|
||||
session: {
|
||||
fromPartition: mocks.fromPartition,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("electron-log/main.js", () => ({
|
||||
default: {
|
||||
info: vi.fn(),
|
||||
warn: mocks.logWarn,
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../src/main/browser/browser-action-executor", () => ({
|
||||
assertAllowedUrl: vi.fn(),
|
||||
attachWindowSecurity: vi.fn(),
|
||||
getPlatformRules: vi.fn(() => ({
|
||||
loginUrl: "https://x.com/i/flow/login",
|
||||
cookieOrigins: [],
|
||||
authenticatedCookieNames: [],
|
||||
})),
|
||||
waitForLoad: vi.fn(),
|
||||
runAction: mocks.runAction,
|
||||
}));
|
||||
|
||||
const ACTION_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function createSession() {
|
||||
return {
|
||||
setPermissionCheckHandler: vi.fn(),
|
||||
setPermissionRequestHandler: vi.fn(),
|
||||
clearStorageData: vi.fn(),
|
||||
clearCache: vi.fn(),
|
||||
clearAuthCache: vi.fn(),
|
||||
cookies: {
|
||||
get: vi.fn(async () => []),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createAction(id: string): BrowserAction {
|
||||
return {
|
||||
id,
|
||||
socialAccountId: "social-1",
|
||||
platform: "twitter",
|
||||
actionType: "post",
|
||||
payload: { content: "hello" },
|
||||
};
|
||||
}
|
||||
|
||||
function createActionWindow() {
|
||||
const win = {
|
||||
destroyed: false,
|
||||
close: vi.fn(() => {
|
||||
win.destroyed = true;
|
||||
}),
|
||||
isDestroyed: vi.fn(() => win.destroyed),
|
||||
};
|
||||
return win;
|
||||
}
|
||||
|
||||
async function createProfileDir() {
|
||||
const dir = await mkdtemp(join(tmpdir(), "agentcompany-browser-timeout-"));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers();
|
||||
mocks.runAction.mockReset();
|
||||
mocks.fromPartition.mockReset();
|
||||
mocks.logWarn.mockReset();
|
||||
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe("browser manager action timeout", () => {
|
||||
it("settles a hung action and starts the next queued action", async () => {
|
||||
const profileDir = await createProfileDir();
|
||||
const firstWindow = createActionWindow();
|
||||
const secondResult: BrowserActionResult = {
|
||||
success: true,
|
||||
cancelled: false,
|
||||
summary: "second action completed",
|
||||
screenshotPath: null,
|
||||
error: null,
|
||||
};
|
||||
|
||||
mocks.fromPartition.mockReturnValue(createSession());
|
||||
mocks.runAction
|
||||
.mockImplementationOnce((action, _screenshotDir, _session, _cancelledActionIds, activeActionWindows) => {
|
||||
activeActionWindows.set(action.id, firstWindow);
|
||||
return new Promise(() => {});
|
||||
})
|
||||
.mockResolvedValueOnce(secondResult);
|
||||
|
||||
vi.useFakeTimers();
|
||||
const manager = new BrowserManager(profileDir);
|
||||
|
||||
const first = manager.executeAction(createAction("action-1"));
|
||||
await Promise.resolve();
|
||||
const second = manager.executeAction(createAction("action-2"));
|
||||
|
||||
expect(mocks.runAction).toHaveBeenCalledTimes(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(ACTION_TIMEOUT_MS);
|
||||
await expect(first).resolves.toMatchObject({
|
||||
success: false,
|
||||
cancelled: true,
|
||||
summary: "Action timed out and was cancelled.",
|
||||
screenshotPath: null,
|
||||
error: "Action timed out.",
|
||||
});
|
||||
await expect(second).resolves.toEqual(secondResult);
|
||||
|
||||
expect(firstWindow.close).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.runAction).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -1,113 +0,0 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { checkBudgetThreshold } from "@main/automation/automation-run-completion";
|
||||
|
||||
/**
|
||||
* Tests for budget alert notification logic in automation-run-completion.ts.
|
||||
*
|
||||
* Budget alerts fire at two thresholds:
|
||||
* 1. >= 80% utilization -> warning notification with agent name and percentage
|
||||
* 2. >= 100% utilization -> exhaustion notification (both fire)
|
||||
* 3. budgetMonthlyUsd <= 0 (unlimited) -> no notification
|
||||
*/
|
||||
|
||||
/** Identity localizer: returns the English string, as in the production default path. */
|
||||
function en(enStr: string, _zh: string): string {
|
||||
return enStr;
|
||||
}
|
||||
|
||||
describe("Budget Alert Notifications", () => {
|
||||
it("fires warning notification at 80% budget utilization", () => {
|
||||
const notify = vi.fn();
|
||||
checkBudgetThreshold(
|
||||
{ name: "Alice", budgetMonthlyUsd: 100, spentMonthlyUsd: 85 },
|
||||
notify,
|
||||
en,
|
||||
);
|
||||
expect(notify).toHaveBeenCalledTimes(1);
|
||||
const call = notify.mock.calls[0]![0] as { title: string; body: string };
|
||||
expect(call.title).toContain("Budget");
|
||||
expect(call.body).toContain("Alice");
|
||||
expect(call.body).toContain("85%");
|
||||
});
|
||||
|
||||
it("fires both notifications at >= 100% utilization", () => {
|
||||
const notify = vi.fn();
|
||||
checkBudgetThreshold(
|
||||
{ name: "Bob", budgetMonthlyUsd: 50, spentMonthlyUsd: 55 },
|
||||
notify,
|
||||
en,
|
||||
);
|
||||
expect(notify).toHaveBeenCalledTimes(2);
|
||||
const bodies = notify.mock.calls.map((c) => (c[0] as { body: string }).body);
|
||||
// Second call is the exhaustion notification
|
||||
expect(bodies[1]).toContain("100%");
|
||||
});
|
||||
|
||||
it("correctly rounds percentage as Math.round(utilization * 100)", () => {
|
||||
const notify = vi.fn();
|
||||
// 83.333...% should round to 83%
|
||||
checkBudgetThreshold(
|
||||
{ name: "Charlie", budgetMonthlyUsd: 60, spentMonthlyUsd: 50 },
|
||||
notify,
|
||||
en,
|
||||
);
|
||||
const body = (notify.mock.calls[0]![0] as { body: string }).body;
|
||||
expect(body).toContain("83%");
|
||||
});
|
||||
|
||||
it("does not fire notification when budgetMonthlyUsd is 0 (unlimited)", () => {
|
||||
const notify = vi.fn();
|
||||
checkBudgetThreshold(
|
||||
{ name: "Diana", budgetMonthlyUsd: 0, spentMonthlyUsd: 500 },
|
||||
notify,
|
||||
en,
|
||||
);
|
||||
expect(notify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not fire notification when utilization is below 80%", () => {
|
||||
const notify = vi.fn();
|
||||
checkBudgetThreshold(
|
||||
{ name: "Eve", budgetMonthlyUsd: 100, spentMonthlyUsd: 50 },
|
||||
notify,
|
||||
en,
|
||||
);
|
||||
expect(notify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fires both notifications at exactly 100%", () => {
|
||||
const notify = vi.fn();
|
||||
checkBudgetThreshold(
|
||||
{ name: "Frank", budgetMonthlyUsd: 100, spentMonthlyUsd: 100 },
|
||||
notify,
|
||||
en,
|
||||
);
|
||||
expect(notify).toHaveBeenCalledTimes(2);
|
||||
const first = notify.mock.calls[0]![0] as { title: string; body: string };
|
||||
const second = notify.mock.calls[1]![0] as { title: string; body: string };
|
||||
expect(first.body).toContain("100%");
|
||||
expect(second.title).toContain("Exhausted");
|
||||
});
|
||||
|
||||
it("does not fire notification when budgetMonthlyUsd is negative", () => {
|
||||
const notify = vi.fn();
|
||||
checkBudgetThreshold(
|
||||
{ name: "Grace", budgetMonthlyUsd: -10, spentMonthlyUsd: 500 },
|
||||
notify,
|
||||
en,
|
||||
);
|
||||
expect(notify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fires only the warning (not exhaustion) at exactly 80%", () => {
|
||||
const notify = vi.fn();
|
||||
checkBudgetThreshold(
|
||||
{ name: "Hank", budgetMonthlyUsd: 100, spentMonthlyUsd: 80 },
|
||||
notify,
|
||||
en,
|
||||
);
|
||||
expect(notify).toHaveBeenCalledTimes(1);
|
||||
const body = (notify.mock.calls[0]![0] as { body: string }).body;
|
||||
expect(body).toContain("80%");
|
||||
});
|
||||
});
|
||||
@@ -1,376 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
/**
|
||||
* Tests for budget policy evaluation and cost aggregation logic.
|
||||
*
|
||||
* Tests pure functions extracted from the budget enforcement service:
|
||||
* - getSpentInWindow: aggregates cost_entries by scope in cents
|
||||
* - getActivePoliciesForRun: finds matching policies for company+agent+project
|
||||
* - saveBudgetPolicy / deleteBudgetPolicy CRUD
|
||||
* - evaluateBudgetGate: returns blocked when ANY scope is exhausted (strictest-wins D-01)
|
||||
*/
|
||||
|
||||
import type { BudgetPolicyRecord } from "../../src/shared/types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: simulate getSpentInWindow (SUM(amount_usd) * 100 rounded to cents)
|
||||
// ---------------------------------------------------------------------------
|
||||
function computeSpentCents(costEntries: Array<{ amountUsd: number | null }>): number {
|
||||
const total = costEntries.reduce((sum, c) => sum + (c.amountUsd ?? 0), 0);
|
||||
return Math.round(total * 100);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: simulate getCalendarMonthWindow
|
||||
// ---------------------------------------------------------------------------
|
||||
function getCalendarMonthWindow(now: Date = new Date()): { start: string; end: string } {
|
||||
const year = now.getUTCFullYear();
|
||||
const month = now.getUTCMonth();
|
||||
const start = new Date(Date.UTC(year, month, 1)).toISOString();
|
||||
const end = new Date(Date.UTC(year, month + 1, 1)).toISOString();
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: simulate evaluateBudgetGate (strictest-wins per D-01)
|
||||
// ---------------------------------------------------------------------------
|
||||
interface BudgetGateResult {
|
||||
allowed: boolean;
|
||||
blockedBy: Array<{
|
||||
policyId: string;
|
||||
scopeType: string;
|
||||
scopeId: string;
|
||||
spent: number;
|
||||
limit: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
function evaluateBudgetGate(
|
||||
policies: BudgetPolicyRecord[],
|
||||
getSpent: (policy: BudgetPolicyRecord) => number,
|
||||
): BudgetGateResult {
|
||||
if (policies.length === 0) {
|
||||
return { allowed: true, blockedBy: [] };
|
||||
}
|
||||
|
||||
const blockedBy: BudgetGateResult["blockedBy"] = [];
|
||||
for (const policy of policies) {
|
||||
if (!policy.hardStopEnabled) continue;
|
||||
const spent = getSpent(policy);
|
||||
if (spent >= policy.amount) {
|
||||
blockedBy.push({
|
||||
policyId: policy.id,
|
||||
scopeType: policy.scopeType,
|
||||
scopeId: policy.scopeId,
|
||||
spent,
|
||||
limit: policy.amount,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { allowed: blockedBy.length === 0, blockedBy };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock database for CRUD testing
|
||||
// ---------------------------------------------------------------------------
|
||||
function createMockDb() {
|
||||
const policies: BudgetPolicyRecord[] = [];
|
||||
const costEntries: Array<{
|
||||
companyId: string;
|
||||
agentId: string | null;
|
||||
projectId: string | null;
|
||||
amountUsd: number;
|
||||
createdAt: string;
|
||||
}> = [];
|
||||
|
||||
const budget = {
|
||||
saveBudgetPolicy(input: Partial<BudgetPolicyRecord> & { companyId: string; scopeType: string; scopeId: string; amount: number }) {
|
||||
const id = input.id ?? crypto.randomUUID();
|
||||
const existing = policies.findIndex((p) => p.id === id);
|
||||
const ts = new Date().toISOString();
|
||||
const policy: BudgetPolicyRecord = {
|
||||
id,
|
||||
companyId: input.companyId,
|
||||
scopeType: input.scopeType as "company" | "agent" | "project",
|
||||
scopeId: input.scopeId,
|
||||
metric: input.metric ?? "billed_cents",
|
||||
windowKind: input.windowKind ?? "calendar_month_utc",
|
||||
amount: input.amount,
|
||||
warnPercent: input.warnPercent ?? 80,
|
||||
hardStopEnabled: input.hardStopEnabled ?? true,
|
||||
notifyEnabled: input.notifyEnabled ?? true,
|
||||
isActive: input.isActive ?? true,
|
||||
createdAt: existing >= 0 ? policies[existing]!.createdAt : ts,
|
||||
updatedAt: ts,
|
||||
};
|
||||
if (existing >= 0) {
|
||||
policies[existing] = policy;
|
||||
} else {
|
||||
policies.push(policy);
|
||||
}
|
||||
return id;
|
||||
},
|
||||
|
||||
deleteBudgetPolicy(companyId: string, policyId: string) {
|
||||
const idx = policies.findIndex((p) => p.id === policyId && p.companyId === companyId);
|
||||
if (idx >= 0) policies.splice(idx, 1);
|
||||
},
|
||||
|
||||
listBudgetPolicies(companyId: string) {
|
||||
return policies
|
||||
.filter((p) => p.companyId === companyId)
|
||||
.sort((a, b) => a.scopeType.localeCompare(b.scopeType) || a.createdAt.localeCompare(b.createdAt));
|
||||
},
|
||||
|
||||
getActivePoliciesForRun(companyId: string, agentId: string, projectId: string | null) {
|
||||
return policies.filter(
|
||||
(p) =>
|
||||
p.companyId === companyId &&
|
||||
p.isActive &&
|
||||
((p.scopeType === "company" && p.scopeId === companyId) ||
|
||||
(p.scopeType === "agent" && p.scopeId === agentId) ||
|
||||
(p.scopeType === "project" && projectId !== null && p.scopeId === projectId)),
|
||||
);
|
||||
},
|
||||
|
||||
getSpentInWindow(policy: BudgetPolicyRecord): number {
|
||||
const window = getCalendarMonthWindow();
|
||||
let filtered = costEntries.filter(
|
||||
(c) => c.companyId === policy.companyId && c.createdAt >= window.start && c.createdAt < window.end,
|
||||
);
|
||||
if (policy.scopeType === "agent") {
|
||||
filtered = filtered.filter((c) => c.agentId === policy.scopeId);
|
||||
} else if (policy.scopeType === "project") {
|
||||
filtered = filtered.filter((c) => c.projectId === policy.scopeId);
|
||||
}
|
||||
return computeSpentCents(filtered);
|
||||
},
|
||||
};
|
||||
return { policies, costEntries, core: { budget } };
|
||||
}
|
||||
|
||||
// ======================================================================
|
||||
// Tests
|
||||
// ======================================================================
|
||||
|
||||
describe("Budget Policy CRUD", () => {
|
||||
let db: ReturnType<typeof createMockDb>;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createMockDb();
|
||||
});
|
||||
|
||||
it("saveBudgetPolicy creates new policy and returns its ID", () => {
|
||||
const id = db.core.budget.saveBudgetPolicy({
|
||||
companyId: "c1",
|
||||
scopeType: "company",
|
||||
scopeId: "c1",
|
||||
amount: 10000,
|
||||
});
|
||||
expect(id).toBeTruthy();
|
||||
expect(db.policies).toHaveLength(1);
|
||||
expect(db.policies[0]!.amount).toBe(10000);
|
||||
expect(db.policies[0]!.scopeType).toBe("company");
|
||||
});
|
||||
|
||||
it("saveBudgetPolicy updates existing policy when ID provided", () => {
|
||||
const id = db.core.budget.saveBudgetPolicy({
|
||||
companyId: "c1",
|
||||
scopeType: "company",
|
||||
scopeId: "c1",
|
||||
amount: 10000,
|
||||
});
|
||||
db.core.budget.saveBudgetPolicy({
|
||||
id,
|
||||
companyId: "c1",
|
||||
scopeType: "company",
|
||||
scopeId: "c1",
|
||||
amount: 20000,
|
||||
});
|
||||
expect(db.policies).toHaveLength(1);
|
||||
expect(db.policies[0]!.amount).toBe(20000);
|
||||
});
|
||||
|
||||
it("deleteBudgetPolicy removes policy by ID", () => {
|
||||
const id = db.core.budget.saveBudgetPolicy({
|
||||
companyId: "c1",
|
||||
scopeType: "company",
|
||||
scopeId: "c1",
|
||||
amount: 10000,
|
||||
});
|
||||
expect(db.policies).toHaveLength(1);
|
||||
db.core.budget.deleteBudgetPolicy("c1", id);
|
||||
expect(db.policies).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getActivePoliciesForRun", () => {
|
||||
let db: ReturnType<typeof createMockDb>;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createMockDb();
|
||||
});
|
||||
|
||||
it("returns all active policies matching company+agent+project scopes", () => {
|
||||
db.core.budget.saveBudgetPolicy({ companyId: "c1", scopeType: "company", scopeId: "c1", amount: 50000 });
|
||||
db.core.budget.saveBudgetPolicy({ companyId: "c1", scopeType: "agent", scopeId: "a1", amount: 10000 });
|
||||
db.core.budget.saveBudgetPolicy({ companyId: "c1", scopeType: "project", scopeId: "p1", amount: 20000 });
|
||||
// Different company — should not match
|
||||
db.core.budget.saveBudgetPolicy({ companyId: "c2", scopeType: "company", scopeId: "c2", amount: 99999 });
|
||||
|
||||
const result = db.core.budget.getActivePoliciesForRun("c1", "a1", "p1");
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result.map((p) => p.scopeType).sort()).toEqual(["agent", "company", "project"]);
|
||||
});
|
||||
|
||||
it("returns empty array when no policies exist (D-02: no policy = unlimited)", () => {
|
||||
const result = db.core.budget.getActivePoliciesForRun("c1", "a1", null);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("excludes project scope when projectId is null", () => {
|
||||
db.core.budget.saveBudgetPolicy({ companyId: "c1", scopeType: "company", scopeId: "c1", amount: 50000 });
|
||||
db.core.budget.saveBudgetPolicy({ companyId: "c1", scopeType: "project", scopeId: "p1", amount: 20000 });
|
||||
|
||||
const result = db.core.budget.getActivePoliciesForRun("c1", "a1", null);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]!.scopeType).toBe("company");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSpentInWindow", () => {
|
||||
let db: ReturnType<typeof createMockDb>;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createMockDb();
|
||||
});
|
||||
|
||||
it("returns SUM(amount_usd)*100 rounded to integer cents for company scope", () => {
|
||||
const now = new Date();
|
||||
db.costEntries.push(
|
||||
{ companyId: "c1", agentId: "a1", projectId: null, amountUsd: 1.50, createdAt: now.toISOString() },
|
||||
{ companyId: "c1", agentId: "a2", projectId: null, amountUsd: 2.30, createdAt: now.toISOString() },
|
||||
);
|
||||
|
||||
const policy: BudgetPolicyRecord = {
|
||||
id: "pol1", companyId: "c1", scopeType: "company", scopeId: "c1",
|
||||
metric: "billed_cents", windowKind: "calendar_month_utc",
|
||||
amount: 100000, warnPercent: 80, hardStopEnabled: true, notifyEnabled: true,
|
||||
isActive: true, createdAt: now.toISOString(), updatedAt: now.toISOString(),
|
||||
};
|
||||
const spent = db.core.budget.getSpentInWindow(policy);
|
||||
expect(spent).toBe(380); // (1.50 + 2.30) * 100 = 380 cents
|
||||
});
|
||||
|
||||
it("filters by agent_id for agent scope", () => {
|
||||
const now = new Date();
|
||||
db.costEntries.push(
|
||||
{ companyId: "c1", agentId: "a1", projectId: null, amountUsd: 5.00, createdAt: now.toISOString() },
|
||||
{ companyId: "c1", agentId: "a2", projectId: null, amountUsd: 3.00, createdAt: now.toISOString() },
|
||||
);
|
||||
|
||||
const policy: BudgetPolicyRecord = {
|
||||
id: "pol1", companyId: "c1", scopeType: "agent", scopeId: "a1",
|
||||
metric: "billed_cents", windowKind: "calendar_month_utc",
|
||||
amount: 100000, warnPercent: 80, hardStopEnabled: true, notifyEnabled: true,
|
||||
isActive: true, createdAt: now.toISOString(), updatedAt: now.toISOString(),
|
||||
};
|
||||
const spent = db.core.budget.getSpentInWindow(policy);
|
||||
expect(spent).toBe(500); // Only a1's $5.00 = 500 cents
|
||||
});
|
||||
|
||||
it("filters by project_id for project scope", () => {
|
||||
const now = new Date();
|
||||
db.costEntries.push(
|
||||
{ companyId: "c1", agentId: "a1", projectId: "p1", amountUsd: 7.50, createdAt: now.toISOString() },
|
||||
{ companyId: "c1", agentId: "a1", projectId: "p2", amountUsd: 2.00, createdAt: now.toISOString() },
|
||||
);
|
||||
|
||||
const policy: BudgetPolicyRecord = {
|
||||
id: "pol1", companyId: "c1", scopeType: "project", scopeId: "p1",
|
||||
metric: "billed_cents", windowKind: "calendar_month_utc",
|
||||
amount: 100000, warnPercent: 80, hardStopEnabled: true, notifyEnabled: true,
|
||||
isActive: true, createdAt: now.toISOString(), updatedAt: now.toISOString(),
|
||||
};
|
||||
const spent = db.core.budget.getSpentInWindow(policy);
|
||||
expect(spent).toBe(750); // Only p1's $7.50 = 750 cents
|
||||
});
|
||||
});
|
||||
|
||||
describe("evaluateBudgetGate", () => {
|
||||
it("returns allowed:true with empty blockedBy when no policies exist (D-02)", () => {
|
||||
const result = evaluateBudgetGate([], vi.fn());
|
||||
expect(result).toEqual({ allowed: true, blockedBy: [] });
|
||||
});
|
||||
|
||||
it("returns allowed:true when no policies are exhausted", () => {
|
||||
const policy: BudgetPolicyRecord = {
|
||||
id: "pol1", companyId: "c1", scopeType: "company", scopeId: "c1",
|
||||
metric: "billed_cents", windowKind: "calendar_month_utc",
|
||||
amount: 10000, warnPercent: 80, hardStopEnabled: true, notifyEnabled: true,
|
||||
isActive: true, createdAt: "", updatedAt: "",
|
||||
};
|
||||
const getSpent = vi.fn().mockReturnValue(5000); // 50% spent
|
||||
|
||||
const result = evaluateBudgetGate([policy], getSpent);
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.blockedBy).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns blocked when ANY scope is exhausted (strictest-wins D-01)", () => {
|
||||
const companyPolicy: BudgetPolicyRecord = {
|
||||
id: "pol1", companyId: "c1", scopeType: "company", scopeId: "c1",
|
||||
metric: "billed_cents", windowKind: "calendar_month_utc",
|
||||
amount: 100000, warnPercent: 80, hardStopEnabled: true, notifyEnabled: true,
|
||||
isActive: true, createdAt: "", updatedAt: "",
|
||||
};
|
||||
const agentPolicy: BudgetPolicyRecord = {
|
||||
id: "pol2", companyId: "c1", scopeType: "agent", scopeId: "a1",
|
||||
metric: "billed_cents", windowKind: "calendar_month_utc",
|
||||
amount: 5000, warnPercent: 80, hardStopEnabled: true, notifyEnabled: true,
|
||||
isActive: true, createdAt: "", updatedAt: "",
|
||||
};
|
||||
|
||||
const getSpent = vi.fn((policy: BudgetPolicyRecord) => {
|
||||
if (policy.scopeType === "company") return 50000; // 50% of company
|
||||
return 5500; // 110% of agent
|
||||
});
|
||||
|
||||
const result = evaluateBudgetGate([companyPolicy, agentPolicy], getSpent);
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.blockedBy).toHaveLength(1);
|
||||
expect(result.blockedBy[0]!.policyId).toBe("pol2");
|
||||
expect(result.blockedBy[0]!.scopeType).toBe("agent");
|
||||
});
|
||||
|
||||
it("skips policies where hardStopEnabled is false", () => {
|
||||
const policy: BudgetPolicyRecord = {
|
||||
id: "pol1", companyId: "c1", scopeType: "company", scopeId: "c1",
|
||||
metric: "billed_cents", windowKind: "calendar_month_utc",
|
||||
amount: 1000, warnPercent: 80, hardStopEnabled: false, notifyEnabled: true,
|
||||
isActive: true, createdAt: "", updatedAt: "",
|
||||
};
|
||||
const getSpent = vi.fn().mockReturnValue(5000); // Way over limit
|
||||
|
||||
const result = evaluateBudgetGate([policy], getSpent);
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.blockedBy).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCalendarMonthWindow", () => {
|
||||
it("returns start of current month and start of next month in UTC", () => {
|
||||
const testDate = new Date("2026-03-15T10:30:00Z");
|
||||
const window = getCalendarMonthWindow(testDate);
|
||||
expect(window.start).toBe("2026-03-01T00:00:00.000Z");
|
||||
expect(window.end).toBe("2026-04-01T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("handles December rollover to January next year", () => {
|
||||
const testDate = new Date("2026-12-25T10:30:00Z");
|
||||
const window = getCalendarMonthWindow(testDate);
|
||||
expect(window.start).toBe("2026-12-01T00:00:00.000Z");
|
||||
expect(window.end).toBe("2027-01-01T00:00:00.000Z");
|
||||
});
|
||||
});
|
||||
@@ -1,339 +0,0 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
|
||||
/**
|
||||
* Tests for budget incident lifecycle: creation, deduplication (D-08),
|
||||
* resolution, and auto-close of expired incidents (D-06).
|
||||
*/
|
||||
|
||||
import type { BudgetPolicyRecord, BudgetIncidentRecord } from "../../src/shared/types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock incident store simulating upsertBudgetIncident, resolveIncidentsForPolicy,
|
||||
// autoCloseExpiredIncidents behaviors from database.ts
|
||||
// ---------------------------------------------------------------------------
|
||||
function createMockIncidentDb() {
|
||||
const incidents: BudgetIncidentRecord[] = [];
|
||||
let idCounter = 0;
|
||||
|
||||
function getCalendarMonthWindow(now: Date = new Date()): { start: string; end: string } {
|
||||
const year = now.getUTCFullYear();
|
||||
const month = now.getUTCMonth();
|
||||
const start = new Date(Date.UTC(year, month, 1)).toISOString();
|
||||
const end = new Date(Date.UTC(year, month + 1, 1)).toISOString();
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
const budget = {
|
||||
getCalendarMonthWindow,
|
||||
|
||||
upsertBudgetIncident(
|
||||
policy: BudgetPolicyRecord,
|
||||
thresholdType: "warn_threshold" | "hard_stop",
|
||||
amountObserved: number,
|
||||
): string {
|
||||
const window = getCalendarMonthWindow();
|
||||
const existing = incidents.find(
|
||||
(i) =>
|
||||
i.policyId === policy.id &&
|
||||
i.windowStart === window.start &&
|
||||
i.thresholdType === thresholdType &&
|
||||
i.status === "open",
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
existing.amountObserved = amountObserved;
|
||||
existing.updatedAt = new Date().toISOString();
|
||||
return existing.id;
|
||||
}
|
||||
|
||||
const id = `inc-${++idCounter}`;
|
||||
const ts = new Date().toISOString();
|
||||
incidents.push({
|
||||
id,
|
||||
companyId: policy.companyId,
|
||||
policyId: policy.id,
|
||||
scopeType: policy.scopeType,
|
||||
scopeId: policy.scopeId,
|
||||
metric: policy.metric,
|
||||
windowKind: policy.windowKind,
|
||||
windowStart: window.start,
|
||||
windowEnd: window.end,
|
||||
thresholdType,
|
||||
amountLimit: policy.amount,
|
||||
amountObserved,
|
||||
status: "open",
|
||||
resolvedAt: null,
|
||||
createdAt: ts,
|
||||
updatedAt: ts,
|
||||
});
|
||||
return id;
|
||||
},
|
||||
|
||||
resolveIncidentsForPolicy(policyId: string, resolutionStatus: string): number {
|
||||
let count = 0;
|
||||
const ts = new Date().toISOString();
|
||||
for (const incident of incidents) {
|
||||
if (incident.policyId === policyId && incident.status === "open") {
|
||||
incident.status = resolutionStatus as BudgetIncidentRecord["status"];
|
||||
incident.resolvedAt = ts;
|
||||
incident.updatedAt = ts;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
},
|
||||
|
||||
resolveWarnIncidentsForPolicy(policyId: string, resolutionStatus: string): number {
|
||||
let count = 0;
|
||||
const ts = new Date().toISOString();
|
||||
for (const incident of incidents) {
|
||||
if (incident.policyId === policyId && incident.status === "open" && incident.thresholdType === "warn_threshold") {
|
||||
incident.status = resolutionStatus as BudgetIncidentRecord["status"];
|
||||
incident.resolvedAt = ts;
|
||||
incident.updatedAt = ts;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
},
|
||||
|
||||
autoCloseExpiredIncidents(companyId: string): number {
|
||||
let count = 0;
|
||||
const now = new Date().toISOString();
|
||||
for (const incident of incidents) {
|
||||
if (
|
||||
incident.companyId === companyId &&
|
||||
incident.status === "open" &&
|
||||
incident.windowEnd <= now
|
||||
) {
|
||||
incident.status = "auto_closed";
|
||||
incident.resolvedAt = now;
|
||||
incident.updatedAt = now;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
},
|
||||
|
||||
listBudgetIncidents(companyId: string, statusFilter: string = "all"): BudgetIncidentRecord[] {
|
||||
return incidents
|
||||
.filter((i) => i.companyId === companyId && (statusFilter === "all" || i.status === statusFilter))
|
||||
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||
},
|
||||
|
||||
resolveBudgetIncident(incidentId: string, companyId: string): boolean {
|
||||
const incident = incidents.find((i) => i.id === incidentId && i.companyId === companyId && i.status === "open");
|
||||
if (!incident) return false;
|
||||
const ts = new Date().toISOString();
|
||||
incident.status = "resolved";
|
||||
incident.resolvedAt = ts;
|
||||
incident.updatedAt = ts;
|
||||
return true;
|
||||
},
|
||||
};
|
||||
return { incidents, core: { budget } };
|
||||
}
|
||||
|
||||
function makePolicy(overrides: Partial<BudgetPolicyRecord> = {}): BudgetPolicyRecord {
|
||||
return {
|
||||
id: "pol-1",
|
||||
companyId: "c1",
|
||||
scopeType: "agent",
|
||||
scopeId: "a1",
|
||||
metric: "billed_cents",
|
||||
windowKind: "calendar_month_utc",
|
||||
amount: 10000,
|
||||
warnPercent: 80,
|
||||
hardStopEnabled: true,
|
||||
notifyEnabled: true,
|
||||
isActive: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ======================================================================
|
||||
// Tests
|
||||
// ======================================================================
|
||||
|
||||
describe("upsertBudgetIncident", () => {
|
||||
let db: ReturnType<typeof createMockIncidentDb>;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createMockIncidentDb();
|
||||
});
|
||||
|
||||
it("creates new incident when none exists for policy+window+threshold_type", () => {
|
||||
const policy = makePolicy();
|
||||
const id = db.core.budget.upsertBudgetIncident(policy, "warn_threshold", 8500);
|
||||
expect(id).toBeTruthy();
|
||||
expect(db.incidents).toHaveLength(1);
|
||||
expect(db.incidents[0]!.thresholdType).toBe("warn_threshold");
|
||||
expect(db.incidents[0]!.amountObserved).toBe(8500);
|
||||
expect(db.incidents[0]!.status).toBe("open");
|
||||
});
|
||||
|
||||
it("updates amount_observed when open incident already exists (D-08 dedup)", () => {
|
||||
const policy = makePolicy();
|
||||
const id1 = db.core.budget.upsertBudgetIncident(policy, "warn_threshold", 8500);
|
||||
const id2 = db.core.budget.upsertBudgetIncident(policy, "warn_threshold", 9200);
|
||||
expect(id1).toBe(id2); // Same incident, not a new one
|
||||
expect(db.incidents).toHaveLength(1); // Still just one
|
||||
expect(db.incidents[0]!.amountObserved).toBe(9200); // Updated
|
||||
});
|
||||
|
||||
it("creates separate incidents for different threshold types", () => {
|
||||
const policy = makePolicy();
|
||||
db.core.budget.upsertBudgetIncident(policy, "warn_threshold", 8500);
|
||||
db.core.budget.upsertBudgetIncident(policy, "hard_stop", 10500);
|
||||
expect(db.incidents).toHaveLength(2);
|
||||
expect(db.incidents[0]!.thresholdType).toBe("warn_threshold");
|
||||
expect(db.incidents[1]!.thresholdType).toBe("hard_stop");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveIncidentsForPolicy", () => {
|
||||
let db: ReturnType<typeof createMockIncidentDb>;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createMockIncidentDb();
|
||||
});
|
||||
|
||||
it("sets status to provided resolution reason for all open incidents", () => {
|
||||
const policy = makePolicy();
|
||||
db.core.budget.upsertBudgetIncident(policy, "warn_threshold", 8500);
|
||||
db.core.budget.upsertBudgetIncident(policy, "hard_stop", 10500);
|
||||
|
||||
const count = db.core.budget.resolveIncidentsForPolicy("pol-1", "auto_resolved_limit_increase");
|
||||
expect(count).toBe(2);
|
||||
expect(db.incidents[0]!.status).toBe("auto_resolved_limit_increase");
|
||||
expect(db.incidents[1]!.status).toBe("auto_resolved_limit_increase");
|
||||
expect(db.incidents[0]!.resolvedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not affect already-resolved incidents", () => {
|
||||
const policy = makePolicy();
|
||||
db.core.budget.upsertBudgetIncident(policy, "warn_threshold", 8500);
|
||||
db.core.budget.resolveIncidentsForPolicy("pol-1", "resolved");
|
||||
// Now try to resolve again
|
||||
const count = db.core.budget.resolveIncidentsForPolicy("pol-1", "auto_resolved_limit_increase");
|
||||
expect(count).toBe(0);
|
||||
expect(db.incidents[0]!.status).toBe("resolved"); // Unchanged
|
||||
});
|
||||
});
|
||||
|
||||
describe("autoCloseExpiredIncidents", () => {
|
||||
let db: ReturnType<typeof createMockIncidentDb>;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createMockIncidentDb();
|
||||
});
|
||||
|
||||
it("closes incidents whose window_end is in the past", () => {
|
||||
const _policy = makePolicy();
|
||||
// Manually create an expired incident
|
||||
db.incidents.push({
|
||||
id: "expired-1",
|
||||
companyId: "c1",
|
||||
policyId: "pol-1",
|
||||
scopeType: "agent",
|
||||
scopeId: "a1",
|
||||
metric: "billed_cents",
|
||||
windowKind: "calendar_month_utc",
|
||||
windowStart: "2025-01-01T00:00:00.000Z",
|
||||
windowEnd: "2025-02-01T00:00:00.000Z", // In the past
|
||||
thresholdType: "warn_threshold",
|
||||
amountLimit: 10000,
|
||||
amountObserved: 8500,
|
||||
status: "open",
|
||||
resolvedAt: null,
|
||||
createdAt: "2025-01-15T00:00:00.000Z",
|
||||
updatedAt: "2025-01-15T00:00:00.000Z",
|
||||
});
|
||||
|
||||
const count = db.core.budget.autoCloseExpiredIncidents("c1");
|
||||
expect(count).toBe(1);
|
||||
expect(db.incidents[0]!.status).toBe("auto_closed");
|
||||
expect(db.incidents[0]!.resolvedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not close incidents whose window_end is in the future", () => {
|
||||
const policy = makePolicy();
|
||||
db.core.budget.upsertBudgetIncident(policy, "warn_threshold", 8500);
|
||||
// The auto-created incident has window_end in the future (end of current month)
|
||||
|
||||
const count = db.core.budget.autoCloseExpiredIncidents("c1");
|
||||
expect(count).toBe(0);
|
||||
expect(db.incidents[0]!.status).toBe("open");
|
||||
});
|
||||
});
|
||||
|
||||
describe("listBudgetIncidents", () => {
|
||||
let db: ReturnType<typeof createMockIncidentDb>;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createMockIncidentDb();
|
||||
});
|
||||
|
||||
it("returns all incidents for a company when status is 'all'", () => {
|
||||
const policy = makePolicy();
|
||||
db.core.budget.upsertBudgetIncident(policy, "warn_threshold", 8500);
|
||||
db.core.budget.upsertBudgetIncident(policy, "hard_stop", 10500);
|
||||
db.core.budget.resolveIncidentsForPolicy("pol-1", "resolved");
|
||||
|
||||
const all = db.core.budget.listBudgetIncidents("c1", "all");
|
||||
expect(all).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("filters by status when specified", () => {
|
||||
const policy = makePolicy();
|
||||
db.core.budget.upsertBudgetIncident(policy, "warn_threshold", 8500);
|
||||
db.core.budget.upsertBudgetIncident(policy, "hard_stop", 10500);
|
||||
// Resolve only warn
|
||||
db.core.budget.resolveWarnIncidentsForPolicy("pol-1", "resolved");
|
||||
|
||||
const open = db.core.budget.listBudgetIncidents("c1", "open");
|
||||
expect(open).toHaveLength(1);
|
||||
expect(open[0]!.thresholdType).toBe("hard_stop");
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkAutoResolve (D-06)", () => {
|
||||
let db: ReturnType<typeof createMockIncidentDb>;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createMockIncidentDb();
|
||||
});
|
||||
|
||||
it("auto-resolves incidents when policy limit increases above observed amount", () => {
|
||||
const policy = makePolicy({ amount: 10000 });
|
||||
db.core.budget.upsertBudgetIncident(policy, "hard_stop", 10500);
|
||||
expect(db.incidents[0]!.status).toBe("open");
|
||||
|
||||
// Simulate limit increase: now checking if spent < new limit
|
||||
// If spent (10500) < new policy.amount (20000), resolve
|
||||
const spent = 10500;
|
||||
const newAmount = 20000;
|
||||
if (spent < newAmount) {
|
||||
db.core.budget.resolveIncidentsForPolicy("pol-1", "auto_resolved_limit_increase");
|
||||
}
|
||||
|
||||
expect(db.incidents[0]!.status).toBe("auto_resolved_limit_increase");
|
||||
});
|
||||
|
||||
it("auto-resolves only warn incidents when below warn threshold but above hard stop", () => {
|
||||
const policy = makePolicy({ amount: 10000, warnPercent: 80 });
|
||||
db.core.budget.upsertBudgetIncident(policy, "warn_threshold", 8500);
|
||||
db.core.budget.upsertBudgetIncident(policy, "hard_stop", 10500);
|
||||
|
||||
// Spent is now 7500 (below warn threshold of 8000 = 10000*80/100)
|
||||
const spent = 7500;
|
||||
const _warnAmount = Math.floor((policy.amount * policy.warnPercent) / 100);
|
||||
if (spent < policy.amount) {
|
||||
db.core.budget.resolveIncidentsForPolicy("pol-1", "auto_resolved_limit_increase");
|
||||
}
|
||||
// Both resolved since spent < policy.amount
|
||||
expect(db.incidents.every((i) => i.status === "auto_resolved_limit_increase")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,41 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildBudgetPolicyInput, getBudgetPolicyAmountDollars } from "../../src/renderer/lib/budget-policy-input";
|
||||
|
||||
describe("budget-policy-input", () => {
|
||||
it("preserves the existing policy id on edit", () => {
|
||||
const payload = buildBudgetPolicyInput({
|
||||
policyId: "policy-123",
|
||||
companyId: "00000000-0000-0000-0000-000000000001",
|
||||
scopeType: "agent",
|
||||
scopeId: "00000000-0000-0000-0000-000000000002",
|
||||
amountDollars: "250.00",
|
||||
warnPercent: "85",
|
||||
hardStopEnabled: true,
|
||||
notifyEnabled: true,
|
||||
});
|
||||
|
||||
expect(payload?.id).toBe("policy-123");
|
||||
expect(payload?.amount).toBe(25000);
|
||||
expect(payload?.warnPercent).toBe(85);
|
||||
});
|
||||
|
||||
it("formats stored cents back to dollars for edit forms", () => {
|
||||
expect(
|
||||
getBudgetPolicyAmountDollars({
|
||||
id: "policy-123",
|
||||
companyId: "company-1",
|
||||
scopeType: "agent",
|
||||
scopeId: "agent-1",
|
||||
metric: "billed_cents",
|
||||
windowKind: "calendar_month_utc",
|
||||
amount: 12550,
|
||||
warnPercent: 80,
|
||||
hardStopEnabled: true,
|
||||
notifyEnabled: true,
|
||||
isActive: true,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
}),
|
||||
).toBe("125.50");
|
||||
});
|
||||
});
|
||||
@@ -1,44 +0,0 @@
|
||||
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: [
|
||||
{ id: "workspace-1", name: "Primary", localPath: "/tmp/workspace" },
|
||||
],
|
||||
allConnectors: [
|
||||
{ id: "openai-codex", 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 (`workspace-1`): /tmp/workspace");
|
||||
expect(section).toContain("Codex (`openai-codex`) — models: gpt-5.4");
|
||||
expect(section).toContain("architecture_decision");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { pointInTriangle } from "../../src/renderer/src/shared/CascadeMenu";
|
||||
|
||||
// The safe triangle: apex at the cursor's prior position, base on the open
|
||||
// submenu's near (left) edge. A submenu sitting to the right spanning y∈[-20,40]
|
||||
// at x=100, with the cursor having just been at the origin.
|
||||
const apex = { x: 0, y: 0 };
|
||||
const top = { x: 100, y: -20 };
|
||||
const bottom = { x: 100, y: 40 };
|
||||
|
||||
describe("safe triangle (cursor → submenu)", () => {
|
||||
test("a cursor sweeping diagonally toward the submenu stays inside", () => {
|
||||
// Midway to the submenu, within the widening cone — switching is deferred.
|
||||
expect(pointInTriangle({ x: 50, y: 5 }, apex, top, bottom)).toBe(true);
|
||||
expect(pointInTriangle({ x: 90, y: 30 }, apex, top, bottom)).toBe(true);
|
||||
});
|
||||
|
||||
test("a cursor that drifts off-course toward a sibling row falls outside", () => {
|
||||
// At x=50 the cone only spans y∈[-10,20]; y=30 is below it — let the sibling win.
|
||||
expect(pointInTriangle({ x: 50, y: 30 }, apex, top, bottom)).toBe(false);
|
||||
// Straight down from the apex, away from the submenu entirely.
|
||||
expect(pointInTriangle({ x: 5, y: 35 }, apex, top, bottom)).toBe(false);
|
||||
});
|
||||
|
||||
test("the apex and the submenu corners are on the triangle", () => {
|
||||
expect(pointInTriangle(apex, apex, top, bottom)).toBe(true);
|
||||
expect(pointInTriangle(top, apex, top, bottom)).toBe(true);
|
||||
expect(pointInTriangle(bottom, apex, top, bottom)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,79 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AgentMessageRecord, AgentRecord } from "../../src/shared/types";
|
||||
import { deriveChannels, getChannelMessages } from "../../src/renderer/components/communication/channel-utils";
|
||||
|
||||
function agent(id: string, name: string): AgentRecord {
|
||||
return {
|
||||
id,
|
||||
companyId: "company-1",
|
||||
name,
|
||||
role: name,
|
||||
title: name,
|
||||
department: null,
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "openai-codex",
|
||||
workspaceId: null,
|
||||
model: null,
|
||||
capabilities: "",
|
||||
budgetMonthlyUsd: 0,
|
||||
spentMonthlyUsd: 0,
|
||||
metadataJson: "{}",
|
||||
heartbeatEnabled: false,
|
||||
heartbeatIntervalSec: 300,
|
||||
lastHeartbeatAt: null,
|
||||
createdAt: "2026-04-22T00:00:00.000Z",
|
||||
updatedAt: "2026-04-22T00:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
function message(input: Partial<AgentMessageRecord>): AgentMessageRecord {
|
||||
return {
|
||||
id: input.id ?? "msg-1",
|
||||
companyId: "company-1",
|
||||
fromAgentId: input.fromAgentId ?? "agent-ceo",
|
||||
toAgentId: input.toAgentId ?? "agent-cto",
|
||||
senderKind: input.senderKind ?? "agent",
|
||||
channel: input.channel ?? "direct",
|
||||
channelTargetId: input.channelTargetId ?? null,
|
||||
subject: input.subject ?? "Subject",
|
||||
body: input.body ?? "Body",
|
||||
priority: input.priority ?? "normal",
|
||||
parentMessageId: input.parentMessageId ?? null,
|
||||
attachmentsJson: input.attachmentsJson ?? "[]",
|
||||
createdAt: input.createdAt ?? "2026-04-22T00:00:00.000Z",
|
||||
readAt: input.readAt ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("communication channel utilities", () => {
|
||||
it("drops legacy direct messages whose sender and recipient are the same agent", () => {
|
||||
const ceo = agent("agent-ceo", "CEO");
|
||||
const selfMessage = message({
|
||||
id: "self-direct",
|
||||
fromAgentId: ceo.id,
|
||||
toAgentId: ceo.id,
|
||||
});
|
||||
|
||||
const channels = deriveChannels([selfMessage], [ceo]);
|
||||
|
||||
expect(channels.map((channel) => channel.label)).not.toContain("CEO ↔ CEO");
|
||||
expect(channels.some((channel) => channel.kind === "message" && channel.channel === "direct")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps normal direct conversations visible", () => {
|
||||
const ceo = agent("agent-ceo", "CEO");
|
||||
const cto = agent("agent-cto", "CTO");
|
||||
const direct = message({
|
||||
id: "ceo-cto",
|
||||
fromAgentId: ceo.id,
|
||||
toAgentId: cto.id,
|
||||
});
|
||||
|
||||
const channels = deriveChannels([direct], [ceo, cto]);
|
||||
const dm = channels.find((channel) => channel.kind === "message" && channel.channel === "direct");
|
||||
|
||||
expect(dm?.label).toBe("CEO ↔ CTO");
|
||||
expect(dm ? getChannelMessages([direct], dm).map((entry) => entry.id) : []).toEqual(["ceo-cto"]);
|
||||
});
|
||||
});
|
||||
@@ -1,74 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
formatDateLabel,
|
||||
formatPlainLabel,
|
||||
shouldRenderLabel,
|
||||
} from "../../src/renderer/components/dashboard/charts/ChartTooltip";
|
||||
|
||||
// The <ChartTooltip /> component itself is a thin shell around three pure
|
||||
// helpers. Testing the helpers covers every branch of the rendered output:
|
||||
//
|
||||
// formatDateLabel → "date" mode default formatter
|
||||
// formatPlainLabel → "plain" mode default formatter
|
||||
// shouldRenderLabel → whether the top label row is rendered at all
|
||||
//
|
||||
// Rendering JSX here would require adding @testing-library/react + jsdom just
|
||||
// to re-check what the helpers already decide. The vitest config also only
|
||||
// picks up *.test.ts (not .tsx), so keeping the test purely functional avoids
|
||||
// expanding the harness for one component.
|
||||
|
||||
describe("formatDateLabel (date mode)", () => {
|
||||
it("formats a valid ISO date string into Mon DD, YYYY", () => {
|
||||
expect(formatDateLabel("2026-03-15")).toBe("Mar 15, 2026");
|
||||
});
|
||||
|
||||
it("falls back to the original string on an Invalid Date", () => {
|
||||
// "not-a-date" cannot be parsed; formatter must return the input verbatim
|
||||
// rather than "Invalid Date" leaking to the UI.
|
||||
expect(formatDateLabel("not-a-date")).toBe("not-a-date");
|
||||
});
|
||||
|
||||
it("stringifies non-string labels (numbers) instead of trying to parse them", () => {
|
||||
expect(formatDateLabel(42)).toBe("42");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatPlainLabel (plain mode)", () => {
|
||||
it("passes a regular string through unchanged", () => {
|
||||
expect(formatPlainLabel("Alice")).toBe("Alice");
|
||||
});
|
||||
|
||||
it("stringifies numbers", () => {
|
||||
expect(formatPlainLabel(7)).toBe("7");
|
||||
});
|
||||
|
||||
it("returns empty string for null/undefined instead of 'null'/'undefined'", () => {
|
||||
expect(formatPlainLabel(undefined)).toBe("");
|
||||
// @ts-expect-error — runtime behavior for null is the contract we care about
|
||||
expect(formatPlainLabel(null)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldRenderLabel (empty label suppression)", () => {
|
||||
it("returns false for undefined", () => {
|
||||
expect(shouldRenderLabel(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for null", () => {
|
||||
// @ts-expect-error — recharts can hand us null at runtime
|
||||
expect(shouldRenderLabel(null)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for empty string", () => {
|
||||
expect(shouldRenderLabel("")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for a non-empty string", () => {
|
||||
expect(shouldRenderLabel("2026-03-15")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for a number (including 0)", () => {
|
||||
expect(shouldRenderLabel(0)).toBe(true);
|
||||
expect(shouldRenderLabel(42)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,874 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { DatabaseConnection } from "../../src/main/database/connection";
|
||||
import { ApprovalService } from "../../src/main/services/approval-service";
|
||||
import { ChatService } from "../../src/main/services/chat-service";
|
||||
import { OrganizationService } from "../../src/main/services/organization-service";
|
||||
import { ProviderService } from "../../src/main/services/provider-service";
|
||||
import { providerAdapters } from "../../src/main/services/provider-adapters";
|
||||
import { SnapshotService } from "../../src/main/services/snapshot-service";
|
||||
import { RISK_LEVEL } from "../../src/shared/domain";
|
||||
|
||||
function createConnection(): DatabaseConnection {
|
||||
return new DatabaseConnection(join(tmpdir(), `agentcompany-new-chat-${randomUUID()}.db`));
|
||||
}
|
||||
|
||||
const testVault = {
|
||||
encryptSecret: (secret: string) => Buffer.from(secret, "utf8"),
|
||||
decryptSecret: (ciphertext: Buffer) => ciphertext.toString("utf8"),
|
||||
};
|
||||
|
||||
describe("chat governance", () => {
|
||||
test("persists memberships, retention policy, and structured delivery metadata", () => {
|
||||
const connection = createConnection();
|
||||
const organization = new OrganizationService(connection.db);
|
||||
const chat = new ChatService(connection.db);
|
||||
const snapshot = new SnapshotService(connection.db);
|
||||
let orgId = "";
|
||||
let employeeId = "";
|
||||
let agentId = "";
|
||||
let spaceId = "";
|
||||
|
||||
connection.transaction(() => {
|
||||
orgId = organization.createOrganization({
|
||||
organizationName: "Acme Operations",
|
||||
employeeName: "Dana Lee",
|
||||
mission: "Coordinate agents with auditable approvals.",
|
||||
goal: "Ship a verified control plane.",
|
||||
});
|
||||
employeeId = (connection.db.prepare("select id from employees where org_id = ?").get(orgId) as { id: string }).id;
|
||||
agentId = organization.createAgent({
|
||||
orgId,
|
||||
name: "Ada Lovelace",
|
||||
role: "Release reviewer",
|
||||
department: "Engineering",
|
||||
modelProviderId: null,
|
||||
modelId: null,
|
||||
budgetCents: 10000,
|
||||
managerAgentId: null,
|
||||
});
|
||||
spaceId = chat.createSpace({
|
||||
orgId,
|
||||
spaceType: "project",
|
||||
name: "release-review",
|
||||
topic: "Release review",
|
||||
visibility: "restricted",
|
||||
memberEmployeeIds: [employeeId],
|
||||
memberAgentIds: [agentId],
|
||||
});
|
||||
chat.sendMessage({
|
||||
orgId,
|
||||
spaceId,
|
||||
threadId: null,
|
||||
senderType: "employee",
|
||||
senderId: employeeId,
|
||||
bodyMd: "/escalate review @Ada-Lovelace release gate",
|
||||
attachmentFileIds: [],
|
||||
});
|
||||
});
|
||||
|
||||
const state = snapshot.load();
|
||||
const message = state.chatMessages[0];
|
||||
const structure = JSON.parse(message!.bodyStructuredJson) as {
|
||||
slashInvocation: { command: string };
|
||||
mentions: Array<{ targetType: string; targetId: string }>;
|
||||
};
|
||||
expect(state.chatSpaces.find((space) => space.id === spaceId)?.retentionPolicy).toBe("restricted_365d");
|
||||
expect(state.chatMemberships
|
||||
.filter((membership) => membership.spaceId === spaceId)
|
||||
.map((membership) => `${membership.principalType}:${membership.principalId}`)
|
||||
.sort())
|
||||
.toEqual([`agent:${agentId}`, `employee:${employeeId}`]);
|
||||
expect(message).toMatchObject({ deliveryState: "sent" });
|
||||
expect(structure.slashInvocation.command).toBe("escalate");
|
||||
expect(structure.mentions[0]).toMatchObject({ targetType: "agent", targetId: agentId });
|
||||
connection.close();
|
||||
});
|
||||
|
||||
test("audits chat message edits and deletes", () => {
|
||||
const connection = createConnection();
|
||||
const organization = new OrganizationService(connection.db);
|
||||
const chat = new ChatService(connection.db);
|
||||
const snapshot = new SnapshotService(connection.db);
|
||||
let orgId = "";
|
||||
let employeeId = "";
|
||||
let agentId = "";
|
||||
let spaceId = "";
|
||||
let messageId = "";
|
||||
|
||||
connection.transaction(() => {
|
||||
orgId = organization.createOrganization({
|
||||
organizationName: "Acme Operations",
|
||||
employeeName: "Dana Lee",
|
||||
mission: "Coordinate agents with auditable approvals.",
|
||||
goal: "Ship a verified control plane.",
|
||||
});
|
||||
employeeId = (connection.db.prepare("select id from employees where org_id = ?").get(orgId) as { id: string }).id;
|
||||
agentId = organization.createAgent({
|
||||
orgId,
|
||||
name: "Ada Lovelace",
|
||||
role: "Reviewer",
|
||||
department: "Engineering",
|
||||
modelProviderId: null,
|
||||
modelId: null,
|
||||
budgetCents: 10000,
|
||||
managerAgentId: null,
|
||||
});
|
||||
spaceId = chat.createSpace({ orgId, spaceType: "project", name: "release", topic: "Release coordination", visibility: "workspace" });
|
||||
messageId = chat.sendMessage({
|
||||
orgId,
|
||||
spaceId,
|
||||
threadId: null,
|
||||
senderType: "employee",
|
||||
senderId: employeeId,
|
||||
bodyMd: "Initial launch note @Ada-Lovelace",
|
||||
attachmentFileIds: [],
|
||||
});
|
||||
chat.editMessage({
|
||||
orgId,
|
||||
messageId,
|
||||
actorType: "employee",
|
||||
actorId: employeeId,
|
||||
bodyMd: `Updated launch note @${agentId}`,
|
||||
});
|
||||
chat.deleteMessage({ orgId, messageId, actorType: "employee", actorId: employeeId });
|
||||
});
|
||||
|
||||
const state = snapshot.load();
|
||||
const message = state.chatMessages.find((item) => item.id === messageId);
|
||||
expect(message).toMatchObject({ bodyMd: `Updated launch note @${agentId}` });
|
||||
expect(message?.editedAt).toBeTruthy();
|
||||
expect(message?.deletedAt).toBeTruthy();
|
||||
expect(state.chatMentions).toEqual([expect.objectContaining({ messageId, targetType: "agent", targetId: agentId })]);
|
||||
expect(state.auditEvents).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ entityType: "chat_message", entityId: messageId, action: "edited" }),
|
||||
expect.objectContaining({ entityType: "chat_message", entityId: messageId, action: "deleted" }),
|
||||
]));
|
||||
connection.close();
|
||||
});
|
||||
|
||||
test("decides approvals from chat slash commands", () => {
|
||||
const connection = createConnection();
|
||||
const organization = new OrganizationService(connection.db);
|
||||
const approvals = new ApprovalService(connection.db);
|
||||
const chat = new ChatService(connection.db);
|
||||
const snapshot = new SnapshotService(connection.db);
|
||||
let orgId = "";
|
||||
let employeeId = "";
|
||||
let spaceId = "";
|
||||
let approvalId = "";
|
||||
let declinedApprovalId = "";
|
||||
|
||||
connection.transaction(() => {
|
||||
orgId = organization.createOrganization({
|
||||
organizationName: "Acme Operations",
|
||||
employeeName: "Dana Lee",
|
||||
mission: "Coordinate agents with auditable approvals.",
|
||||
goal: "Ship a verified control plane.",
|
||||
});
|
||||
employeeId = (connection.db.prepare("select id from employees where org_id = ?").get(orgId) as { id: string }).id;
|
||||
spaceId = chat.createSpace({ orgId, spaceType: "project", name: "release", topic: "Release coordination", visibility: "workspace" });
|
||||
approvalId = approvals.create({
|
||||
orgId,
|
||||
kind: "release_gate",
|
||||
title: "Release gate",
|
||||
details: "Review the release window.",
|
||||
risk: RISK_LEVEL.HIGH,
|
||||
requestedByType: "employee",
|
||||
requestedById: employeeId,
|
||||
});
|
||||
declinedApprovalId = approvals.create({
|
||||
orgId,
|
||||
kind: "release_gate",
|
||||
title: "Rollback gate",
|
||||
details: "Review rollback window.",
|
||||
risk: RISK_LEVEL.HIGH,
|
||||
requestedByType: "employee",
|
||||
requestedById: employeeId,
|
||||
});
|
||||
chat.sendMessage({
|
||||
orgId,
|
||||
spaceId,
|
||||
threadId: null,
|
||||
senderType: "employee",
|
||||
senderId: employeeId,
|
||||
bodyMd: `/approve ${approvalId} release window cleared`,
|
||||
attachmentFileIds: [],
|
||||
});
|
||||
chat.sendMessage({
|
||||
orgId,
|
||||
spaceId,
|
||||
threadId: null,
|
||||
senderType: "employee",
|
||||
senderId: employeeId,
|
||||
bodyMd: `/deny ${declinedApprovalId} rollback window blocked`,
|
||||
attachmentFileIds: [],
|
||||
});
|
||||
});
|
||||
|
||||
const state = snapshot.load();
|
||||
expect(state.approvals.find((item) => item.id === approvalId)?.state).toBe("approved");
|
||||
expect(state.approvals.find((item) => item.id === declinedApprovalId)?.state).toBe("declined");
|
||||
expect(state.chatSlashInvocations).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ command: "approve", resultRef: approvalId }),
|
||||
expect.objectContaining({ command: "deny", resultRef: declinedApprovalId }),
|
||||
]));
|
||||
expect(state.auditEvents).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ entityType: "approval", entityId: approvalId, action: "approved" }),
|
||||
expect.objectContaining({ entityType: "approval", entityId: declinedApprovalId, action: "declined" }),
|
||||
]));
|
||||
connection.close();
|
||||
});
|
||||
|
||||
test("routes collaboration slash commands to concrete domain targets", () => {
|
||||
const connection = createConnection();
|
||||
const organization = new OrganizationService(connection.db);
|
||||
const providers = new ProviderService(connection.db, providerAdapters, testVault);
|
||||
const chat = new ChatService(connection.db);
|
||||
const snapshot = new SnapshotService(connection.db);
|
||||
let orgId = "";
|
||||
let employeeId = "";
|
||||
let agentId = "";
|
||||
let providerId = "";
|
||||
let spaceId = "";
|
||||
let skillId = "";
|
||||
let fileId = "";
|
||||
let agentMessageId = "";
|
||||
let skillMessageId = "";
|
||||
let fileMessageId = "";
|
||||
let runbookMessageId = "";
|
||||
let handoffMessageId = "";
|
||||
|
||||
connection.transaction(() => {
|
||||
orgId = organization.createOrganization({
|
||||
organizationName: "Acme Operations",
|
||||
employeeName: "Dana Lee",
|
||||
mission: "Coordinate agents with auditable approvals.",
|
||||
goal: "Ship a verified control plane.",
|
||||
});
|
||||
employeeId = (connection.db.prepare("select id from employees where org_id = ?").get(orgId) as { id: string }).id;
|
||||
providerId = (connection.db.prepare("select id from providers where org_id = ? and provider_key = 'openai'")
|
||||
.get(orgId) as { id: string }).id;
|
||||
providers.saveCredential({ orgId, providerId, secret: "sk-live-provider", model: "gpt-5.4" });
|
||||
agentId = organization.createAgent({
|
||||
orgId,
|
||||
name: "Ada",
|
||||
role: "Release reviewer",
|
||||
department: "Engineering",
|
||||
modelProviderId: providerId,
|
||||
modelId: "gpt-5.4",
|
||||
budgetCents: 10000,
|
||||
managerAgentId: null,
|
||||
});
|
||||
spaceId = chat.createSpace({ orgId, spaceType: "project", name: "release", topic: "Release coordination", visibility: "workspace" });
|
||||
skillId = "skd_release_gate";
|
||||
fileId = "fil_release_notes";
|
||||
const now = new Date().toISOString();
|
||||
connection.db.prepare(`
|
||||
insert into skill_definitions(id, org_id, slug, name, owner_type, status, created_at)
|
||||
values (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(skillId, orgId, "release-gate", "Release Gate", "system", "active", now);
|
||||
connection.db.prepare(`
|
||||
insert into file_objects(id, org_id, name, kind, size_bytes, sha256, storage_path, owner_type, owner_id, created_at)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(fileId, orgId, "release-notes.md", "md", 24, "8f3a", "artifacts/release-notes.md", "employee", employeeId, now);
|
||||
agentMessageId = chat.sendMessage({
|
||||
orgId,
|
||||
spaceId,
|
||||
threadId: null,
|
||||
senderType: "employee",
|
||||
senderId: employeeId,
|
||||
bodyMd: "/agent @Ada review the gate",
|
||||
attachmentFileIds: [],
|
||||
});
|
||||
skillMessageId = chat.sendMessage({
|
||||
orgId,
|
||||
spaceId,
|
||||
threadId: null,
|
||||
senderType: "employee",
|
||||
senderId: employeeId,
|
||||
bodyMd: "/skill release-gate apply release checks",
|
||||
attachmentFileIds: [],
|
||||
});
|
||||
fileMessageId = chat.sendMessage({
|
||||
orgId,
|
||||
spaceId,
|
||||
threadId: null,
|
||||
senderType: "employee",
|
||||
senderId: employeeId,
|
||||
bodyMd: "/file release-notes.md",
|
||||
attachmentFileIds: [],
|
||||
});
|
||||
runbookMessageId = chat.sendMessage({
|
||||
orgId,
|
||||
spaceId,
|
||||
threadId: null,
|
||||
senderType: "employee",
|
||||
senderId: employeeId,
|
||||
bodyMd: "/runbook release rollback steps",
|
||||
attachmentFileIds: [],
|
||||
});
|
||||
handoffMessageId = chat.sendMessage({
|
||||
orgId,
|
||||
spaceId,
|
||||
threadId: null,
|
||||
senderType: "employee",
|
||||
senderId: employeeId,
|
||||
bodyMd: "/handoff @Ada release notes",
|
||||
attachmentFileIds: [fileId],
|
||||
});
|
||||
});
|
||||
|
||||
const state = snapshot.load();
|
||||
const slashByMessage = new Map(state.chatSlashInvocations.map((item) => [item.messageId, item]));
|
||||
expect(slashByMessage.get(agentMessageId)).toMatchObject({ command: "agent", resultRef: agentId });
|
||||
expect(slashByMessage.get(skillMessageId)).toMatchObject({ command: "skill", resultRef: skillId });
|
||||
expect(slashByMessage.get(fileMessageId)).toMatchObject({ command: "file", resultRef: fileId });
|
||||
expect(slashByMessage.get(runbookMessageId)).toMatchObject({ command: "runbook", resultRef: runbookMessageId });
|
||||
expect(slashByMessage.get(handoffMessageId)).toMatchObject({ command: "handoff", resultRef: fileId });
|
||||
const agentTask = state.tasks.find((item) => item.assigneeAgentId === agentId && item.title === "Chat mention: review the gate");
|
||||
expect(agentTask).toMatchObject({
|
||||
orgId,
|
||||
assigneeAgentId: agentId,
|
||||
status: "backlog",
|
||||
risk: "low",
|
||||
priority: "high",
|
||||
requiresFilesystem: false,
|
||||
});
|
||||
expect(agentTask?.description).toContain(`Source message: ${agentMessageId}`);
|
||||
const agentRun = state.taskRuns.find((item) => item.taskId === agentTask?.id);
|
||||
expect(agentRun).toMatchObject({
|
||||
orgId,
|
||||
agentId,
|
||||
providerId,
|
||||
trigger: "chat_mention",
|
||||
state: "queued",
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
errorMessage: null,
|
||||
});
|
||||
expect(agentRun?.executionContextId).toMatch(/^ctx_/);
|
||||
expect(agentRun?.serviceIdentityId).toMatch(/^sid_/);
|
||||
expect(JSON.parse(agentRun?.secretScopeJson ?? "{}")).toMatchObject({
|
||||
serviceIdentityId: agentRun?.serviceIdentityId,
|
||||
providerIds: [providerId],
|
||||
scope: "chat_mention",
|
||||
credentialAccess: "provider_runtime_only",
|
||||
sourceMessageId: agentMessageId,
|
||||
});
|
||||
expect(state.chatPresence).toContainEqual(expect.objectContaining({ principalType: "agent", principalId: agentId, status: "thinking" }));
|
||||
expect(state.fileHandoffRecords).toContainEqual(expect.objectContaining({
|
||||
messageId: handoffMessageId,
|
||||
fileId,
|
||||
targetPrincipalType: "agent",
|
||||
targetPrincipalId: agentId,
|
||||
policyDecision: "approved_for_handoff",
|
||||
}));
|
||||
expect(state.auditEvents.filter((event) => event.action === "slash_routed")).toHaveLength(5);
|
||||
connection.close();
|
||||
});
|
||||
|
||||
test("bounds autonomous agent-to-agent wake loops until a human re-engages", () => {
|
||||
const connection = createConnection();
|
||||
const organization = new OrganizationService(connection.db);
|
||||
const providers = new ProviderService(connection.db, providerAdapters, testVault);
|
||||
const chat = new ChatService(connection.db);
|
||||
let orgId = "";
|
||||
let employeeId = "";
|
||||
let speakerId = "";
|
||||
let targetId = "";
|
||||
let spaceId = "";
|
||||
let threadId = "";
|
||||
|
||||
connection.transaction(() => {
|
||||
orgId = organization.createOrganization({
|
||||
organizationName: "Acme Operations",
|
||||
employeeName: "Dana Lee",
|
||||
mission: "Coordinate agents with auditable approvals.",
|
||||
goal: "Ship a verified control plane.",
|
||||
});
|
||||
employeeId = (connection.db.prepare("select id from employees where org_id = ?").get(orgId) as { id: string }).id;
|
||||
const providerId = (connection.db.prepare("select id from providers where org_id = ? and provider_key = 'openai'")
|
||||
.get(orgId) as { id: string }).id;
|
||||
providers.saveCredential({ orgId, providerId, secret: "sk-live-provider", model: "gpt-5.4" });
|
||||
speakerId = organization.createAgent({ orgId, name: "Alice", role: "Engineer", department: "Engineering", modelProviderId: providerId, modelId: "gpt-5.4", budgetCents: 10000, managerAgentId: null });
|
||||
targetId = organization.createAgent({ orgId, name: "Bob", role: "Engineer", department: "Engineering", modelProviderId: providerId, modelId: "gpt-5.4", budgetCents: 10000, managerAgentId: null });
|
||||
spaceId = chat.createSpace({ orgId, spaceType: "project", name: "engineering", topic: "Eng", visibility: "workspace" });
|
||||
});
|
||||
|
||||
const wakeRunCount = (): number => (connection.db.prepare(
|
||||
"select count(*) as n from task_runs where org_id = ? and agent_id = ? and trigger = 'chat_mention'",
|
||||
).get(orgId, targetId) as { n: number }).n;
|
||||
|
||||
// Alice (an agent) keeps @mentioning Bob in one thread. Each post is an
|
||||
// autonomous turn; only the first batch wakes Bob, then the budget is spent.
|
||||
connection.transaction(() => {
|
||||
const first = chat.sendMessage({ orgId, spaceId, threadId: null, senderType: "agent", senderId: speakerId, bodyMd: "@Bob turn 1", attachmentFileIds: [] });
|
||||
threadId = (connection.db.prepare("select thread_id from chat_messages where id = ?").get(first) as { thread_id: string }).thread_id;
|
||||
for (let turn = 2; turn <= 40; turn += 1) {
|
||||
chat.sendMessage({ orgId, spaceId, threadId, senderType: "agent", senderId: speakerId, bodyMd: `@Bob turn ${turn}`, attachmentFileIds: [] });
|
||||
}
|
||||
});
|
||||
const capped = wakeRunCount();
|
||||
// A real exchange happened, but it is finite — far below the 40 posts.
|
||||
expect(capped).toBeGreaterThanOrEqual(10);
|
||||
expect(capped).toBeLessThanOrEqual(16);
|
||||
|
||||
// A human re-enters the thread: their message wakes Bob and grants the
|
||||
// agents a fresh budget, so the next agent turn wakes him again.
|
||||
connection.transaction(() => {
|
||||
chat.sendMessage({ orgId, spaceId, threadId, senderType: "employee", senderId: employeeId, bodyMd: "Keep going @Bob", attachmentFileIds: [] });
|
||||
chat.sendMessage({ orgId, spaceId, threadId, senderType: "agent", senderId: speakerId, bodyMd: "@Bob resuming", attachmentFileIds: [] });
|
||||
});
|
||||
expect(wakeRunCount()).toBeGreaterThan(capped);
|
||||
|
||||
connection.close();
|
||||
});
|
||||
|
||||
test("records board interventions and enforces thread control", () => {
|
||||
const connection = createConnection();
|
||||
const organization = new OrganizationService(connection.db);
|
||||
const chat = new ChatService(connection.db);
|
||||
const snapshot = new SnapshotService(connection.db);
|
||||
let orgId = "";
|
||||
let employeeId = "";
|
||||
let agentId = "";
|
||||
let spaceId = "";
|
||||
let threadId = "";
|
||||
|
||||
connection.transaction(() => {
|
||||
orgId = organization.createOrganization({
|
||||
organizationName: "Acme Operations",
|
||||
employeeName: "Dana Lee",
|
||||
mission: "Coordinate agents with auditable approvals.",
|
||||
goal: "Ship a verified control plane.",
|
||||
});
|
||||
employeeId = (connection.db.prepare("select id from employees where org_id = ?").get(orgId) as { id: string }).id;
|
||||
agentId = organization.createAgent({
|
||||
orgId,
|
||||
name: "Ada",
|
||||
role: "Release reviewer",
|
||||
department: "Engineering",
|
||||
modelProviderId: null,
|
||||
modelId: null,
|
||||
budgetCents: 10000,
|
||||
managerAgentId: null,
|
||||
});
|
||||
spaceId = chat.createSpace({ orgId, spaceType: "project", name: "release", topic: "Release coordination", visibility: "workspace" });
|
||||
chat.sendMessage({
|
||||
orgId,
|
||||
spaceId,
|
||||
threadId: null,
|
||||
senderType: "employee",
|
||||
senderId: employeeId,
|
||||
bodyMd: "Release thread",
|
||||
attachmentFileIds: [],
|
||||
});
|
||||
threadId = (connection.db.prepare("select id from chat_threads where space_id = ?").get(spaceId) as { id: string }).id;
|
||||
chat.sendMessage({
|
||||
orgId,
|
||||
spaceId,
|
||||
threadId,
|
||||
senderType: "board",
|
||||
senderId: employeeId,
|
||||
bodyMd: "/board freeze hold the release",
|
||||
attachmentFileIds: [],
|
||||
});
|
||||
});
|
||||
|
||||
expect(() => connection.transaction(() => chat.sendMessage({
|
||||
orgId,
|
||||
spaceId,
|
||||
threadId,
|
||||
senderType: "employee",
|
||||
senderId: employeeId,
|
||||
bodyMd: "Proceeding",
|
||||
attachmentFileIds: [],
|
||||
}))).toThrow("Board-frozen thread requires board sender.");
|
||||
|
||||
connection.transaction(() => {
|
||||
chat.sendMessage({
|
||||
orgId,
|
||||
spaceId,
|
||||
threadId,
|
||||
senderType: "board",
|
||||
senderId: employeeId,
|
||||
bodyMd: "/board rollback reopen release coordination",
|
||||
attachmentFileIds: [],
|
||||
});
|
||||
chat.sendMessage({
|
||||
orgId,
|
||||
spaceId,
|
||||
threadId,
|
||||
senderType: "board",
|
||||
senderId: employeeId,
|
||||
bodyMd: `/board takeover @${agentId} Ada owns release checks`,
|
||||
attachmentFileIds: [],
|
||||
});
|
||||
});
|
||||
|
||||
expect(() => connection.transaction(() => chat.sendMessage({
|
||||
orgId,
|
||||
spaceId,
|
||||
threadId,
|
||||
senderType: "employee",
|
||||
senderId: employeeId,
|
||||
bodyMd: "Taking back control",
|
||||
attachmentFileIds: [],
|
||||
}))).toThrow("Board takeover controls this thread.");
|
||||
|
||||
connection.transaction(() => {
|
||||
chat.sendMessage({
|
||||
orgId,
|
||||
spaceId,
|
||||
threadId,
|
||||
senderType: "agent",
|
||||
senderId: agentId,
|
||||
bodyMd: "Release checks are in progress.",
|
||||
attachmentFileIds: [],
|
||||
});
|
||||
chat.sendMessage({
|
||||
orgId,
|
||||
spaceId,
|
||||
threadId,
|
||||
senderType: "board",
|
||||
senderId: employeeId,
|
||||
bodyMd: "/board force-approval approve gated release window",
|
||||
attachmentFileIds: [],
|
||||
});
|
||||
});
|
||||
|
||||
const state = snapshot.load();
|
||||
const thread = state.chatThreads.find((item) => item.id === threadId);
|
||||
expect(thread).toMatchObject({ boardState: "takeover", controlledByType: "agent", controlledById: agentId });
|
||||
expect(state.boardInterventions.map((item) => item.action)).toEqual(["freeze", "rollback", "takeover", "force_approval"]);
|
||||
const forceApproval = state.approvals.find((item) => item.kind === "board_force_approval");
|
||||
if (!forceApproval) throw new Error("Board force approval was not created.");
|
||||
const forceIntervention = state.boardInterventions.find((item) => item.action === "force_approval");
|
||||
if (!forceIntervention) throw new Error("Board force approval intervention was not created.");
|
||||
expect(forceIntervention.approvalId).toBe(forceApproval.id);
|
||||
expect(state.chatSlashInvocations.some((item) => item.resultRef === forceIntervention.id)).toBe(true);
|
||||
expect(state.auditEvents.some((event) => event.entityType === "board_intervention" && event.action === "recorded")).toBe(true);
|
||||
connection.close();
|
||||
});
|
||||
|
||||
test("rejects chat writes outside membership and prevents partial invalid messages", () => {
|
||||
const connection = createConnection();
|
||||
const organization = new OrganizationService(connection.db);
|
||||
const chat = new ChatService(connection.db);
|
||||
let orgId = "";
|
||||
let employeeId = "";
|
||||
let agentId = "";
|
||||
let restrictedSpaceId = "";
|
||||
let workspaceSpaceId = "";
|
||||
|
||||
connection.transaction(() => {
|
||||
orgId = organization.createOrganization({
|
||||
organizationName: "Acme Operations",
|
||||
employeeName: "Dana Lee",
|
||||
mission: "Coordinate agents with auditable approvals.",
|
||||
goal: "Ship a verified control plane.",
|
||||
});
|
||||
employeeId = (connection.db.prepare("select id from employees where org_id = ?").get(orgId) as { id: string }).id;
|
||||
agentId = organization.createAgent({
|
||||
orgId,
|
||||
name: "Ada",
|
||||
role: "Release reviewer",
|
||||
department: "Engineering",
|
||||
modelProviderId: null,
|
||||
modelId: null,
|
||||
budgetCents: 10000,
|
||||
managerAgentId: null,
|
||||
});
|
||||
restrictedSpaceId = chat.createSpace({
|
||||
orgId,
|
||||
spaceType: "project",
|
||||
name: "board-review",
|
||||
topic: "Restricted release review",
|
||||
visibility: "restricted",
|
||||
memberEmployeeIds: [employeeId],
|
||||
});
|
||||
workspaceSpaceId = chat.createSpace({
|
||||
orgId,
|
||||
spaceType: "project",
|
||||
name: "release",
|
||||
topic: "Release coordination",
|
||||
visibility: "workspace",
|
||||
});
|
||||
});
|
||||
|
||||
expect(() => connection.transaction(() => chat.sendMessage({
|
||||
orgId,
|
||||
spaceId: restrictedSpaceId,
|
||||
threadId: null,
|
||||
senderType: "agent",
|
||||
senderId: agentId,
|
||||
bodyMd: "I should not be here.",
|
||||
attachmentFileIds: [],
|
||||
}))).toThrow("Chat membership is required.");
|
||||
expect(() => connection.transaction(() => chat.sendMessage({
|
||||
orgId,
|
||||
spaceId: restrictedSpaceId,
|
||||
threadId: null,
|
||||
senderType: "employee",
|
||||
senderId: employeeId,
|
||||
bodyMd: "Restricted ping @Ada",
|
||||
attachmentFileIds: [],
|
||||
}))).toThrow("Mention @Ada could not be resolved.");
|
||||
expect(() => connection.transaction(() => chat.sendMessage({
|
||||
orgId,
|
||||
spaceId: workspaceSpaceId,
|
||||
threadId: null,
|
||||
senderType: "employee",
|
||||
senderId: employeeId,
|
||||
bodyMd: "/unknown command",
|
||||
attachmentFileIds: [],
|
||||
}))).toThrow("Slash command /unknown is unavailable.");
|
||||
expect(() => connection.transaction(() => chat.sendMessage({
|
||||
orgId,
|
||||
spaceId: workspaceSpaceId,
|
||||
threadId: null,
|
||||
senderType: "employee",
|
||||
senderId: employeeId,
|
||||
bodyMd: "Missing file",
|
||||
attachmentFileIds: ["file_missing"],
|
||||
}))).toThrow("Attachment file was not found.");
|
||||
expect(() => connection.transaction(() => chat.sendMessage({
|
||||
orgId,
|
||||
spaceId: workspaceSpaceId,
|
||||
threadId: null,
|
||||
senderType: "employee",
|
||||
senderId: employeeId,
|
||||
bodyMd: "/escalate",
|
||||
attachmentFileIds: [],
|
||||
}))).toThrow("Slash command /escalate requires details.");
|
||||
expect(() => connection.transaction(() => chat.sendMessage({
|
||||
orgId,
|
||||
spaceId: workspaceSpaceId,
|
||||
threadId: null,
|
||||
senderType: "employee",
|
||||
senderId: employeeId,
|
||||
bodyMd: "/approve",
|
||||
attachmentFileIds: [],
|
||||
}))).toThrow("Slash command /approve requires an approval id.");
|
||||
expect(() => connection.transaction(() => chat.sendMessage({
|
||||
orgId,
|
||||
spaceId: workspaceSpaceId,
|
||||
threadId: null,
|
||||
senderType: "employee",
|
||||
senderId: employeeId,
|
||||
bodyMd: "/agent wake release checks",
|
||||
attachmentFileIds: [],
|
||||
}))).toThrow("Slash command /agent requires an agent mention.");
|
||||
expect(() => connection.transaction(() => chat.sendMessage({
|
||||
orgId,
|
||||
spaceId: workspaceSpaceId,
|
||||
threadId: null,
|
||||
senderType: "employee",
|
||||
senderId: employeeId,
|
||||
bodyMd: "/handoff @Ada release notes",
|
||||
attachmentFileIds: [],
|
||||
}))).toThrow("Slash command /handoff requires a file attachment.");
|
||||
expect(() => connection.transaction(() => chat.sendMessage({
|
||||
orgId,
|
||||
spaceId: workspaceSpaceId,
|
||||
threadId: null,
|
||||
senderType: "employee",
|
||||
senderId: employeeId,
|
||||
bodyMd: "/file missing.md",
|
||||
attachmentFileIds: [],
|
||||
}))).toThrow("Slash command /file target was not found.");
|
||||
expect(() => connection.transaction(() => chat.sendMessage({
|
||||
orgId,
|
||||
spaceId: workspaceSpaceId,
|
||||
threadId: null,
|
||||
senderType: "employee",
|
||||
senderId: employeeId,
|
||||
bodyMd: "/skill missing-skill",
|
||||
attachmentFileIds: [],
|
||||
}))).toThrow("Slash command /skill target was not found.");
|
||||
expect(() => connection.transaction(() => chat.sendMessage({
|
||||
orgId,
|
||||
spaceId: workspaceSpaceId,
|
||||
threadId: null,
|
||||
senderType: "employee",
|
||||
senderId: employeeId,
|
||||
bodyMd: "Unknown @Nobody",
|
||||
attachmentFileIds: [],
|
||||
}))).toThrow("Mention @Nobody could not be resolved.");
|
||||
|
||||
const count = connection.db.prepare("select count(*) as count from chat_messages").get() as { count: number };
|
||||
expect(count.count).toBe(0);
|
||||
connection.close();
|
||||
});
|
||||
|
||||
test("rejects reserved mention handles for employees and agents", () => {
|
||||
const connection = createConnection();
|
||||
const organization = new OrganizationService(connection.db);
|
||||
let orgId = "";
|
||||
|
||||
expect(() => connection.transaction(() => organization.createOrganization({
|
||||
organizationName: "Acme Operations",
|
||||
employeeName: "Board",
|
||||
mission: "Coordinate agents with auditable approvals.",
|
||||
goal: "Ship a verified control plane.",
|
||||
}))).toThrow("Mention handle is reserved.");
|
||||
|
||||
connection.transaction(() => {
|
||||
orgId = organization.createOrganization({
|
||||
organizationName: "Acme Operations",
|
||||
employeeName: "Dana Lee",
|
||||
mission: "Coordinate agents with auditable approvals.",
|
||||
goal: "Ship a verified control plane.",
|
||||
});
|
||||
});
|
||||
expect(() => connection.transaction(() => organization.createAgent({
|
||||
orgId,
|
||||
name: "On Call",
|
||||
role: "Escalation lead",
|
||||
department: "Operations",
|
||||
modelProviderId: null,
|
||||
modelId: null,
|
||||
budgetCents: 10000,
|
||||
managerAgentId: null,
|
||||
}))).toThrow("Mention handle is reserved.");
|
||||
connection.close();
|
||||
});
|
||||
|
||||
test("rejects ambiguous chat mentions", () => {
|
||||
const connection = createConnection();
|
||||
const organization = new OrganizationService(connection.db);
|
||||
const chat = new ChatService(connection.db);
|
||||
let orgId = "";
|
||||
let employeeId = "";
|
||||
let spaceId = "";
|
||||
|
||||
connection.transaction(() => {
|
||||
orgId = organization.createOrganization({
|
||||
organizationName: "Acme Operations",
|
||||
employeeName: "Dana Lee",
|
||||
mission: "Coordinate agents with auditable approvals.",
|
||||
goal: "Ship a verified control plane.",
|
||||
});
|
||||
employeeId = (connection.db.prepare("select id from employees where org_id = ?").get(orgId) as { id: string }).id;
|
||||
organization.createAgent({ orgId, name: "Ada", role: "Reviewer", department: "Engineering", modelProviderId: null, modelId: null, budgetCents: 10000, managerAgentId: null });
|
||||
organization.createAgent({ orgId, name: "Ada", role: "Release lead", department: "Engineering", modelProviderId: null, modelId: null, budgetCents: 10000, managerAgentId: null });
|
||||
spaceId = chat.createSpace({ orgId, spaceType: "project", name: "release", topic: "Release coordination", visibility: "workspace" });
|
||||
});
|
||||
|
||||
expect(() => connection.transaction(() => chat.sendMessage({
|
||||
orgId,
|
||||
spaceId,
|
||||
threadId: null,
|
||||
senderType: "employee",
|
||||
senderId: employeeId,
|
||||
bodyMd: "Please review @Ada",
|
||||
attachmentFileIds: [],
|
||||
}))).toThrow("Mention @Ada is ambiguous.");
|
||||
const count = connection.db.prepare("select count(*) as count from chat_messages").get() as { count: number };
|
||||
expect(count.count).toBe(0);
|
||||
connection.close();
|
||||
});
|
||||
|
||||
test("resolves chat mentions by display handle and principal id", () => {
|
||||
const connection = createConnection();
|
||||
const organization = new OrganizationService(connection.db);
|
||||
const chat = new ChatService(connection.db);
|
||||
let orgId = "";
|
||||
let employeeId = "";
|
||||
let agentId = "";
|
||||
let spaceId = "";
|
||||
|
||||
connection.transaction(() => {
|
||||
orgId = organization.createOrganization({
|
||||
organizationName: "Acme Operations",
|
||||
employeeName: "Dana Lee",
|
||||
mission: "Coordinate agents with auditable approvals.",
|
||||
goal: "Ship a verified control plane.",
|
||||
});
|
||||
employeeId = (connection.db.prepare("select id from employees where org_id = ?").get(orgId) as { id: string }).id;
|
||||
agentId = organization.createAgent({
|
||||
orgId,
|
||||
name: "Ada Lovelace",
|
||||
role: "Reviewer",
|
||||
department: "Engineering",
|
||||
modelProviderId: null,
|
||||
modelId: null,
|
||||
budgetCents: 10000,
|
||||
managerAgentId: null,
|
||||
});
|
||||
spaceId = chat.createSpace({ orgId, spaceType: "project", name: "release", topic: "Release coordination", visibility: "workspace" });
|
||||
});
|
||||
|
||||
connection.transaction(() => {
|
||||
chat.sendMessage({
|
||||
orgId,
|
||||
spaceId,
|
||||
threadId: null,
|
||||
senderType: "employee",
|
||||
senderId: employeeId,
|
||||
bodyMd: `Review @Ada-Lovelace and @${agentId}`,
|
||||
attachmentFileIds: [],
|
||||
});
|
||||
});
|
||||
|
||||
const mentions = connection.db.prepare(`
|
||||
select target_type, target_id
|
||||
from chat_mentions
|
||||
order by offset_start asc
|
||||
`).all() as Array<{ target_type: string; target_id: string }>;
|
||||
expect(mentions).toEqual([
|
||||
{ target_type: "agent", target_id: agentId },
|
||||
{ target_type: "agent", target_id: agentId },
|
||||
]);
|
||||
connection.close();
|
||||
});
|
||||
|
||||
test("rejects replies to threads from another chat space", () => {
|
||||
const connection = createConnection();
|
||||
const organization = new OrganizationService(connection.db);
|
||||
const chat = new ChatService(connection.db);
|
||||
let orgId = "";
|
||||
let employeeId = "";
|
||||
let firstSpaceId = "";
|
||||
let secondSpaceId = "";
|
||||
let threadId = "";
|
||||
|
||||
connection.transaction(() => {
|
||||
orgId = organization.createOrganization({
|
||||
organizationName: "Acme Operations",
|
||||
employeeName: "Dana Lee",
|
||||
mission: "Coordinate agents with auditable approvals.",
|
||||
goal: "Ship a verified control plane.",
|
||||
});
|
||||
employeeId = (connection.db.prepare("select id from employees where org_id = ?").get(orgId) as { id: string }).id;
|
||||
firstSpaceId = chat.createSpace({ orgId, spaceType: "project", name: "release-a", topic: "A", visibility: "workspace" });
|
||||
secondSpaceId = chat.createSpace({ orgId, spaceType: "project", name: "release-b", topic: "B", visibility: "workspace" });
|
||||
chat.sendMessage({
|
||||
orgId,
|
||||
spaceId: firstSpaceId,
|
||||
threadId: null,
|
||||
senderType: "employee",
|
||||
senderId: employeeId,
|
||||
bodyMd: "Root",
|
||||
attachmentFileIds: [],
|
||||
});
|
||||
threadId = (connection.db.prepare("select id from chat_threads where space_id = ?").get(firstSpaceId) as { id: string }).id;
|
||||
});
|
||||
|
||||
expect(() => connection.transaction(() => chat.sendMessage({
|
||||
orgId,
|
||||
spaceId: secondSpaceId,
|
||||
threadId,
|
||||
senderType: "employee",
|
||||
senderId: employeeId,
|
||||
bodyMd: "Wrong thread",
|
||||
attachmentFileIds: [],
|
||||
}))).toThrow("Chat thread was not found in this space.");
|
||||
const count = connection.db.prepare("select count(*) as count from chat_messages").get() as { count: number };
|
||||
expect(count.count).toBe(1);
|
||||
connection.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { coalesce } from "../../src/main/app/coalesce";
|
||||
|
||||
describe("coalesce", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test("collapses a burst into one trailing call", () => {
|
||||
const calls: number[] = [];
|
||||
const notify = coalesce(() => calls.push(Date.now()), 200);
|
||||
notify();
|
||||
notify();
|
||||
notify();
|
||||
expect(calls).toHaveLength(0);
|
||||
vi.advanceTimersByTime(199);
|
||||
expect(calls).toHaveLength(0);
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("fires again for events after the window closes", () => {
|
||||
let count = 0;
|
||||
const notify = coalesce(() => { count += 1; }, 200);
|
||||
notify();
|
||||
vi.advanceTimersByTime(200);
|
||||
expect(count).toBe(1);
|
||||
notify();
|
||||
notify();
|
||||
vi.advanceTimersByTime(200);
|
||||
expect(count).toBe(2);
|
||||
});
|
||||
|
||||
test("an event during the pending window is covered by the trailing call", () => {
|
||||
let count = 0;
|
||||
const notify = coalesce(() => { count += 1; }, 200);
|
||||
notify();
|
||||
vi.advanceTimersByTime(100);
|
||||
notify();
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(count).toBe(1);
|
||||
vi.advanceTimersByTime(200);
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -1,180 +0,0 @@
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("electron", () => ({
|
||||
safeStorage: {
|
||||
isEncryptionAvailable: () => false,
|
||||
getSelectedStorageBackend: () => "basic_text",
|
||||
encryptString: (value: string) => Buffer.from(value, "utf8"),
|
||||
decryptString: (value: Buffer) => value.toString("utf8"),
|
||||
},
|
||||
}));
|
||||
|
||||
import { AppDatabase } from "../../src/main/database";
|
||||
import {
|
||||
handleBeforeToolCall,
|
||||
type ActiveRunContext,
|
||||
} from "../../src/main/agent-runtime/hook-handlers";
|
||||
|
||||
function seedRun(db: AppDatabase, executionMode: "ai_ceo" | "ai_cofounder" = "ai_cofounder") {
|
||||
const companyId = db.core.company.saveCompany({
|
||||
name: "Gate Co",
|
||||
description: "Cofounder runtime gate test",
|
||||
status: "active",
|
||||
executionMode,
|
||||
});
|
||||
db.core.connectors.saveConnector({
|
||||
id: "openai-codex",
|
||||
model: "gpt-5",
|
||||
envBindingText: "",
|
||||
notes: "fixture",
|
||||
});
|
||||
const agentId = db.core.agents.saveAgent({
|
||||
companyId,
|
||||
name: "Researcher",
|
||||
role: "Research Lead",
|
||||
title: "Research Lead",
|
||||
department: "product",
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "openai-codex",
|
||||
workspaceId: null,
|
||||
model: null,
|
||||
capabilities: "Research",
|
||||
budgetMonthlyUsd: 100,
|
||||
});
|
||||
const taskId = db.core.tasks.saveTask({
|
||||
companyId,
|
||||
title: "Draft market plan",
|
||||
description: "Prepare the market plan after approval.",
|
||||
assigneeAgentId: agentId,
|
||||
priority: "high",
|
||||
status: "in_progress",
|
||||
requiresFilesystem: false,
|
||||
});
|
||||
const runContext: ActiveRunContext = {
|
||||
runId: "run-1",
|
||||
agentId,
|
||||
companyId,
|
||||
taskId,
|
||||
connectorId: "openai-codex",
|
||||
modelId: "gpt-5",
|
||||
taskSessionId: "session-1",
|
||||
workspaceId: null,
|
||||
workspacePath: null,
|
||||
workspaceMode: "none",
|
||||
workspaceBaseline: null,
|
||||
state: "agent_active",
|
||||
};
|
||||
return { companyId, agentId, taskId, runContext };
|
||||
}
|
||||
|
||||
describe("cofounder approval runtime gate", () => {
|
||||
let tempDir: string;
|
||||
let db: AppDatabase;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "cofounder-gate-"));
|
||||
db = new AppDatabase(tempDir);
|
||||
db.init();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
db.close();
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("blocks destructive tools while a cofounder plan review is pending", () => {
|
||||
const { companyId, agentId, taskId, runContext } = seedRun(db, "ai_cofounder");
|
||||
const approvalId = db.core.approvals.requestApproval({
|
||||
companyId,
|
||||
relatedTaskId: taskId,
|
||||
requestedByAgentId: agentId,
|
||||
type: "plan_review",
|
||||
payloadSummary: "Review plan",
|
||||
impactSummary: "Human approval is required before execution.",
|
||||
payloadJson: "{}",
|
||||
});
|
||||
|
||||
const blocking = db.core.approvals.getBlockingApproval(companyId, agentId, taskId);
|
||||
expect(blocking?.id).toBe(approvalId);
|
||||
|
||||
const result = handleBeforeToolCall(
|
||||
{
|
||||
db,
|
||||
logger: { info: () => {}, warn: () => {}, error: () => {} },
|
||||
},
|
||||
runContext,
|
||||
{
|
||||
toolName: "bash",
|
||||
toolCallId: "call-1",
|
||||
args: { command: "touch blocked.txt" },
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.block).toBe(true);
|
||||
expect(result.reason).toContain("plan_review");
|
||||
});
|
||||
|
||||
it("lets CEO-mode execution proceed past plan review policy", () => {
|
||||
const { companyId, agentId, taskId, runContext } = seedRun(db, "ai_ceo");
|
||||
db.core.approvals.requestApproval({
|
||||
companyId,
|
||||
relatedTaskId: taskId,
|
||||
requestedByAgentId: agentId,
|
||||
type: "plan_review",
|
||||
payloadSummary: "Review plan",
|
||||
impactSummary: "Human review requested for planning.",
|
||||
payloadJson: "{}",
|
||||
});
|
||||
|
||||
expect(db.core.approvals.getBlockingApproval(companyId, agentId, taskId)).toBeNull();
|
||||
const result = handleBeforeToolCall(
|
||||
{
|
||||
db,
|
||||
logger: { info: () => {}, warn: () => {}, error: () => {} },
|
||||
},
|
||||
runContext,
|
||||
{
|
||||
toolName: "bash",
|
||||
toolCallId: "call-1",
|
||||
args: { command: "touch allowed.txt" },
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.block).toBe(false);
|
||||
});
|
||||
|
||||
it("blocks operational approvals in CEO mode", () => {
|
||||
const { companyId, agentId, taskId, runContext } = seedRun(db, "ai_ceo");
|
||||
const approvalId = db.core.approvals.requestApproval({
|
||||
companyId,
|
||||
relatedTaskId: taskId,
|
||||
requestedByAgentId: agentId,
|
||||
type: "dangerous_command",
|
||||
payloadSummary: "Approve destructive command",
|
||||
impactSummary: "Host-level mutation requires explicit approval.",
|
||||
payloadJson: "{}",
|
||||
});
|
||||
|
||||
const blocking = db.core.approvals.getBlockingApproval(companyId, agentId, taskId);
|
||||
expect(blocking?.id).toBe(approvalId);
|
||||
const result = handleBeforeToolCall(
|
||||
{
|
||||
db,
|
||||
logger: { info: () => {}, warn: () => {}, error: () => {} },
|
||||
},
|
||||
runContext,
|
||||
{
|
||||
toolName: "bash",
|
||||
toolCallId: "call-1",
|
||||
args: { command: "rm -rf dist" },
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.block).toBe(true);
|
||||
expect(result.reason).toContain("dangerous_command");
|
||||
});
|
||||
});
|
||||
@@ -1,179 +0,0 @@
|
||||
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 type { IpcMainInvokeEvent } from "electron";
|
||||
import type { DesktopResult } from "@shared/contracts";
|
||||
import { IPC_CHANNELS } from "@shared/contracts";
|
||||
import { AppDatabase } from "../../src/main/database";
|
||||
import { registerCoreAdminHandlers } from "../../src/main/ipc-core-admin-handlers";
|
||||
import { createThreadIntegration } from "../../src/main/thread-integration";
|
||||
|
||||
type IpcHandler = (
|
||||
event: IpcMainInvokeEvent,
|
||||
...args: unknown[]
|
||||
) => Promise<DesktopResult<unknown>> | DesktopResult<unknown>;
|
||||
|
||||
type RawDb = {
|
||||
db: {
|
||||
prepare: (sql: string) => {
|
||||
run: (...args: unknown[]) => void;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
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("company mode switching", () => {
|
||||
it("clears cofounder review artifacts when switching back to AI CEO", async () => {
|
||||
const profileDir = await createTempDir("agentcompany-mode-switch-");
|
||||
const db = new AppDatabase(profileDir);
|
||||
db.init();
|
||||
|
||||
db.core.connectors.updateConnectorHealth({
|
||||
id: "openai-codex",
|
||||
status: "ready",
|
||||
authState: "authenticated",
|
||||
version: "test",
|
||||
lastError: null,
|
||||
});
|
||||
|
||||
const companyId = db.core.company.saveCompany({
|
||||
name: "Mode Switch Co",
|
||||
description: "Execution mode cleanup",
|
||||
status: "active",
|
||||
executionMode: "ai_cofounder",
|
||||
autoApproveHires: false,
|
||||
reviewDeliverables: true,
|
||||
});
|
||||
const workspaceId = db.core.workspaces.saveWorkspace({
|
||||
companyId,
|
||||
name: "Primary",
|
||||
localPath: profileDir,
|
||||
repoUrl: "",
|
||||
repoRef: "",
|
||||
isPrimary: true,
|
||||
});
|
||||
const ceoId = db.core.agents.saveAgent({
|
||||
companyId,
|
||||
name: "CEO",
|
||||
role: "CEO",
|
||||
title: "Chief Executive Officer",
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Lead the company",
|
||||
budgetMonthlyUsd: 250,
|
||||
});
|
||||
const engineerId = db.core.agents.saveAgent({
|
||||
companyId,
|
||||
name: "Engineer",
|
||||
role: "Engineer",
|
||||
title: "Lead Engineer",
|
||||
status: "active",
|
||||
reportsTo: ceoId,
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Ship features",
|
||||
budgetMonthlyUsd: 180,
|
||||
department: "engineering",
|
||||
});
|
||||
const taskId = db.core.tasks.saveTask({
|
||||
companyId,
|
||||
title: "Ship feature",
|
||||
description: "Waits for cofounder review.",
|
||||
assigneeAgentId: engineerId,
|
||||
workspaceId,
|
||||
priority: "high",
|
||||
status: "in_progress",
|
||||
});
|
||||
|
||||
const threadIntegration = createThreadIntegration({
|
||||
db,
|
||||
logger: { info: () => {}, warn: () => {}, error: () => {} },
|
||||
});
|
||||
|
||||
threadIntegration.onTaskCreated(taskId, companyId);
|
||||
threadIntegration.onRunFinished("run-1", taskId, engineerId, companyId, "succeeded", null);
|
||||
db.core.tasks.setTaskRequiresUserReview(taskId, true);
|
||||
const approvalId = db.core.approvals.requestApproval({
|
||||
companyId,
|
||||
relatedTaskId: taskId,
|
||||
requestedByAgentId: engineerId,
|
||||
type: "deliverable_review",
|
||||
payloadSummary: "Review deliverable",
|
||||
impactSummary: "Needs review",
|
||||
payloadJson: "{}",
|
||||
});
|
||||
threadIntegration.onApprovalCreated(approvalId, companyId, taskId, "deliverable_review", "Review deliverable");
|
||||
(db as unknown as RawDb).db
|
||||
.prepare("update approval_policies set blocks_execution = 1, required_mode = 'ai_cofounder', updated_at = ? where approval_type = 'incident_escalation'")
|
||||
.run(new Date().toISOString());
|
||||
const policyDrivenApprovalId = db.core.approvals.requestApproval({
|
||||
companyId,
|
||||
relatedTaskId: taskId,
|
||||
requestedByAgentId: engineerId,
|
||||
type: "incident_escalation",
|
||||
payloadSummary: "Review incident policy",
|
||||
impactSummary: "Data-driven cofounder review policy.",
|
||||
payloadJson: "{}",
|
||||
});
|
||||
|
||||
expect(db.core.threads.listThreadWaitStates(companyId).length).toBeGreaterThan(0);
|
||||
expect(db.core.entity.getTask(taskId).requiresUserReview).toBe(true);
|
||||
expect(db.core.approvals.getApprovalRecord(approvalId).state).toBe("pending");
|
||||
expect(db.core.approvals.getApprovalRecord(policyDrivenApprovalId).state).toBe("pending");
|
||||
|
||||
const handlers = new Map<string, IpcHandler>();
|
||||
registerCoreAdminHandlers({
|
||||
registerHandle: (channel, handler) => {
|
||||
handlers.set(channel, handler);
|
||||
},
|
||||
db,
|
||||
ensureCompanyExists: (id) => (db.core.settings.hasCompany(id) ? null : { ok: false, error: { code: "COMPANY_NOT_FOUND", message: "missing" } }),
|
||||
publishDomainChanged: vi.fn(),
|
||||
handleApprovalResolved: (resolvedApprovalId, resolvedCompanyId, decision) => {
|
||||
threadIntegration.onApprovalResolved(resolvedApprovalId, resolvedCompanyId, decision);
|
||||
},
|
||||
checkConnector: vi.fn(),
|
||||
openGenericTerminal: vi.fn(),
|
||||
});
|
||||
|
||||
const saveCompany = handlers.get(IPC_CHANNELS.saveCompany);
|
||||
if (!saveCompany) {
|
||||
throw new Error("saveCompany handler was not registered.");
|
||||
}
|
||||
|
||||
const result = await saveCompany(null as unknown as IpcMainInvokeEvent, {
|
||||
id: companyId,
|
||||
name: "Mode Switch Co",
|
||||
description: "Execution mode cleanup",
|
||||
status: "active",
|
||||
autoApproveHires: true,
|
||||
reviewDeliverables: false,
|
||||
executionMode: "ai_ceo",
|
||||
}) as { ok: boolean };
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(db.core.company.getCompanyExecutionMode(companyId)).toBe("ai_ceo");
|
||||
expect(db.core.threads.listThreadWaitStates(companyId)).toHaveLength(0);
|
||||
expect(db.core.entity.getTask(taskId).requiresUserReview).toBe(false);
|
||||
expect(db.core.approvals.getApprovalRecord(approvalId).state).toBe("approved");
|
||||
expect(db.core.approvals.getApprovalRecord(policyDrivenApprovalId).state).toBe("approved");
|
||||
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
@@ -1,36 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { IPC_CHANNELS } from "../../src/shared/contracts";
|
||||
import { registerCompanyOperationsHandlers } from "../../src/main/ipc-company-operations";
|
||||
import type { CompanyOperationHandlerDependencies } from "../../src/main/ipc-company-operations/types";
|
||||
|
||||
describe("company operation IPC handlers", () => {
|
||||
it("registers only company-operation channels through the company-operation registry", () => {
|
||||
const registeredChannels = new Set<string>();
|
||||
|
||||
registerCompanyOperationsHandlers({
|
||||
registerHandle: (channel) => {
|
||||
registeredChannels.add(channel);
|
||||
},
|
||||
db: {} as CompanyOperationHandlerDependencies["db"],
|
||||
ensureCompanyExists: () => null,
|
||||
findInvalidReference: () => null,
|
||||
publishDomainChanged: vi.fn(),
|
||||
dispatchAutomation: vi.fn(),
|
||||
threadIntegration: {} as CompanyOperationHandlerDependencies["threadIntegration"],
|
||||
checkpointEngine: {} as CompanyOperationHandlerDependencies["checkpointEngine"],
|
||||
lifecycleProcessor: {} as CompanyOperationHandlerDependencies["lifecycleProcessor"],
|
||||
logger: { warn: vi.fn(), error: vi.fn() },
|
||||
});
|
||||
|
||||
expect([...registeredChannels].sort()).toEqual([
|
||||
IPC_CHANNELS.deleteDocument,
|
||||
IPC_CHANNELS.deleteKnowledgeEntry,
|
||||
IPC_CHANNELS.deleteMeeting,
|
||||
IPC_CHANNELS.deleteSprint,
|
||||
IPC_CHANNELS.saveDocument,
|
||||
IPC_CHANNELS.saveKnowledgeEntry,
|
||||
IPC_CHANNELS.saveMeeting,
|
||||
IPC_CHANNELS.saveSprint,
|
||||
].sort());
|
||||
});
|
||||
});
|
||||
@@ -1,256 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { assembleProfileSnapshot } from "@renderer/state/snapshot-sections";
|
||||
import { useAppStore } from "@renderer/state/store";
|
||||
import type { AgentRecord, ApprovalRecord, ProfileSnapshot, RunRecord, TaskRecord } from "@shared/types";
|
||||
|
||||
function makeSnapshot(overrides: Partial<ProfileSnapshot> = {}): ProfileSnapshot {
|
||||
return {
|
||||
currentCompanyId: "company-1",
|
||||
profilePath: "/test/path",
|
||||
updatesEnabled: true,
|
||||
theme: "system",
|
||||
locale: "en",
|
||||
backend: { secretBackend: "safe_storage" },
|
||||
companies: [],
|
||||
connectors: [],
|
||||
workspaces: [],
|
||||
agents: [],
|
||||
goals: [],
|
||||
projects: [],
|
||||
tasks: [],
|
||||
approvals: [],
|
||||
runs: [],
|
||||
costs: [],
|
||||
activity: [],
|
||||
secrets: [],
|
||||
comments: [],
|
||||
socialAccounts: [],
|
||||
meetings: [],
|
||||
documents: [],
|
||||
workProducts: [],
|
||||
knowledgeBase: [],
|
||||
sprints: [],
|
||||
automationRules: [],
|
||||
agentMessages: [],
|
||||
workflows: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function expectSnapshotSectionsMatchSnapshot() {
|
||||
const { snapshot, snapshotSections } = useAppStore.getState();
|
||||
expect(snapshot).toEqual(snapshotSections ? assembleProfileSnapshot(snapshotSections) : null);
|
||||
}
|
||||
|
||||
const fakeTask = (id: string, title = "Task"): TaskRecord => ({
|
||||
id,
|
||||
companyId: "company-1",
|
||||
projectId: null,
|
||||
goalId: null,
|
||||
parentId: null,
|
||||
title,
|
||||
description: "",
|
||||
assigneeAgentId: null,
|
||||
workspaceId: null,
|
||||
status: "open",
|
||||
priority: "medium",
|
||||
activeRunId: null,
|
||||
sessionJson: null,
|
||||
requiresUserReview: false,
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
cancelledAt: null,
|
||||
createdAt: "2026-01-01",
|
||||
updatedAt: "2026-01-01",
|
||||
} as unknown as TaskRecord);
|
||||
|
||||
const fakeRun = (id: string): RunRecord => ({
|
||||
id,
|
||||
companyId: "company-1",
|
||||
taskId: "t-1",
|
||||
agentId: "ag-1",
|
||||
workspaceId: null,
|
||||
connectorId: "claude-pro",
|
||||
status: "queued",
|
||||
summary: "",
|
||||
errorMessage: null,
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
model: null,
|
||||
unattributedCost: false,
|
||||
startedAt: null,
|
||||
finishedAt: null,
|
||||
logPath: null,
|
||||
createdAt: "2026-01-01",
|
||||
updatedAt: "2026-01-01",
|
||||
} as RunRecord);
|
||||
|
||||
const fakeAgent = (id: string, name = "Agent"): AgentRecord => ({
|
||||
id,
|
||||
companyId: "company-1",
|
||||
name,
|
||||
role: "engineer",
|
||||
title: "",
|
||||
department: "engineering",
|
||||
status: "idle",
|
||||
reportsTo: null,
|
||||
connectorId: "claude-pro",
|
||||
workspaceId: null,
|
||||
model: null,
|
||||
capabilities: "",
|
||||
budgetMonthlyUsd: 100,
|
||||
spentMonthlyUsd: 0,
|
||||
metadataJson: "{}",
|
||||
heartbeatEnabled: false,
|
||||
heartbeatIntervalSec: 0,
|
||||
lastHeartbeatAt: null,
|
||||
createdAt: "2026-01-01",
|
||||
updatedAt: "2026-01-01",
|
||||
} as AgentRecord);
|
||||
|
||||
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 unknown as ApprovalRecord);
|
||||
|
||||
describe("CompanySlice patch methods", () => {
|
||||
beforeEach(() => {
|
||||
useAppStore.setState(useAppStore.getInitialState());
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("patchTask inserts new task at beginning when task ID is missing", () => {
|
||||
useAppStore.getState().setSnapshot(makeSnapshot({ tasks: [fakeTask("t-existing")] }));
|
||||
|
||||
useAppStore.getState().patchTask(fakeTask("t-new", "New Task"));
|
||||
|
||||
const tasks = useAppStore.getState().snapshot!.tasks;
|
||||
expect(tasks.map((task) => task.id)).toEqual(["t-new", "t-existing"]);
|
||||
expect(useAppStore.getState().snapshotSections!.company.tasks.map((task) => task.id)).toEqual([
|
||||
"t-new",
|
||||
"t-existing",
|
||||
]);
|
||||
expectSnapshotSectionsMatchSnapshot();
|
||||
});
|
||||
|
||||
it("patchTask updates existing task in both section and compatibility snapshot", () => {
|
||||
useAppStore.getState().setSnapshot(makeSnapshot({ tasks: [fakeTask("t-1", "Original")] }));
|
||||
|
||||
useAppStore.getState().patchTask(fakeTask("t-1", "Updated"));
|
||||
|
||||
expect(useAppStore.getState().snapshot!.tasks[0]!.title).toBe("Updated");
|
||||
expect(useAppStore.getState().snapshotSections!.company.tasks[0]!.title).toBe("Updated");
|
||||
expectSnapshotSectionsMatchSnapshot();
|
||||
});
|
||||
|
||||
it("removeTask filters task by ID in both section and compatibility snapshot", () => {
|
||||
useAppStore.getState().setSnapshot(makeSnapshot({ tasks: [fakeTask("t-1"), fakeTask("t-2")] }));
|
||||
|
||||
useAppStore.getState().removeTask("t-1");
|
||||
|
||||
expect(useAppStore.getState().snapshot!.tasks.map((task) => task.id)).toEqual(["t-2"]);
|
||||
expect(useAppStore.getState().snapshotSections!.company.tasks.map((task) => task.id)).toEqual(["t-2"]);
|
||||
expectSnapshotSectionsMatchSnapshot();
|
||||
});
|
||||
|
||||
it("patchRun inserts and updates run records through snapshot sections", () => {
|
||||
useAppStore.getState().setSnapshot(makeSnapshot({ runs: [fakeRun("r-existing")] }));
|
||||
|
||||
useAppStore.getState().patchRun(fakeRun("r-new"));
|
||||
useAppStore.getState().patchRun({ ...fakeRun("r-new"), status: "running" });
|
||||
|
||||
expect(useAppStore.getState().snapshot!.runs.map((run) => `${run.id}:${run.status}`)).toEqual([
|
||||
"r-new:running",
|
||||
"r-existing:queued",
|
||||
]);
|
||||
expect(useAppStore.getState().snapshotSections!.company.runs[0]!.status).toBe("running");
|
||||
expectSnapshotSectionsMatchSnapshot();
|
||||
});
|
||||
|
||||
it("removeRun filters run by ID in both section and compatibility snapshot", () => {
|
||||
useAppStore.getState().setSnapshot(makeSnapshot({ runs: [fakeRun("r-1"), fakeRun("r-2")] }));
|
||||
|
||||
useAppStore.getState().removeRun("r-1");
|
||||
|
||||
expect(useAppStore.getState().snapshot!.runs.map((run) => run.id)).toEqual(["r-2"]);
|
||||
expect(useAppStore.getState().snapshotSections!.company.runs.map((run) => run.id)).toEqual(["r-2"]);
|
||||
expectSnapshotSectionsMatchSnapshot();
|
||||
});
|
||||
|
||||
it("patchAgent inserts and updates agent records through snapshot sections", () => {
|
||||
useAppStore.getState().setSnapshot(makeSnapshot({ agents: [fakeAgent("ag-existing")] }));
|
||||
|
||||
useAppStore.getState().patchAgent(fakeAgent("ag-new", "New Agent"));
|
||||
useAppStore.getState().patchAgent(fakeAgent("ag-new", "Renamed Agent"));
|
||||
|
||||
expect(useAppStore.getState().snapshot!.agents.map((agent) => `${agent.id}:${agent.name}`)).toEqual([
|
||||
"ag-new:Renamed Agent",
|
||||
"ag-existing:Agent",
|
||||
]);
|
||||
expect(useAppStore.getState().snapshotSections!.company.agents[0]!.name).toBe("Renamed Agent");
|
||||
expectSnapshotSectionsMatchSnapshot();
|
||||
});
|
||||
|
||||
it("removeAgent filters agent by ID in both section and compatibility snapshot", () => {
|
||||
useAppStore.getState().setSnapshot(makeSnapshot({ agents: [fakeAgent("ag-1"), fakeAgent("ag-2")] }));
|
||||
|
||||
useAppStore.getState().removeAgent("ag-1");
|
||||
|
||||
expect(useAppStore.getState().snapshot!.agents.map((agent) => agent.id)).toEqual(["ag-2"]);
|
||||
expect(useAppStore.getState().snapshotSections!.company.agents.map((agent) => agent.id)).toEqual(["ag-2"]);
|
||||
expectSnapshotSectionsMatchSnapshot();
|
||||
});
|
||||
|
||||
it("patchApproval inserts and updates approval records through snapshot sections", () => {
|
||||
useAppStore.getState().setSnapshot(makeSnapshot({ approvals: [fakeApproval("ap-existing")] }));
|
||||
|
||||
useAppStore.getState().patchApproval(fakeApproval("ap-new"));
|
||||
useAppStore.getState().patchApproval({ ...fakeApproval("ap-new"), state: "approved" });
|
||||
|
||||
expect(useAppStore.getState().snapshot!.approvals.map((approval) => `${approval.id}:${approval.state}`)).toEqual([
|
||||
"ap-new:approved",
|
||||
"ap-existing:pending",
|
||||
]);
|
||||
expect(useAppStore.getState().snapshotSections!.company.approvals[0]!.state).toBe("approved");
|
||||
expectSnapshotSectionsMatchSnapshot();
|
||||
});
|
||||
|
||||
it("removeApproval filters approval by ID in both section and compatibility snapshot", () => {
|
||||
useAppStore.getState().setSnapshot(makeSnapshot({ approvals: [fakeApproval("ap-1"), fakeApproval("ap-2")] }));
|
||||
|
||||
useAppStore.getState().removeApproval("ap-1");
|
||||
|
||||
expect(useAppStore.getState().snapshot!.approvals.map((approval) => approval.id)).toEqual(["ap-2"]);
|
||||
expect(useAppStore.getState().snapshotSections!.company.approvals.map((approval) => approval.id)).toEqual([
|
||||
"ap-2",
|
||||
]);
|
||||
expectSnapshotSectionsMatchSnapshot();
|
||||
});
|
||||
|
||||
it("patch methods leave null snapshot state unchanged", () => {
|
||||
expect(useAppStore.getState().snapshot).toBeNull();
|
||||
|
||||
useAppStore.getState().patchTask(fakeTask("t-1"));
|
||||
useAppStore.getState().removeTask("t-1");
|
||||
useAppStore.getState().patchRun(fakeRun("r-1"));
|
||||
useAppStore.getState().removeRun("r-1");
|
||||
useAppStore.getState().patchAgent(fakeAgent("ag-1"));
|
||||
useAppStore.getState().removeAgent("ag-1");
|
||||
useAppStore.getState().patchApproval(fakeApproval("ap-1"));
|
||||
useAppStore.getState().removeApproval("ap-1");
|
||||
|
||||
expect(useAppStore.getState().snapshot).toBeNull();
|
||||
expect(useAppStore.getState().snapshotSections).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,304 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { DatabaseConnection } from "../../src/main/database/connection";
|
||||
import { ApprovalService } from "../../src/main/services/approval-service";
|
||||
import { ChatService } from "../../src/main/services/chat-service";
|
||||
import { ComplianceService } from "../../src/main/services/compliance-service";
|
||||
import { OrganizationService } from "../../src/main/services/organization-service";
|
||||
import { SnapshotService } from "../../src/main/services/snapshot-service";
|
||||
import { SocialService } from "../../src/main/services/social-service";
|
||||
import { APPROVAL_STATE, SOCIAL_PLATFORM } from "../../src/shared/domain";
|
||||
|
||||
function createConnection(): DatabaseConnection {
|
||||
return new DatabaseConnection(join(tmpdir(), `agentcompany-new-compliance-${randomUUID()}.db`));
|
||||
}
|
||||
|
||||
describe("compliance service", () => {
|
||||
test("creates GDPR export payloads with audit coverage", () => {
|
||||
const connection = createConnection();
|
||||
const organization = new OrganizationService(connection.db);
|
||||
const chat = new ChatService(connection.db);
|
||||
const compliance = new ComplianceService(connection.db);
|
||||
const snapshot = new SnapshotService(connection.db);
|
||||
let orgId = "";
|
||||
let employeeId = "";
|
||||
let spaceId = "";
|
||||
|
||||
connection.transaction(() => {
|
||||
orgId = organization.createOrganization({
|
||||
organizationName: "Acme Operations",
|
||||
employeeName: "Dana Lee",
|
||||
mission: "Coordinate agents with auditable approvals.",
|
||||
goal: "Ship a verified control plane.",
|
||||
});
|
||||
employeeId = (connection.db.prepare("select id from employees where org_id = ?").get(orgId) as { id: string }).id;
|
||||
spaceId = chat.createSpace({ orgId, spaceType: "project", name: "release", topic: "Release coordination", visibility: "workspace" });
|
||||
chat.sendMessage({
|
||||
orgId,
|
||||
spaceId,
|
||||
threadId: null,
|
||||
senderType: "employee",
|
||||
senderId: employeeId,
|
||||
bodyMd: "Exportable message",
|
||||
attachmentFileIds: [],
|
||||
});
|
||||
compliance.createExport({
|
||||
orgId,
|
||||
subjectType: "employee",
|
||||
subjectId: employeeId,
|
||||
exportType: "gdpr_export",
|
||||
requestedByEmployeeId: employeeId,
|
||||
});
|
||||
});
|
||||
|
||||
const state = snapshot.load();
|
||||
const record = state.complianceExports[0];
|
||||
if (!record) throw new Error("Compliance export was not created.");
|
||||
const payload = JSON.parse(record.payloadJson) as { profile: { display_name: string }; chatMessages: unknown[] };
|
||||
expect(record).toMatchObject({ subjectType: "employee", subjectId: employeeId, exportType: "gdpr_export", state: "completed" });
|
||||
expect(payload.profile.display_name).toBe("Dana Lee");
|
||||
expect(payload.chatMessages).toHaveLength(1);
|
||||
expect(state.auditEvents.some((event) => event.entityType === "compliance_export" && event.action === "created")).toBe(true);
|
||||
connection.close();
|
||||
});
|
||||
|
||||
test("blocks GDPR delete under holds and redacts after release", () => {
|
||||
const connection = createConnection();
|
||||
const organization = new OrganizationService(connection.db);
|
||||
const approvals = new ApprovalService(connection.db);
|
||||
const chat = new ChatService(connection.db);
|
||||
const compliance = new ComplianceService(connection.db);
|
||||
const social = new SocialService(connection.db);
|
||||
const snapshot = new SnapshotService(connection.db);
|
||||
let orgId = "";
|
||||
let employeeId = "";
|
||||
let spaceId = "";
|
||||
let holdId = "";
|
||||
let approvalId = "";
|
||||
let socialAccountId = "";
|
||||
|
||||
connection.transaction(() => {
|
||||
orgId = organization.createOrganization({
|
||||
organizationName: "Acme Operations",
|
||||
employeeName: "Dana Lee",
|
||||
mission: "Coordinate agents with auditable approvals.",
|
||||
goal: "Ship a verified control plane.",
|
||||
});
|
||||
employeeId = (connection.db.prepare("select id from employees where org_id = ?").get(orgId) as { id: string }).id;
|
||||
spaceId = chat.createSpace({ orgId, spaceType: "project", name: "release", topic: "Release coordination", visibility: "workspace" });
|
||||
chat.sendMessage({ orgId, spaceId, threadId: null, senderType: "employee", senderId: employeeId, bodyMd: "Delete me", attachmentFileIds: [] });
|
||||
approvalId = approvals.create({
|
||||
orgId,
|
||||
kind: "security_review",
|
||||
title: "Security review",
|
||||
details: "Employee requested and decided.",
|
||||
risk: "low",
|
||||
requestedByType: "employee",
|
||||
requestedById: employeeId,
|
||||
});
|
||||
approvals.decide({ orgId, approvalId, state: APPROVAL_STATE.APPROVED, decidedByEmployeeId: employeeId });
|
||||
socialAccountId = social.createAccount({
|
||||
orgId,
|
||||
platform: SOCIAL_PLATFORM.X,
|
||||
handle: "@acme",
|
||||
displayName: "Acme",
|
||||
createdByEmployeeId: employeeId,
|
||||
authorizedEmployeeId: employeeId,
|
||||
});
|
||||
const providerId = (connection.db.prepare("select id from providers where org_id = ? and provider_key = 'github-copilot'")
|
||||
.get(orgId) as { id: string }).id;
|
||||
connection.db.prepare(`
|
||||
insert into provider_auth_sessions(
|
||||
id, org_id, provider_id, provider_key, employee_id, state, nonce, code_verifier_sha256,
|
||||
redirect_uri, sign_in_url, partition, created_at, expires_at, completed_at
|
||||
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, null)
|
||||
`).run(
|
||||
"auths-test",
|
||||
orgId,
|
||||
providerId,
|
||||
"github-copilot",
|
||||
employeeId,
|
||||
"state",
|
||||
"nonce",
|
||||
"a".repeat(64),
|
||||
`agentcompany://provider-auth/${providerId}`,
|
||||
"https://github.com/login",
|
||||
`persist:agentcompany-provider-${providerId}`,
|
||||
new Date().toISOString(),
|
||||
new Date(Date.now() + 60000).toISOString(),
|
||||
);
|
||||
holdId = compliance.createLegalHold({
|
||||
orgId,
|
||||
entityType: "employee",
|
||||
entityId: employeeId,
|
||||
reason: "Discovery hold",
|
||||
createdByEmployeeId: employeeId,
|
||||
});
|
||||
});
|
||||
|
||||
expect(() => connection.transaction(() => compliance.createExport({
|
||||
orgId,
|
||||
subjectType: "employee",
|
||||
subjectId: employeeId,
|
||||
exportType: "gdpr_delete",
|
||||
requestedByEmployeeId: employeeId,
|
||||
}))).toThrow("Active compliance hold prevents GDPR delete.");
|
||||
|
||||
connection.transaction(() => {
|
||||
compliance.setLegalHoldState({ orgId, legalHoldId: holdId, active: false, employeeId });
|
||||
compliance.createExport({
|
||||
orgId,
|
||||
subjectType: "employee",
|
||||
subjectId: employeeId,
|
||||
exportType: "gdpr_delete",
|
||||
requestedByEmployeeId: employeeId,
|
||||
});
|
||||
});
|
||||
|
||||
const state = snapshot.load();
|
||||
const redactedApproval = state.approvals.find((approval) => approval.id === approvalId);
|
||||
const redactedSocialAccount = state.socialAccounts.find((account) => account.id === socialAccountId);
|
||||
expect(state.employees[0]?.displayName).toBe("Deleted employee");
|
||||
expect(state.chatMessages[0]?.senderId).toBeNull();
|
||||
expect(redactedApproval?.requestedById).toBeNull();
|
||||
expect(redactedApproval?.decidedByEmployeeId).toBeNull();
|
||||
expect(redactedSocialAccount?.authorizedEmployeeId).toBeNull();
|
||||
expect(state.socialAccountGrants).toHaveLength(0);
|
||||
expect(state.providerAuthSessions).toHaveLength(0);
|
||||
expect(state.complianceExports[0]?.exportType).toBe("gdpr_delete");
|
||||
expect(state.complianceExports[0]?.requestedByEmployeeId).toBeNull();
|
||||
expect(state.auditEvents.some((event) => event.entityType === "compliance_export" && event.action === "gdpr_deleted")).toBe(true);
|
||||
connection.close();
|
||||
});
|
||||
|
||||
test("blocks GDPR delete under organization retention freeze", () => {
|
||||
const connection = createConnection();
|
||||
const organization = new OrganizationService(connection.db);
|
||||
const compliance = new ComplianceService(connection.db);
|
||||
let orgId = "";
|
||||
let employeeId = "";
|
||||
let agentId = "";
|
||||
|
||||
connection.transaction(() => {
|
||||
orgId = organization.createOrganization({
|
||||
organizationName: "Acme Operations",
|
||||
employeeName: "Dana Lee",
|
||||
mission: "Coordinate agents with auditable approvals.",
|
||||
goal: "Ship a verified control plane.",
|
||||
});
|
||||
employeeId = (connection.db.prepare("select id from employees where org_id = ?").get(orgId) as { id: string }).id;
|
||||
agentId = organization.createAgent({
|
||||
orgId,
|
||||
name: "Ada",
|
||||
role: "Release reviewer",
|
||||
department: "Engineering",
|
||||
modelProviderId: null,
|
||||
modelId: null,
|
||||
budgetCents: 10000,
|
||||
managerAgentId: null,
|
||||
});
|
||||
compliance.createRetentionFreeze({
|
||||
orgId,
|
||||
scopeType: "organization",
|
||||
scopeId: orgId,
|
||||
reason: "Retention freeze",
|
||||
createdByEmployeeId: employeeId,
|
||||
});
|
||||
});
|
||||
|
||||
expect(() => connection.transaction(() => compliance.createExport({
|
||||
orgId,
|
||||
subjectType: "agent",
|
||||
subjectId: agentId,
|
||||
exportType: "gdpr_delete",
|
||||
requestedByEmployeeId: employeeId,
|
||||
}))).toThrow("Active compliance hold prevents GDPR delete.");
|
||||
connection.close();
|
||||
});
|
||||
|
||||
test("blocks GDPR delete under related chat and file holds", () => {
|
||||
const connection = createConnection();
|
||||
const organization = new OrganizationService(connection.db);
|
||||
const chat = new ChatService(connection.db);
|
||||
const compliance = new ComplianceService(connection.db);
|
||||
let orgId = "";
|
||||
let employeeId = "";
|
||||
let spaceId = "";
|
||||
let threadId = "";
|
||||
let fileId = "";
|
||||
let holdId = "";
|
||||
let freezeId = "";
|
||||
|
||||
connection.transaction(() => {
|
||||
orgId = organization.createOrganization({
|
||||
organizationName: "Acme Operations",
|
||||
employeeName: "Dana Lee",
|
||||
mission: "Coordinate agents with auditable approvals.",
|
||||
goal: "Ship a verified control plane.",
|
||||
});
|
||||
employeeId = (connection.db.prepare("select id from employees where org_id = ?").get(orgId) as { id: string }).id;
|
||||
spaceId = chat.createSpace({ orgId, spaceType: "project", name: "release", topic: "Release coordination", visibility: "workspace" });
|
||||
chat.sendMessage({ orgId, spaceId, threadId: null, senderType: "employee", senderId: employeeId, bodyMd: "Held chat", attachmentFileIds: [] });
|
||||
threadId = (connection.db.prepare("select thread_id from chat_messages where org_id = ?").get(orgId) as { thread_id: string }).thread_id;
|
||||
fileId = "file-held";
|
||||
connection.db.prepare(`
|
||||
insert into file_objects(id, org_id, name, kind, size_bytes, sha256, storage_path, owner_type, owner_id, created_at)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(fileId, orgId, "held.txt", "text/plain", 4, "a".repeat(64), "artifacts/held.txt", "employee", employeeId, new Date().toISOString());
|
||||
holdId = compliance.createLegalHold({
|
||||
orgId,
|
||||
entityType: "chat_thread",
|
||||
entityId: threadId,
|
||||
reason: "Thread discovery",
|
||||
createdByEmployeeId: employeeId,
|
||||
});
|
||||
});
|
||||
|
||||
expect(() => connection.transaction(() => compliance.createExport({
|
||||
orgId,
|
||||
subjectType: "employee",
|
||||
subjectId: employeeId,
|
||||
exportType: "gdpr_delete",
|
||||
requestedByEmployeeId: employeeId,
|
||||
}))).toThrow("Active compliance hold prevents GDPR delete.");
|
||||
|
||||
connection.transaction(() => {
|
||||
compliance.setLegalHoldState({ orgId, legalHoldId: holdId, active: false, employeeId });
|
||||
freezeId = compliance.createRetentionFreeze({
|
||||
orgId,
|
||||
scopeType: "chat_space",
|
||||
scopeId: spaceId,
|
||||
reason: "Space retention",
|
||||
createdByEmployeeId: employeeId,
|
||||
});
|
||||
});
|
||||
expect(() => connection.transaction(() => compliance.createExport({
|
||||
orgId,
|
||||
subjectType: "employee",
|
||||
subjectId: employeeId,
|
||||
exportType: "gdpr_delete",
|
||||
requestedByEmployeeId: employeeId,
|
||||
}))).toThrow("Active compliance hold prevents GDPR delete.");
|
||||
|
||||
connection.transaction(() => {
|
||||
compliance.setRetentionFreezeState({ orgId, retentionFreezeId: freezeId, active: false, employeeId });
|
||||
compliance.createLegalHold({
|
||||
orgId,
|
||||
entityType: "file_object",
|
||||
entityId: fileId,
|
||||
reason: "File discovery",
|
||||
createdByEmployeeId: employeeId,
|
||||
});
|
||||
});
|
||||
expect(() => connection.transaction(() => compliance.createExport({
|
||||
orgId,
|
||||
subjectType: "employee",
|
||||
subjectId: employeeId,
|
||||
exportType: "gdpr_delete",
|
||||
requestedByEmployeeId: employeeId,
|
||||
}))).toThrow("Active compliance hold prevents GDPR delete.");
|
||||
connection.close();
|
||||
});
|
||||
});
|
||||
@@ -1,82 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { CONNECTORS } from "../../src/main/connector-definitions";
|
||||
import { findConnectorDefinition, listFeaturedOnboardingConnectors } from "../../src/main/connector-definitions-lookup";
|
||||
|
||||
/** Verifies the static pi-mono connector catalog is shape-correct and that
|
||||
* required fields hold the values downstream code (worker, resolver,
|
||||
* Settings panel) depends on. */
|
||||
|
||||
describe("CONNECTORS catalog", () => {
|
||||
it("contains exactly 28 connectors (5 OAuth + 18 API key + 3 Cloud + 2 Local)", () => {
|
||||
expect(CONNECTORS.length).toBe(28);
|
||||
const oauth = CONNECTORS.filter((c) => c.auth?.type === "oauth");
|
||||
const apiKey = CONNECTORS.filter((c) => c.auth?.type === "api_key");
|
||||
const cloud = CONNECTORS.filter((c) => c.auth ? ["aws", "azure", "gcp_adc"].includes(c.auth.type) : false);
|
||||
const local = CONNECTORS.filter((c) => c.auth?.type === "local");
|
||||
expect(oauth.length).toBe(5);
|
||||
expect(apiKey.length).toBe(18);
|
||||
expect(cloud.length).toBe(3);
|
||||
expect(local.length).toBe(2);
|
||||
});
|
||||
|
||||
it("uses unique connector ids", () => {
|
||||
const ids = CONNECTORS.map((c) => c.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
|
||||
it("every entry has a non-empty label and description", () => {
|
||||
for (const connector of CONNECTORS) {
|
||||
expect(connector.label.length).toBeGreaterThan(0);
|
||||
expect(connector.description.length).toBeGreaterThan(0);
|
||||
expect((connector.defaultModel ?? "").length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("every entry references a documented pi `KnownProvider` id (or a local model-resolver provider)", () => {
|
||||
// pi-ai's KnownProvider matrix (catalog covered by getModel/getModels).
|
||||
const knownPiProviders = new Set([
|
||||
"amazon-bedrock", "anthropic", "google", "google-gemini-cli", "google-antigravity", "google-vertex",
|
||||
"openai", "azure-openai-responses", "openai-codex", "github-copilot",
|
||||
"xai", "groq", "cerebras", "openrouter", "vercel-ai-gateway", "zai", "mistral",
|
||||
"minimax", "minimax-cn", "huggingface", "opencode", "opencode-go", "kimi-coding",
|
||||
]);
|
||||
// Providers wired through src/main/model-resolver.ts instead of pi's catalog.
|
||||
const localProviders = new Set(["fireworks", "fireworks-firepass", "ollama", "lm-studio"]);
|
||||
for (const connector of CONNECTORS) {
|
||||
const provider = connector.provider ?? "";
|
||||
const recognised = knownPiProviders.has(provider) || localProviders.has(provider);
|
||||
expect(recognised).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("OAuth connectors carry one of the five pi OAuth provider ids", () => {
|
||||
const validOauthIds = new Set(["anthropic", "openai-codex", "github-copilot", "google-gemini-cli", "google-antigravity"]);
|
||||
const oauthConnectors = CONNECTORS.filter((c) => c.auth?.type === "oauth");
|
||||
for (const connector of oauthConnectors) {
|
||||
if (connector.auth?.type !== "oauth") throw new Error("unreachable");
|
||||
expect(validOauthIds.has(connector.auth.oauthId ?? "")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("API-key connectors carry an envVar name", () => {
|
||||
const apiKeyConnectors = CONNECTORS.filter((c) => c.auth?.type === "api_key");
|
||||
for (const connector of apiKeyConnectors) {
|
||||
if (connector.auth?.type !== "api_key") throw new Error("unreachable");
|
||||
expect((connector.auth.envKey ?? "").length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("findConnectorDefinition resolves known ids and returns undefined for unknown ones", () => {
|
||||
expect(findConnectorDefinition("anthropic")?.label).toBe("Anthropic API");
|
||||
expect(findConnectorDefinition("amazon-bedrock")?.auth?.type).toBe("aws");
|
||||
expect(findConnectorDefinition("google-vertex")?.auth?.type).toBe("gcp_adc");
|
||||
expect(findConnectorDefinition("definitely-not-a-connector")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("featured onboarding rail returns the curated 3-provider subset", () => {
|
||||
const featured = listFeaturedOnboardingConnectors();
|
||||
expect(featured.length).toBeGreaterThanOrEqual(2);
|
||||
expect(featured.length).toBeLessThanOrEqual(5);
|
||||
expect(featured.find((c) => (c.id as string) === "claude-pro")).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { DatabaseConnection } from "../../src/main/database/connection";
|
||||
import { OrganizationService } from "../../src/main/services/organization-service";
|
||||
import { ChatService } from "../../src/main/services/chat-service";
|
||||
import { ConnectorGrantsService } from "../../src/main/services/composio/connector-grants";
|
||||
import { dispatchAgentTool, type AgentToolContext } from "../../src/main/services/agent-tools";
|
||||
|
||||
function call(name: string, args: Record<string, unknown>) {
|
||||
return { id: `call_${randomUUID()}`, name, arguments: args };
|
||||
}
|
||||
|
||||
interface Harness {
|
||||
connection: DatabaseConnection;
|
||||
orgId: string;
|
||||
ceoId: string;
|
||||
danaId: string;
|
||||
ceoCtx: AgentToolContext;
|
||||
danaCtx: AgentToolContext;
|
||||
deps: { db: DatabaseConnection["db"]; organization: OrganizationService; chat: ChatService };
|
||||
}
|
||||
|
||||
function harness(): Harness {
|
||||
const connection = new DatabaseConnection(join(tmpdir(), `ac-connector-${randomUUID()}.db`));
|
||||
const organization = new OrganizationService(connection.db);
|
||||
let orgId = "";
|
||||
let ceoId = "";
|
||||
let danaId = "";
|
||||
connection.transaction(() => {
|
||||
orgId = organization.createOrganization({ organizationName: "OrbitOps", employeeName: "Yang", mission: "m", goal: "g" });
|
||||
const providerId = (connection.db.prepare("select id from providers where org_id = ? and provider_key = 'openai'").get(orgId) as { id: string }).id;
|
||||
ceoId = organization.createAgent({
|
||||
orgId, name: "CEO", role: "CEO", department: "Executive",
|
||||
modelProviderId: providerId, modelId: "gpt-5.4", budgetCents: 25000, managerAgentId: null, roleLevel: "c_suite",
|
||||
});
|
||||
danaId = organization.createAgent({
|
||||
orgId, name: "Dana", role: "Engineer", department: "Engineering",
|
||||
modelProviderId: providerId, modelId: "gpt-5.4", budgetCents: 1000, managerAgentId: ceoId,
|
||||
});
|
||||
});
|
||||
const context = (agentId: string): AgentToolContext => ({
|
||||
orgId,
|
||||
agent: { id: agentId, modelProviderId: null, modelId: null },
|
||||
taskProjectId: null,
|
||||
taskGoalId: null,
|
||||
workspace: null,
|
||||
});
|
||||
return {
|
||||
connection,
|
||||
orgId,
|
||||
ceoId,
|
||||
danaId,
|
||||
ceoCtx: context(ceoId),
|
||||
danaCtx: context(danaId),
|
||||
deps: { db: connection.db, organization, chat: new ChatService(connection.db) },
|
||||
};
|
||||
}
|
||||
|
||||
function connectToolkit(h: Harness, slug: string, name: string): void {
|
||||
const now = new Date().toISOString();
|
||||
h.connection.db.prepare(`
|
||||
insert into connector_accounts(id, org_id, toolkit_slug, toolkit_name, auth_config_id, status, created_at, updated_at)
|
||||
values (?, ?, ?, ?, ?, 'ACTIVE', ?, ?)
|
||||
`).run(`ca_${slug}`, h.orgId, slug, name, `ac_${slug}`, now, now);
|
||||
}
|
||||
|
||||
describe("connector grants", () => {
|
||||
test("granting requires an actively connected toolkit", () => {
|
||||
const h = harness();
|
||||
const grants = new ConnectorGrantsService(h.connection.db);
|
||||
expect(() => grants.grant({
|
||||
orgId: h.orgId, agentId: h.danaId, toolkitSlug: "github", grantedByType: "employee", grantedById: null,
|
||||
})).toThrow(/No active connection/);
|
||||
});
|
||||
|
||||
test("the CEO grants and revokes toolkits for teammates", () => {
|
||||
const h = harness();
|
||||
connectToolkit(h, "github", "GitHub");
|
||||
const granted = dispatchAgentTool(h.deps, h.ceoCtx, call("grant_connector", { agent_name: "Dana", toolkit: "github" }));
|
||||
expect(granted).toContain("Dana now has access to the github connector");
|
||||
|
||||
const grants = new ConnectorGrantsService(h.connection.db);
|
||||
expect(grants.toolkitsForAgent(h.orgId, h.danaId)).toEqual(["github"]);
|
||||
expect(grants.toolkitsForAgent(h.orgId, h.ceoId)).toEqual([]);
|
||||
|
||||
const listing = dispatchAgentTool(h.deps, h.danaCtx, call("list_connectors", {}));
|
||||
expect(listing).toContain("GitHub (github): granted to Dana");
|
||||
|
||||
const revoked = dispatchAgentTool(h.deps, h.ceoCtx, call("revoke_connector", { agent_name: "Dana", toolkit: "github" }));
|
||||
expect(revoked).toContain("no longer has access");
|
||||
expect(grants.toolkitsForAgent(h.orgId, h.danaId)).toEqual([]);
|
||||
});
|
||||
|
||||
test("staff cannot hand out connector access", () => {
|
||||
const h = harness();
|
||||
connectToolkit(h, "github", "GitHub");
|
||||
const denied = dispatchAgentTool(h.deps, h.danaCtx, call("grant_connector", { agent_name: "Dana", toolkit: "github" }));
|
||||
expect(denied).toContain("ERROR");
|
||||
expect(new ConnectorGrantsService(h.connection.db).toolkitsForAgent(h.orgId, h.danaId)).toEqual([]);
|
||||
});
|
||||
|
||||
test("a disconnected toolkit stops counting for its grants", () => {
|
||||
const h = harness();
|
||||
connectToolkit(h, "slack", "Slack");
|
||||
const grants = new ConnectorGrantsService(h.connection.db);
|
||||
grants.grant({ orgId: h.orgId, agentId: h.danaId, toolkitSlug: "slack", grantedByType: "employee", grantedById: null });
|
||||
expect(grants.toolkitsForAgent(h.orgId, h.danaId)).toEqual(["slack"]);
|
||||
h.connection.db.prepare("update connector_accounts set status = 'EXPIRED' where org_id = ? and toolkit_slug = 'slack'").run(h.orgId);
|
||||
expect(grants.toolkitsForAgent(h.orgId, h.danaId)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,92 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
inferTaskType,
|
||||
getConnectorStrengths,
|
||||
} from "../../src/main/connector-matching";
|
||||
|
||||
describe("connector-matching", () => {
|
||||
describe("inferTaskType", () => {
|
||||
it("returns code for engineering department", () => {
|
||||
expect(inferTaskType("engineering")).toBe("code");
|
||||
});
|
||||
|
||||
it("returns research for research department", () => {
|
||||
expect(inferTaskType("research")).toBe("research");
|
||||
});
|
||||
|
||||
it("returns content for marketing department", () => {
|
||||
expect(inferTaskType("marketing")).toBe("content");
|
||||
});
|
||||
|
||||
it("returns content for product department", () => {
|
||||
expect(inferTaskType("product")).toBe("content");
|
||||
});
|
||||
|
||||
it("returns management for hr department", () => {
|
||||
expect(inferTaskType("hr")).toBe("management");
|
||||
});
|
||||
|
||||
it("returns management for finance department", () => {
|
||||
expect(inferTaskType("finance")).toBe("management");
|
||||
});
|
||||
|
||||
it("returns management for executive department", () => {
|
||||
expect(inferTaskType("executive")).toBe("management");
|
||||
});
|
||||
|
||||
it("returns content for design department", () => {
|
||||
expect(inferTaskType("design")).toBe("content");
|
||||
});
|
||||
|
||||
it("returns content for customer_support department", () => {
|
||||
expect(inferTaskType("customer_support")).toBe("content");
|
||||
});
|
||||
|
||||
it("returns management for operations department", () => {
|
||||
expect(inferTaskType("operations")).toBe("management");
|
||||
});
|
||||
|
||||
it("returns content for sales department", () => {
|
||||
expect(inferTaskType("sales")).toBe("content");
|
||||
});
|
||||
|
||||
it("returns management for legal department", () => {
|
||||
expect(inferTaskType("legal")).toBe("management");
|
||||
});
|
||||
|
||||
it("returns general for null department", () => {
|
||||
expect(inferTaskType(null)).toBe("general");
|
||||
});
|
||||
|
||||
it("returns general for undefined department", () => {
|
||||
expect(inferTaskType(undefined)).toBe("general");
|
||||
});
|
||||
|
||||
it("returns general for unknown department", () => {
|
||||
expect(inferTaskType("unknown_dept")).toBe("general");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getConnectorStrengths", () => {
|
||||
it("returns non-empty string for claude-pro", () => {
|
||||
const result = getConnectorStrengths("claude-pro");
|
||||
expect(result).toBeTruthy();
|
||||
expect(typeof result).toBe("string");
|
||||
expect(result.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
it("returns non-empty string for openai-codex", () => {
|
||||
const result = getConnectorStrengths("openai-codex");
|
||||
expect(result).toBeTruthy();
|
||||
expect(typeof result).toBe("string");
|
||||
expect(result.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
it("returns non-empty string for gemini-cli", () => {
|
||||
const result = getConnectorStrengths("gemini-cli");
|
||||
expect(result).toBeTruthy();
|
||||
expect(typeof result).toBe("string");
|
||||
expect(result.length).toBeGreaterThan(10);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,92 +0,0 @@
|
||||
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: "anthropic" as const,
|
||||
label: "Anthropic",
|
||||
model: overrides.connectorModel ?? null,
|
||||
}),
|
||||
getDisabledModels: (_c: string, _k: string) => overrides.disabledModels ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
describe("connector model policy", () => {
|
||||
it("excludes disabled models from the enabled set", () => {
|
||||
const policy = getConnectorModelPolicy(createDeps(), "test-co", "anthropic");
|
||||
const allIds = policy.enabledModels.map((model) => model.id);
|
||||
if (allIds.length === 0) {
|
||||
throw new Error("anthropic provider should expose at least one model from pi-ai.");
|
||||
}
|
||||
|
||||
const firstModel = allIds[0];
|
||||
const filteredPolicy = getConnectorModelPolicy(
|
||||
createDeps({ disabledModels: [{ id: firstModel, label: firstModel }] }), "test-co", "anthropic",
|
||||
);
|
||||
|
||||
expect(filteredPolicy.enabledModels.some((model) => model.id === firstModel)).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts an explicit enabled model", () => {
|
||||
const policy = getConnectorModelPolicy(createDeps(), "test-co", "anthropic");
|
||||
const enabledId = policy.enabledModels[0]?.id;
|
||||
if (!enabledId) {
|
||||
throw new Error("anthropic provider should expose at least one model from pi-ai.");
|
||||
}
|
||||
const result = validateConnectorModelSelection(createDeps(), "test-co", "anthropic", enabledId);
|
||||
|
||||
expect(result).toEqual({ ok: true, effectiveModel: enabledId });
|
||||
});
|
||||
|
||||
it("rejects an explicit disabled model", () => {
|
||||
const policy = getConnectorModelPolicy(createDeps(), "test-co", "anthropic");
|
||||
const enabledId = policy.enabledModels[0]?.id;
|
||||
if (!enabledId) {
|
||||
throw new Error("anthropic provider should expose at least one model from pi-ai.");
|
||||
}
|
||||
const result = validateConnectorModelSelection(
|
||||
createDeps({ disabledModels: [{ id: enabledId, label: enabledId }] }), "test-co", "anthropic",
|
||||
enabledId,
|
||||
);
|
||||
|
||||
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 "${enabledId}"`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("native connector defaults", () => {
|
||||
it("falls back to definition defaultModel when connector model is empty", () => {
|
||||
const definition = getConnectorDefinition("openai-codex");
|
||||
const configuredDefault = definition.defaultModel;
|
||||
|
||||
const policy = getConnectorModelPolicy({
|
||||
getConnector: () => ({
|
||||
id: "openai-codex" as const,
|
||||
label: "Codex",
|
||||
model: null,
|
||||
}),
|
||||
getDisabledModels: (_c: string, _k: string) => [],
|
||||
}, "test-co", "openai-codex");
|
||||
|
||||
expect(policy.defaultModel).toBe(configuredDefault);
|
||||
expect(validateConnectorModelSelection({
|
||||
getConnector: () => ({
|
||||
id: "openai-codex" as const,
|
||||
label: "Codex",
|
||||
model: null,
|
||||
}),
|
||||
getDisabledModels: (_c: string, _k: string) => [],
|
||||
}, "test-co", "openai-codex", null)).toEqual({
|
||||
ok: true,
|
||||
effectiveModel: configuredDefault,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,38 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
mergeConnectorModelEntries,
|
||||
} 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("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 },
|
||||
]);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -1,68 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { getProviders } from "@mariozechner/pi-ai";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
CONNECTORS,
|
||||
getConnectorKnownProvider,
|
||||
getConnectorProvider,
|
||||
isLocalModelResolverProvider,
|
||||
isPiKnownProvider,
|
||||
} from "../../src/main/connectors";
|
||||
import { FIREWORKS_FIREPASS_MODEL_ID, fetchLocalConnectorModels, resolveModel, resolveModels } from "../../src/main/model-resolver";
|
||||
|
||||
describe("connector provider guard", () => {
|
||||
it("classifies catalog providers before they enter pi-ai APIs", () => {
|
||||
const piProviders = new Set(getProviders());
|
||||
|
||||
for (const connector of CONNECTORS) {
|
||||
const provider = getConnectorProvider(connector.id);
|
||||
expect(provider).toBe(connector.provider);
|
||||
|
||||
if (isLocalModelResolverProvider(provider)) {
|
||||
expect(getConnectorKnownProvider(connector.id)).toBeNull();
|
||||
} else {
|
||||
expect(isPiKnownProvider(provider)).toBe(true);
|
||||
expect(piProviders.has(provider)).toBe(true);
|
||||
expect(getConnectorKnownProvider(connector.id)).toBe(provider);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("uses the provider guard in model discovery", () => {
|
||||
const anthropicModels = resolveModels("anthropic");
|
||||
const firstAnthropicModel = anthropicModels[0];
|
||||
if (!firstAnthropicModel) throw new Error("Anthropic model catalog is empty.");
|
||||
|
||||
expect(resolveModel("anthropic", firstAnthropicModel.id)?.id).toBe(firstAnthropicModel.id);
|
||||
expect(resolveModels("not-a-pi-provider")).toEqual([]);
|
||||
expect(resolveModel("not-a-pi-provider", "model-id")).toBeNull();
|
||||
expect(resolveModel("fireworks-firepass", "ignored")?.id).toBe(FIREWORKS_FIREPASS_MODEL_ID);
|
||||
expect(resolveModel("ollama", "llama3.2", { baseUrl: "http://127.0.0.1:11434/v1" })?.baseUrl).toBe("http://127.0.0.1:11434/v1");
|
||||
expect(resolveModel("ollama", "llama3.2", { baseUrl: "https://example.com/v1" })).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects remote URLs before local model catalog fetch", async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("fetch should stay unused"));
|
||||
try {
|
||||
await expect(fetchLocalConnectorModels("https://example.com/v1")).resolves.toEqual([]);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps provider calls free of erased never casts", () => {
|
||||
const files = [
|
||||
"src/main/model-resolver.ts",
|
||||
"src/main/connector-health-service.ts",
|
||||
"src/main/credentials/credential-resolver.ts",
|
||||
];
|
||||
|
||||
for (const file of files) {
|
||||
const source = readFileSync(join(process.cwd(), file), "utf8");
|
||||
expect(source).not.toContain("provider as never");
|
||||
expect(source).not.toContain("definition.provider as never");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,39 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildDefaultConnectorRows, parseEnvBindingText } from "../../src/main/connectors";
|
||||
|
||||
describe("parseEnvBindingText", () => {
|
||||
it("ignores comments and blank lines while preserving values with equals", () => {
|
||||
expect(
|
||||
parseEnvBindingText(`
|
||||
# comment
|
||||
OPENAI_API_KEY=vault-openai
|
||||
|
||||
CUSTOM=value=with=equals
|
||||
`),
|
||||
).toEqual({
|
||||
OPENAI_API_KEY: "vault-openai",
|
||||
CUSTOM: "value=with=equals",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDefaultConnectorRows", () => {
|
||||
it("preserves previously saved connector overrides", () => {
|
||||
const rows = buildDefaultConnectorRows([
|
||||
{
|
||||
id: "openai-codex",
|
||||
status: "ready",
|
||||
notes: "custom note",
|
||||
},
|
||||
]);
|
||||
const codex = rows.find((row) => row.id === "openai-codex");
|
||||
if (!codex) {
|
||||
throw new Error("openai-codex connector not found in default rows.");
|
||||
}
|
||||
|
||||
expect(codex.id).toBe("openai-codex");
|
||||
expect(codex.status).toBe("ready");
|
||||
expect(codex.notes).toBe("custom note");
|
||||
expect(codex.capabilityMatrix.structuredTranscript).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,66 +0,0 @@
|
||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { buildAgentHooks } from "../../src/main/agent-runtime/agent-hooks";
|
||||
import type { WorkerRpcClient } from "../../src/main/worker/entry/rpc";
|
||||
|
||||
function createRpc() {
|
||||
return {
|
||||
invoke: vi.fn(async () => ({ ok: true, result: undefined })),
|
||||
} as unknown as WorkerRpcClient & { invoke: ReturnType<typeof vi.fn> };
|
||||
}
|
||||
|
||||
function largeTranscript(): AgentMessage[] {
|
||||
const messages: AgentMessage[] = [];
|
||||
for (let i = 0; i < 46; i += 1) {
|
||||
const role = i % 2 === 0 ? "user" : "assistant";
|
||||
messages.push({
|
||||
role,
|
||||
content: [{
|
||||
type: "text",
|
||||
text: `${role} turn ${i}\n${"context ".repeat(6_000)}`,
|
||||
}],
|
||||
timestamp: i,
|
||||
} as AgentMessage);
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
describe("worker-side context compaction", () => {
|
||||
it("compacts inside the worker without transformContext RPC", async () => {
|
||||
const rpc = createRpc();
|
||||
const onContextCompaction = vi.fn();
|
||||
const hooks = buildAgentHooks({
|
||||
runId: "8b3f07b2-841a-4e16-9d1c-9d2a27c3c2ad",
|
||||
rpc,
|
||||
onContextCompaction,
|
||||
});
|
||||
|
||||
const messages = largeTranscript();
|
||||
const transformed = await hooks.transformContext(messages);
|
||||
|
||||
expect(rpc.invoke).not.toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
"transformContext",
|
||||
expect.anything(),
|
||||
);
|
||||
expect(transformed.length).toBeLessThan(messages.length);
|
||||
expect(transformed[0]).toMatchObject({ role: "user" });
|
||||
expect(onContextCompaction).toHaveBeenCalledWith(expect.objectContaining({
|
||||
runId: "8b3f07b2-841a-4e16-9d1c-9d2a27c3c2ad",
|
||||
originalMessageCount: messages.length,
|
||||
compactedMessageCount: transformed.length,
|
||||
}));
|
||||
});
|
||||
|
||||
it("propagates compaction failures instead of returning the original transcript", async () => {
|
||||
const rpc = createRpc();
|
||||
const hooks = buildAgentHooks({
|
||||
runId: "b9f6aa93-0db7-4f39-8f28-2d87742ef414",
|
||||
rpc,
|
||||
});
|
||||
|
||||
await expect(hooks.transformContext(null as unknown as AgentMessage[])).rejects.toThrow(
|
||||
"transformContext compaction failed",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,119 +0,0 @@
|
||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { compactMessagesIfNeeded } from "../../src/main/agent-runtime/context-compactor";
|
||||
|
||||
function textOf(message: AgentMessage): string {
|
||||
const content = (message as { content?: unknown }).content;
|
||||
if (!Array.isArray(content)) return "";
|
||||
const first = content[0] as { text?: string } | undefined;
|
||||
return first?.text ?? "";
|
||||
}
|
||||
|
||||
function makeLongTranscript(olderFacts: AgentMessage[]): AgentMessage[] {
|
||||
const messages: AgentMessage[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: `Ship the approval workflow.\n${"context ".repeat(12_000)}` }],
|
||||
timestamp: 1,
|
||||
} as AgentMessage,
|
||||
...olderFacts,
|
||||
];
|
||||
|
||||
for (let i = 0; i < 42; i += 1) {
|
||||
messages.push({
|
||||
role: i % 2 === 0 ? "user" : "assistant",
|
||||
content: [{ type: "text", text: `recent turn ${i}\n${"tail ".repeat(4_000)}` }],
|
||||
timestamp: 100 + i,
|
||||
} as AgentMessage);
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
describe("context compactor tool result facts", () => {
|
||||
it("uses tokenizer counts so long CJK transcripts compact before provider overflow", () => {
|
||||
const messages: AgentMessage[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Start the Chinese market launch review." }],
|
||||
timestamp: 1,
|
||||
} as AgentMessage,
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "I will keep the plan concise." }],
|
||||
timestamp: 2,
|
||||
} as AgentMessage,
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "请完成市场复盘,保留所有客户反馈与行动项。 ".repeat(8_000) }],
|
||||
timestamp: 3,
|
||||
} as AgentMessage,
|
||||
];
|
||||
for (let i = 0; i < 42; i += 1) {
|
||||
messages.push({
|
||||
role: i % 2 === 0 ? "user" : "assistant",
|
||||
content: [{ type: "text", text: `recent ${i}` }],
|
||||
timestamp: 100 + i,
|
||||
} as AgentMessage);
|
||||
}
|
||||
|
||||
const outcome = compactMessagesIfNeeded(messages);
|
||||
|
||||
expect(outcome.changed).toBe(true);
|
||||
expect(outcome.telemetry?.estimatedTokens).toBeGreaterThanOrEqual(120_000);
|
||||
});
|
||||
|
||||
it("preserves task, approval, message, status, decision, and error facts from tool results", () => {
|
||||
const messages = makeLongTranscript([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "I will request approval for the deliverable." },
|
||||
{
|
||||
type: "toolCall",
|
||||
id: "call-approval",
|
||||
name: "file_approval",
|
||||
args: { taskId: "task_123" },
|
||||
},
|
||||
],
|
||||
timestamp: 2,
|
||||
} as AgentMessage,
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call-approval",
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
ok: false,
|
||||
taskId: "task_123",
|
||||
approvalId: "approval_456",
|
||||
messageId: "message_789",
|
||||
status: "blocked",
|
||||
decision: "requires_human_review",
|
||||
error: "missing reviewer",
|
||||
}),
|
||||
}],
|
||||
details: {
|
||||
taskId: "task_123",
|
||||
approvalId: "approval_456",
|
||||
messageId: "message_789",
|
||||
status: "blocked",
|
||||
decision: "requires_human_review",
|
||||
error: "missing reviewer",
|
||||
},
|
||||
timestamp: 3,
|
||||
} as AgentMessage,
|
||||
]);
|
||||
|
||||
const outcome = compactMessagesIfNeeded(messages);
|
||||
expect(outcome.changed).toBe(true);
|
||||
const summary = textOf(outcome.messages?.[0] as AgentMessage);
|
||||
|
||||
expect(summary).toContain("file_approval");
|
||||
expect(summary).toContain("taskId=task_123");
|
||||
expect(summary).toContain("approvalId=approval_456");
|
||||
expect(summary).toContain("messageId=message_789");
|
||||
expect(summary).toContain("status=blocked");
|
||||
expect(summary).toContain("decision=requires_human_review");
|
||||
expect(summary).toContain("error=missing reviewer");
|
||||
});
|
||||
});
|
||||
@@ -1,122 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
connectorInputSchema,
|
||||
documentInputSchema,
|
||||
knowledgeEntryInputSchema,
|
||||
meetingInputSchema,
|
||||
runLogSchema,
|
||||
secretInputSchema,
|
||||
startRunSchema,
|
||||
workspaceInputSchema,
|
||||
} from "../../src/shared/contracts";
|
||||
|
||||
describe("shared contracts", () => {
|
||||
it("fills connector defaults for optional fields", () => {
|
||||
const parsed = connectorInputSchema.parse({
|
||||
id: "openai-codex",
|
||||
});
|
||||
|
||||
expect(parsed.model).toBeUndefined();
|
||||
expect(parsed.envBindingText).toBe("");
|
||||
expect(parsed.notes).toBe("");
|
||||
});
|
||||
|
||||
it("validates workspace and run payloads", () => {
|
||||
expect(() =>
|
||||
workspaceInputSchema.parse({
|
||||
companyId: "bad-id",
|
||||
name: "Primary Workspace",
|
||||
localPath: "/tmp/workspace",
|
||||
}),
|
||||
).toThrow();
|
||||
|
||||
expect(() =>
|
||||
startRunSchema.parse({
|
||||
taskId: "bad-id",
|
||||
}),
|
||||
).toThrow();
|
||||
|
||||
expect(() =>
|
||||
runLogSchema.parse({
|
||||
runId: "bad-id",
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("accepts bounded secret payloads", () => {
|
||||
const parsed = secretInputSchema.parse({
|
||||
companyId: "00000000-0000-0000-0000-000000000000",
|
||||
name: "vault-openai",
|
||||
description: "OpenAI API token",
|
||||
value: "sk-test-value",
|
||||
});
|
||||
|
||||
expect(parsed.companyId).toBe("00000000-0000-0000-0000-000000000000");
|
||||
expect(parsed.name).toBe("vault-openai");
|
||||
expect(parsed.value).toContain("sk-");
|
||||
});
|
||||
|
||||
it("normalizes empty optional ids to null", () => {
|
||||
const parsed = workspaceInputSchema.parse({
|
||||
companyId: "00000000-0000-0000-0000-000000000000",
|
||||
name: "Primary Workspace",
|
||||
localPath: "/tmp/workspace",
|
||||
projectId: "",
|
||||
});
|
||||
|
||||
expect(parsed.projectId).toBeNull();
|
||||
});
|
||||
|
||||
it("canonicalizes structured JSON string fields", () => {
|
||||
const companyId = "00000000-0000-0000-0000-000000000000";
|
||||
|
||||
const document = documentInputSchema.parse({
|
||||
companyId,
|
||||
type: "technical_spec",
|
||||
title: "JSON contract",
|
||||
tagsJson: "[ \"security\", \"audit\" ]",
|
||||
});
|
||||
expect(document.tagsJson).toBe("[\"security\",\"audit\"]");
|
||||
|
||||
const knowledge = knowledgeEntryInputSchema.parse({
|
||||
companyId,
|
||||
category: "technical",
|
||||
topic: "JSON fields",
|
||||
content: "Canonicalize JSON before persistence.",
|
||||
tagsJson: "[ \"runtime\" ]",
|
||||
});
|
||||
expect(knowledge.tagsJson).toBe("[\"runtime\"]");
|
||||
|
||||
const meeting = meetingInputSchema.parse({
|
||||
companyId,
|
||||
type: "department_sync",
|
||||
title: "JSON field sync",
|
||||
scheduledAt: "2026-05-15T10:00:00.000Z",
|
||||
participantAgentIds: "[ \"11111111-1111-1111-1111-111111111111\" ]",
|
||||
agendaJson: "[ \"Plan\" ]",
|
||||
actionItemsJson: "[ { \"title\": \"Ship\", \"description\": \"Finish\" } ]",
|
||||
});
|
||||
expect(meeting.participantAgentIds).toBe("[\"11111111-1111-1111-1111-111111111111\"]");
|
||||
expect(meeting.agendaJson).toBe("[\"Plan\"]");
|
||||
expect(meeting.actionItemsJson).toBe("[{\"title\":\"Ship\",\"description\":\"Finish\"}]");
|
||||
});
|
||||
|
||||
it("rejects malformed structured JSON string fields", () => {
|
||||
const companyId = "00000000-0000-0000-0000-000000000000";
|
||||
|
||||
expect(() => documentInputSchema.parse({
|
||||
companyId,
|
||||
type: "technical_spec",
|
||||
title: "Broken tags",
|
||||
tagsJson: "{",
|
||||
})).toThrow();
|
||||
|
||||
expect(() => meetingInputSchema.parse({
|
||||
companyId,
|
||||
type: "department_sync",
|
||||
title: "Broken participants",
|
||||
scheduledAt: "2026-05-15T10:00:00.000Z",
|
||||
participantAgentIds: "[1]",
|
||||
})).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,185 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import {
|
||||
interruptStaleRunsLogic,
|
||||
retryInterruptedTaskLogic,
|
||||
markInterruptedTaskFailedLogic,
|
||||
type StaleRunDb,
|
||||
type RetryTaskDb,
|
||||
type FailTaskDb,
|
||||
} from "@main/database/crash-recovery-helpers";
|
||||
|
||||
/**
|
||||
* Tests for crash recovery detection and user notification.
|
||||
*
|
||||
* Covers:
|
||||
* - interruptStaleRunsLogic returns correct count and marks tasks as blocked
|
||||
* - retryInterruptedTaskLogic resets task to 'todo' and clears retry_count
|
||||
* - markInterruptedTaskFailedLogic sets task to 'cancelled'
|
||||
*
|
||||
* getInterruptedRunsForRecovery is a pure SQL query with a well-defined JOIN
|
||||
* shape; its contract is tested via the return-type assertions below.
|
||||
*/
|
||||
|
||||
function makeStaleRunDb(runningCount: number): StaleRunDb & { calls: string[] } {
|
||||
const calls: string[] = [];
|
||||
return {
|
||||
calls,
|
||||
interruptRunningRuns: vi.fn((_ts: string) => {
|
||||
calls.push("interruptRunningRuns");
|
||||
return runningCount;
|
||||
}) as unknown as StaleRunDb["interruptRunningRuns"],
|
||||
blockInterruptedTasks: vi.fn((_ts: string) => {
|
||||
calls.push("blockInterruptedTasks");
|
||||
}) as unknown as StaleRunDb["blockInterruptedTasks"],
|
||||
clearInterruptedTaskLeases: vi.fn(() => {
|
||||
calls.push("clearInterruptedTaskLeases");
|
||||
}) as unknown as StaleRunDb["clearInterruptedTaskLeases"],
|
||||
resetAgentsToIdle: vi.fn((_ts: string) => {
|
||||
calls.push("resetAgentsToIdle");
|
||||
}) as unknown as StaleRunDb["resetAgentsToIdle"],
|
||||
releaseAllWorkspaceLocks: vi.fn(() => {
|
||||
calls.push("releaseAllWorkspaceLocks");
|
||||
}) as unknown as StaleRunDb["releaseAllWorkspaceLocks"],
|
||||
};
|
||||
}
|
||||
|
||||
describe("Crash Recovery Detection", () => {
|
||||
const transaction = (fn: () => void) => fn();
|
||||
const nowIso = () => new Date().toISOString();
|
||||
|
||||
describe("interruptStaleRunsLogic", () => {
|
||||
it("returns 0 when no running runs exist", () => {
|
||||
const db = makeStaleRunDb(0);
|
||||
const count = interruptStaleRunsLogic(db, transaction, nowIso);
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
|
||||
it("returns correct count when running runs exist", () => {
|
||||
const db = makeStaleRunDb(3);
|
||||
const count = interruptStaleRunsLogic(db, transaction, nowIso);
|
||||
expect(count).toBe(3);
|
||||
});
|
||||
|
||||
it("marks associated tasks as blocked when interrupted runs exist", () => {
|
||||
const db = makeStaleRunDb(2);
|
||||
interruptStaleRunsLogic(db, transaction, nowIso);
|
||||
expect(db.calls).toContain("blockInterruptedTasks");
|
||||
});
|
||||
|
||||
it("leaves task blocking step idle when no runs were interrupted", () => {
|
||||
const db = makeStaleRunDb(0);
|
||||
interruptStaleRunsLogic(db, transaction, nowIso);
|
||||
expect(db.calls).not.toContain("blockInterruptedTasks");
|
||||
});
|
||||
|
||||
it("always clears interrupted task leases and resets agents to idle", () => {
|
||||
const db = makeStaleRunDb(0);
|
||||
interruptStaleRunsLogic(db, transaction, nowIso);
|
||||
expect(db.calls).toContain("clearInterruptedTaskLeases");
|
||||
expect(db.calls).toContain("resetAgentsToIdle");
|
||||
});
|
||||
|
||||
it("releases all workspace locks", () => {
|
||||
const db = makeStaleRunDb(1);
|
||||
interruptStaleRunsLogic(db, transaction, nowIso);
|
||||
expect(db.calls).toContain("releaseAllWorkspaceLocks");
|
||||
});
|
||||
|
||||
it("executes all steps inside the transaction", () => {
|
||||
const db = makeStaleRunDb(1);
|
||||
const txCalls: string[] = [];
|
||||
const trackingTransaction = (fn: () => void) => {
|
||||
txCalls.push("begin");
|
||||
fn();
|
||||
txCalls.push("end");
|
||||
};
|
||||
interruptStaleRunsLogic(db, trackingTransaction, nowIso);
|
||||
expect(txCalls[0]).toBe("begin");
|
||||
expect(txCalls[txCalls.length - 1]).toBe("end");
|
||||
expect(db.calls.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getInterruptedRunsForRecovery (shape contract)", () => {
|
||||
it("result entries have required fields", () => {
|
||||
// This function is a pure SQL JOIN; we verify its return shape here
|
||||
// by asserting the TypeScript type it must conform to.
|
||||
const mockResult: Array<{
|
||||
runId: string;
|
||||
taskId: string;
|
||||
taskTitle: string;
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
connectorId: string;
|
||||
interruptedAt: string;
|
||||
}> = [
|
||||
{
|
||||
runId: "run-1",
|
||||
taskId: "task-1",
|
||||
taskTitle: "Build feature",
|
||||
agentId: "agent-1",
|
||||
agentName: "Developer",
|
||||
connectorId: "claude-code",
|
||||
interruptedAt: "2026-03-22T07:00:00Z",
|
||||
},
|
||||
];
|
||||
expect(mockResult[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
runId: expect.any(String),
|
||||
taskId: expect.any(String),
|
||||
taskTitle: expect.any(String),
|
||||
agentName: expect.any(String),
|
||||
interruptedAt: expect.any(String),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns empty array when no interrupted runs with blocked tasks exist", () => {
|
||||
const result: Array<unknown> = [];
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("retryInterruptedTaskLogic", () => {
|
||||
let db: RetryTaskDb;
|
||||
|
||||
beforeEach(() => {
|
||||
db = {
|
||||
setTaskTodo: vi.fn(),
|
||||
resetTaskRetryFields: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
it("sets task to todo and resets retry fields", () => {
|
||||
retryInterruptedTaskLogic(db, "task-42", nowIso);
|
||||
expect(db.setTaskTodo).toHaveBeenCalledWith(expect.any(String), "task-42");
|
||||
expect(db.resetTaskRetryFields).toHaveBeenCalledWith(expect.any(String), "task-42");
|
||||
});
|
||||
|
||||
it("calls both statements with the same timestamp", () => {
|
||||
const fixedTs = "2026-01-01T00:00:00.000Z";
|
||||
retryInterruptedTaskLogic(db, "task-42", () => fixedTs);
|
||||
expect(db.setTaskTodo).toHaveBeenCalledWith(fixedTs, "task-42");
|
||||
expect(db.resetTaskRetryFields).toHaveBeenCalledWith(fixedTs, "task-42");
|
||||
});
|
||||
});
|
||||
|
||||
describe("markInterruptedTaskFailedLogic", () => {
|
||||
let db: FailTaskDb;
|
||||
|
||||
beforeEach(() => {
|
||||
db = { setTaskCancelled: vi.fn() };
|
||||
});
|
||||
|
||||
it("sets task to cancelled", () => {
|
||||
markInterruptedTaskFailedLogic(db, "task-99", nowIso);
|
||||
expect(db.setTaskCancelled).toHaveBeenCalledWith(expect.any(String), "task-99");
|
||||
});
|
||||
|
||||
it("passes the taskId correctly", () => {
|
||||
const fixedTs = "2026-01-01T00:00:00.000Z";
|
||||
markInterruptedTaskFailedLogic(db, "task-99", () => fixedTs);
|
||||
expect(db.setTaskCancelled).toHaveBeenCalledWith(fixedTs, "task-99");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,106 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { InMemoryCredentialConcurrencyGate } from "../../src/main/worker/runtime/credential-concurrency-gate";
|
||||
|
||||
function makeGate(limit: number | null) {
|
||||
return new InMemoryCredentialConcurrencyGate(() => limit);
|
||||
}
|
||||
|
||||
describe("InMemoryCredentialConcurrencyGate", () => {
|
||||
it("returns a no-op release when the provider is uncapped", async () => {
|
||||
const gate = makeGate(null);
|
||||
const release = await gate.acquire("anthropic", "key-x");
|
||||
expect(typeof release).toBe("function");
|
||||
release();
|
||||
release();
|
||||
});
|
||||
|
||||
it("hands out slots up to the limit immediately", async () => {
|
||||
const gate = makeGate(2);
|
||||
const r1 = await gate.acquire("fireworks-firepass", "shared");
|
||||
const r2 = await gate.acquire("fireworks-firepass", "shared");
|
||||
expect(r1).not.toBe(r2);
|
||||
r1();
|
||||
r2();
|
||||
});
|
||||
|
||||
it("queues the third caller until a slot is released", async () => {
|
||||
const gate = makeGate(1);
|
||||
const release = await gate.acquire("fireworks-firepass", "shared");
|
||||
const order: string[] = [];
|
||||
|
||||
const second = (async () => {
|
||||
const r = await gate.acquire("fireworks-firepass", "shared");
|
||||
order.push("second");
|
||||
r();
|
||||
})();
|
||||
const third = (async () => {
|
||||
const r = await gate.acquire("fireworks-firepass", "shared");
|
||||
order.push("third");
|
||||
r();
|
||||
})();
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
expect(order).toEqual([]);
|
||||
|
||||
release();
|
||||
await second;
|
||||
expect(order).toEqual(["second"]);
|
||||
|
||||
await third;
|
||||
expect(order).toEqual(["second", "third"]);
|
||||
});
|
||||
|
||||
it("scopes pools by (provider, apiKey) so different keys do not block each other", async () => {
|
||||
const gate = makeGate(1);
|
||||
const r1 = await gate.acquire("fireworks-firepass", "key-a");
|
||||
// Different apiKey -> new pool, no waiting.
|
||||
const r2 = await gate.acquire("fireworks-firepass", "key-b");
|
||||
expect(typeof r1).toBe("function");
|
||||
expect(typeof r2).toBe("function");
|
||||
r1();
|
||||
r2();
|
||||
});
|
||||
|
||||
it("releases the pool when no callers remain", async () => {
|
||||
const gate = makeGate(1);
|
||||
const release = await gate.acquire("fireworks-firepass", "shared");
|
||||
release();
|
||||
// Re-acquire should not be queued.
|
||||
const next = await gate.acquire("fireworks-firepass", "shared");
|
||||
next();
|
||||
});
|
||||
|
||||
it("rejects when the abort signal fires while waiting", async () => {
|
||||
const gate = makeGate(1);
|
||||
const held = await gate.acquire("fireworks-firepass", "shared");
|
||||
|
||||
const controller = new AbortController();
|
||||
const pending = gate.acquire("fireworks-firepass", "shared", controller.signal);
|
||||
controller.abort();
|
||||
|
||||
await expect(pending).rejects.toMatchObject({ name: "AbortError" });
|
||||
|
||||
// Slot must remain available after the aborted waiter is removed.
|
||||
held();
|
||||
const next = await gate.acquire("fireworks-firepass", "shared");
|
||||
next();
|
||||
});
|
||||
|
||||
it("rejects immediately when the abort signal is already aborted", async () => {
|
||||
const gate = makeGate(1);
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
await expect(
|
||||
gate.acquire("fireworks-firepass", "shared", controller.signal),
|
||||
).rejects.toMatchObject({ name: "AbortError" });
|
||||
});
|
||||
|
||||
it("treats double-release as a no-op", async () => {
|
||||
const gate = makeGate(1);
|
||||
const release = await gate.acquire("fireworks-firepass", "shared");
|
||||
release();
|
||||
release();
|
||||
const next = await gate.acquire("fireworks-firepass", "shared");
|
||||
next();
|
||||
});
|
||||
});
|
||||
@@ -1,210 +0,0 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { AppDatabase } from "../../src/main/database";
|
||||
import { CredentialStore } from "../../src/main/credentials/credential-store";
|
||||
import { CredentialStoreReadError } from "../../src/main/credentials/credential-store";
|
||||
import { CredentialResolver, CredentialError } from "../../src/main/credentials/credential-resolver";
|
||||
import { AppError } from "../../src/main/errors";
|
||||
|
||||
beforeAll(() => {
|
||||
process.env.AGENTCOMPANY_ALLOW_INSECURE_VAULT = "true";
|
||||
});
|
||||
|
||||
describe("CredentialResolver", () => {
|
||||
let tempDir: string;
|
||||
let db: AppDatabase;
|
||||
let store: CredentialStore;
|
||||
let resolver: CredentialResolver;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "agentcompany-credential-resolver-"));
|
||||
db = new AppDatabase(tempDir);
|
||||
db.init();
|
||||
store = new CredentialStore({ db });
|
||||
resolver = new CredentialResolver(store);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
db.close();
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("returns the literal apiKey for an api_key connector", async () => {
|
||||
store.save("anthropic", {
|
||||
kind: "api_key",
|
||||
value: { source: "literal", value: "sk-ant-test-1234567890abcd" },
|
||||
});
|
||||
const resolved = await resolver.resolve("anthropic");
|
||||
expect(resolved.apiKey).toBe("sk-ant-test-1234567890abcd");
|
||||
expect(resolved.extraEnv).toBeUndefined();
|
||||
});
|
||||
|
||||
it("resolves an api_key connector backed by an env variable", async () => {
|
||||
process.env.OPENAI_API_KEY = "sk-from-env-1234";
|
||||
store.save("openai", {
|
||||
kind: "api_key",
|
||||
value: { source: "env", value: "OPENAI_API_KEY" },
|
||||
});
|
||||
try {
|
||||
const resolved = await resolver.resolve("openai");
|
||||
expect(resolved.apiKey).toBe("sk-from-env-1234");
|
||||
} finally {
|
||||
delete process.env.OPENAI_API_KEY;
|
||||
}
|
||||
});
|
||||
|
||||
it("throws CREDENTIAL_MISSING when the env-backed key is unset", async () => {
|
||||
delete process.env.XAI_API_KEY;
|
||||
store.save("xai", { kind: "api_key", value: { source: "env", value: "XAI_API_KEY" } });
|
||||
let thrown: unknown;
|
||||
try {
|
||||
await resolver.resolve("xai");
|
||||
} catch (error) {
|
||||
thrown = error;
|
||||
}
|
||||
expect(thrown).toBeInstanceOf(CredentialError);
|
||||
expect(thrown).toBeInstanceOf(AppError);
|
||||
expect(thrown).toMatchObject({
|
||||
kind: "upstream",
|
||||
code: "CREDENTIAL_MISSING",
|
||||
connectorId: "xai",
|
||||
context: { connectorId: "xai" },
|
||||
});
|
||||
});
|
||||
|
||||
it("assembles AWS extraEnv for IAM auth mode", async () => {
|
||||
store.save("amazon-bedrock", {
|
||||
kind: "aws",
|
||||
value: {
|
||||
authMode: "iam",
|
||||
region: "us-west-2",
|
||||
accessKeyId: "AKIA-TEST-FAKE-1234",
|
||||
secretAccessKey: "secret-fake-secret",
|
||||
sessionToken: "session-fake-token",
|
||||
},
|
||||
});
|
||||
const resolved = await resolver.resolve("amazon-bedrock");
|
||||
expect(resolved.apiKey).toBe("");
|
||||
expect(resolved.extraEnv).toEqual({
|
||||
AWS_REGION: "us-west-2",
|
||||
AWS_ACCESS_KEY_ID: "AKIA-TEST-FAKE-1234",
|
||||
AWS_SECRET_ACCESS_KEY: "secret-fake-secret",
|
||||
AWS_SESSION_TOKEN: "session-fake-token",
|
||||
});
|
||||
});
|
||||
|
||||
it("assembles AWS extraEnv for profile auth mode plus advanced flags", async () => {
|
||||
store.save("amazon-bedrock", {
|
||||
kind: "aws",
|
||||
value: {
|
||||
authMode: "profile",
|
||||
region: "eu-west-1",
|
||||
awsProfile: "production",
|
||||
forceCache: true,
|
||||
endpointUrl: "https://bedrock-runtime.eu-west-1.amazonaws.com",
|
||||
skipAuth: true,
|
||||
forceHttp1: true,
|
||||
},
|
||||
});
|
||||
const resolved = await resolver.resolve("amazon-bedrock");
|
||||
expect(resolved.extraEnv).toEqual({
|
||||
AWS_REGION: "eu-west-1",
|
||||
AWS_PROFILE: "production",
|
||||
AWS_BEDROCK_FORCE_CACHE: "1",
|
||||
AWS_ENDPOINT_URL_BEDROCK_RUNTIME: "https://bedrock-runtime.eu-west-1.amazonaws.com",
|
||||
AWS_BEDROCK_SKIP_AUTH: "1",
|
||||
AWS_BEDROCK_FORCE_HTTP1: "1",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects unsafe AWS endpointUrl values from stored credentials", async () => {
|
||||
store.save("amazon-bedrock", {
|
||||
kind: "aws",
|
||||
value: {
|
||||
authMode: "profile",
|
||||
region: "us-east-1",
|
||||
awsProfile: "default",
|
||||
endpointUrl: "https://example.com",
|
||||
},
|
||||
});
|
||||
|
||||
let thrown: unknown;
|
||||
try {
|
||||
await resolver.resolve("amazon-bedrock");
|
||||
} catch (error) {
|
||||
thrown = error;
|
||||
}
|
||||
|
||||
expect(thrown).toBeInstanceOf(CredentialStoreReadError);
|
||||
expect(thrown).toMatchObject({
|
||||
kind: "upstream",
|
||||
code: "CREDENTIAL_INVALID",
|
||||
connectorId: "amazon-bedrock",
|
||||
context: { connectorId: "amazon-bedrock" },
|
||||
});
|
||||
});
|
||||
|
||||
it("assembles Azure extraEnv with deploymentMap", async () => {
|
||||
store.save("azure-openai", {
|
||||
kind: "azure",
|
||||
value: {
|
||||
apiKey: "azure-secret-key",
|
||||
baseUrl: "https://my-resource.openai.azure.com/",
|
||||
resourceName: "my-resource",
|
||||
apiVersion: "2024-08-01-preview",
|
||||
deploymentMap: { "gpt-4o": "gpt4-deploy", "gpt-4o-mini": "gpt4mini-deploy" },
|
||||
},
|
||||
});
|
||||
const resolved = await resolver.resolve("azure-openai");
|
||||
expect(resolved.apiKey).toBe("azure-secret-key");
|
||||
expect(resolved.extraEnv).toMatchObject({
|
||||
AZURE_OPENAI_API_KEY: "azure-secret-key",
|
||||
AZURE_OPENAI_BASE_URL: "https://my-resource.openai.azure.com/",
|
||||
AZURE_OPENAI_RESOURCE_NAME: "my-resource",
|
||||
AZURE_OPENAI_API_VERSION: "2024-08-01-preview",
|
||||
});
|
||||
expect(resolved.extraEnv?.AZURE_OPENAI_DEPLOYMENT_NAME_MAP).toContain("gpt-4o=gpt4-deploy");
|
||||
});
|
||||
|
||||
it("assembles Vertex extraEnv from project + location + credentials path", async () => {
|
||||
store.save("google-vertex", {
|
||||
kind: "gcp_adc",
|
||||
value: {
|
||||
project: "my-gcp-project",
|
||||
location: "us-central1",
|
||||
credentialsPath: "/etc/gcp/service-account.json",
|
||||
},
|
||||
});
|
||||
const resolved = await resolver.resolve("google-vertex");
|
||||
expect(resolved.apiKey).toBe("");
|
||||
expect(resolved.extraEnv).toEqual({
|
||||
GOOGLE_CLOUD_PROJECT: "my-gcp-project",
|
||||
GOOGLE_CLOUD_LOCATION: "us-central1",
|
||||
GOOGLE_APPLICATION_CREDENTIALS: "/etc/gcp/service-account.json",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects remote baseUrl values for local connectors", async () => {
|
||||
store.save("ollama", {
|
||||
kind: "local",
|
||||
value: { baseUrl: "https://example.com/v1" },
|
||||
});
|
||||
|
||||
let thrown: unknown;
|
||||
try {
|
||||
await resolver.resolve("ollama");
|
||||
} catch (error) {
|
||||
thrown = error;
|
||||
}
|
||||
|
||||
expect(thrown).toBeInstanceOf(CredentialStoreReadError);
|
||||
expect(thrown).toMatchObject({
|
||||
kind: "upstream",
|
||||
code: "CREDENTIAL_INVALID",
|
||||
connectorId: "ollama",
|
||||
context: { connectorId: "ollama" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,152 +0,0 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { AppDatabase } from "../../src/main/database";
|
||||
import { CredentialStore, CredentialStoreReadError } from "../../src/main/credentials/credential-store";
|
||||
|
||||
beforeAll(() => {
|
||||
// SecretVault throws unless safeStorage is available or this gate is set.
|
||||
// safeStorage is unavailable inside vitest, so we use the test-only path.
|
||||
process.env.AGENTCOMPANY_ALLOW_INSECURE_VAULT = "true";
|
||||
});
|
||||
|
||||
describe("CredentialStore", () => {
|
||||
let tempDir: string;
|
||||
let db: AppDatabase;
|
||||
let store: CredentialStore;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "agentcompany-credential-store-"));
|
||||
db = new AppDatabase(tempDir);
|
||||
db.init();
|
||||
store = new CredentialStore({ db });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
db.close();
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("round-trips an api_key literal credential", () => {
|
||||
const summary = store.save("anthropic", { kind: "api_key", value: { source: "literal", value: "sk-ant-test1234567890abcd" } });
|
||||
expect(summary.kind).toBe("api_key");
|
||||
expect(summary.status).toBe("active");
|
||||
expect(summary.displayHint).toContain("...");
|
||||
expect(summary.displayHint).not.toContain("test1234567890");
|
||||
|
||||
const decrypted = store.read("anthropic");
|
||||
expect(decrypted).not.toBeNull();
|
||||
expect(decrypted!.kind).toBe("api_key");
|
||||
if (decrypted!.kind === "api_key") {
|
||||
expect(decrypted!.value.source).toBe("literal");
|
||||
expect(decrypted!.value.value).toBe("sk-ant-test1234567890abcd");
|
||||
}
|
||||
});
|
||||
|
||||
it("round-trips an aws credential with bearer mode", () => {
|
||||
store.save("amazon-bedrock", {
|
||||
kind: "aws",
|
||||
value: {
|
||||
authMode: "bearer",
|
||||
region: "us-east-2",
|
||||
bearerToken: "bedrock-bearer-xyz",
|
||||
},
|
||||
});
|
||||
const decrypted = store.read("amazon-bedrock");
|
||||
expect(decrypted?.kind).toBe("aws");
|
||||
if (decrypted?.kind === "aws") {
|
||||
expect(decrypted.value.authMode).toBe("bearer");
|
||||
expect(decrypted.value.region).toBe("us-east-2");
|
||||
expect(decrypted.value.bearerToken).toBe("bedrock-bearer-xyz");
|
||||
}
|
||||
});
|
||||
|
||||
it("round-trips an oauth credential through the read schema", () => {
|
||||
store.save("claude-pro", {
|
||||
kind: "oauth",
|
||||
value: {
|
||||
refresh: "refresh-token",
|
||||
access: "access-token",
|
||||
expires: Date.now() + 60_000,
|
||||
domain: "anthropic.com",
|
||||
},
|
||||
});
|
||||
|
||||
const decrypted = store.read("claude-pro");
|
||||
expect(decrypted?.kind).toBe("oauth");
|
||||
if (decrypted?.kind === "oauth") {
|
||||
expect(decrypted.value.refresh).toBe("refresh-token");
|
||||
expect(decrypted.value.access).toBe("access-token");
|
||||
expect(decrypted.value.domain).toBe("anthropic.com");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects stored credentials whose decrypted payload does not match the row kind", () => {
|
||||
const encrypted = db.vault.encrypt(JSON.stringify({ source: "literal", value: "sk-test-abcdefghij" }));
|
||||
db.core.credentials.upsert({
|
||||
connectorId: "amazon-bedrock",
|
||||
kind: "aws",
|
||||
backend: encrypted.backend,
|
||||
ciphertext: encrypted.payload,
|
||||
displayHint: "bad",
|
||||
status: "active",
|
||||
});
|
||||
|
||||
let thrown: unknown;
|
||||
try {
|
||||
store.read("amazon-bedrock");
|
||||
} catch (error) {
|
||||
thrown = error;
|
||||
}
|
||||
|
||||
expect(thrown).toBeInstanceOf(CredentialStoreReadError);
|
||||
expect(thrown).toMatchObject({
|
||||
code: "CREDENTIAL_INVALID",
|
||||
context: { connectorId: "amazon-bedrock" },
|
||||
});
|
||||
expect(thrown).toHaveProperty("message", expect.stringContaining("failed schema validation"));
|
||||
});
|
||||
|
||||
it("rejects stored credentials whose ciphertext is not valid JSON", () => {
|
||||
const encrypted = db.vault.encrypt("{not-json");
|
||||
db.core.credentials.upsert({
|
||||
connectorId: "anthropic",
|
||||
kind: "api_key",
|
||||
backend: encrypted.backend,
|
||||
ciphertext: encrypted.payload,
|
||||
displayHint: "bad",
|
||||
status: "active",
|
||||
});
|
||||
|
||||
let thrown: unknown;
|
||||
try {
|
||||
store.read("anthropic");
|
||||
} catch (error) {
|
||||
thrown = error;
|
||||
}
|
||||
|
||||
expect(thrown).toBeInstanceOf(CredentialStoreReadError);
|
||||
expect(thrown).toMatchObject({
|
||||
code: "CREDENTIAL_INVALID",
|
||||
context: { connectorId: "anthropic" },
|
||||
});
|
||||
expect(thrown).toHaveProperty("message", "Stored credential payload is not valid JSON.");
|
||||
});
|
||||
|
||||
it("removes a credential", () => {
|
||||
store.save("openai", { kind: "api_key", value: { source: "literal", value: "sk-test-abcdefghij" } });
|
||||
expect(store.list().some((row) => row.connectorId === "openai")).toBe(true);
|
||||
store.remove("openai");
|
||||
expect(store.list().some((row) => row.connectorId === "openai")).toBe(false);
|
||||
expect(store.read("openai")).toBeNull();
|
||||
});
|
||||
|
||||
it("setStatus updates the status row without losing ciphertext", () => {
|
||||
store.save("groq", { kind: "api_key", value: { source: "env", value: "GROQ_API_KEY" } });
|
||||
store.setStatus("groq", "expired", "refresh failed");
|
||||
const row = store.list().find((entry) => entry.connectorId === "groq");
|
||||
expect(row?.status).toBe("expired");
|
||||
expect(row?.lastError).toBe("refresh failed");
|
||||
});
|
||||
});
|
||||
@@ -1,219 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
apiKeyValueSchema,
|
||||
awsCredentialSchema,
|
||||
azureCredentialSchema,
|
||||
credentialPayloadSchema,
|
||||
gcpAdcCredentialSchema,
|
||||
isAwsBedrockEndpointUrl,
|
||||
isLocalConnectorBaseUrl,
|
||||
localCredentialSchema,
|
||||
removeCredentialInputSchema,
|
||||
saveCredentialInputSchema,
|
||||
testCredentialInputSchema,
|
||||
} from "../../src/shared/contracts/schemas-credentials";
|
||||
import {
|
||||
oauthCancelInputSchema,
|
||||
oauthManualCodeResponseSchema,
|
||||
oauthPromptResponseSchema,
|
||||
oauthStartInputSchema,
|
||||
} from "../../src/shared/contracts/schemas-oauth";
|
||||
|
||||
/** Verifies the IPC payload schemas reject malformed input and accept valid
|
||||
* shapes. These are the only contract between renderer ↔ main for the new
|
||||
* credential / OAuth channels — incorrect validation would let bad data
|
||||
* reach pi-mono primitives. */
|
||||
|
||||
describe("credentials Zod schemas", () => {
|
||||
it("apiKeyValueSchema rejects empty value", () => {
|
||||
const result = apiKeyValueSchema.safeParse({ source: "literal", value: "" });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("apiKeyValueSchema accepts both source kinds", () => {
|
||||
expect(apiKeyValueSchema.safeParse({ source: "literal", value: "sk-x" }).success).toBe(true);
|
||||
expect(apiKeyValueSchema.safeParse({ source: "env", value: "MY_KEY" }).success).toBe(true);
|
||||
});
|
||||
|
||||
it("apiKeyValueSchema rejects unknown source", () => {
|
||||
const result = apiKeyValueSchema.safeParse({ source: "shell", value: "$(echo x)" });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("awsCredentialSchema requires authMode", () => {
|
||||
expect(awsCredentialSchema.safeParse({ region: "us-east-1" }).success).toBe(false);
|
||||
expect(awsCredentialSchema.safeParse({ authMode: "profile", region: "us-east-1", awsProfile: "default" }).success).toBe(true);
|
||||
});
|
||||
|
||||
it("awsCredentialSchema accepts all three auth modes", () => {
|
||||
for (const authMode of ["profile", "iam", "bearer"] as const) {
|
||||
const result = awsCredentialSchema.safeParse({ authMode, region: "us-east-1" });
|
||||
expect(result.success).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("awsCredentialSchema accepts AWS Bedrock and private HTTPS endpointUrl values", () => {
|
||||
for (const endpointUrl of [
|
||||
"https://bedrock-runtime.us-east-1.amazonaws.com",
|
||||
"https://bedrock-runtime.us-east-1.amazonaws.com/",
|
||||
"https://bedrock-runtime-fips.us-gov-west-1.amazonaws.com",
|
||||
"https://bedrock-runtime.cn-north-1.amazonaws.com.cn",
|
||||
"https://vpce-123abc.bedrock-runtime.us-east-1.vpce.amazonaws.com",
|
||||
"https://vpce-123abc-us-east-1a.bedrock-runtime.us-east-1.vpce.amazonaws.com",
|
||||
"https://10.0.0.20",
|
||||
"https://192.168.1.20",
|
||||
"https://[fd12::1]",
|
||||
]) {
|
||||
const result = awsCredentialSchema.safeParse({ authMode: "profile", region: "us-east-1", awsProfile: "default", endpointUrl });
|
||||
expect(result.success).toBe(true);
|
||||
expect(isAwsBedrockEndpointUrl(endpointUrl)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("awsCredentialSchema rejects unsafe endpointUrl values", () => {
|
||||
for (const endpointUrl of [
|
||||
"http://bedrock-runtime.us-east-1.amazonaws.com",
|
||||
"https://api.openai.com/v1",
|
||||
"https://bedrock-runtime.example.com",
|
||||
"https://user:pass@bedrock-runtime.us-east-1.amazonaws.com",
|
||||
"https://bedrock-runtime.us-east-1.amazonaws.com/model/test",
|
||||
"https://bedrock-runtime.us-east-1.amazonaws.com?proxy=1",
|
||||
"ftp://bedrock-runtime.us-east-1.amazonaws.com",
|
||||
"https://bedrock-runtime.us-east-1.amazonaws.com?",
|
||||
"https://bedrock-runtime.us-east-1.amazonaws.com#",
|
||||
"https://bedrock-runtime.us-east-1.amazonaws.com/%2e%2e",
|
||||
"https://bedrock-runtime.us-east-1.amazonaws.com/a/..",
|
||||
"https://bedrock-runtime.us-east-1.vpce.amazonaws.com",
|
||||
"https://foo.bar.bedrock-runtime.us-east-1.vpce.amazonaws.com",
|
||||
]) {
|
||||
const result = awsCredentialSchema.safeParse({ authMode: "profile", region: "us-east-1", awsProfile: "default", endpointUrl });
|
||||
expect(result.success).toBe(false);
|
||||
expect(isAwsBedrockEndpointUrl(endpointUrl)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("azureCredentialSchema requires apiKey", () => {
|
||||
expect(azureCredentialSchema.safeParse({}).success).toBe(false);
|
||||
expect(azureCredentialSchema.safeParse({ apiKey: "azure-secret" }).success).toBe(true);
|
||||
});
|
||||
|
||||
it("gcpAdcCredentialSchema requires project + location", () => {
|
||||
expect(gcpAdcCredentialSchema.safeParse({ project: "" }).success).toBe(false);
|
||||
expect(gcpAdcCredentialSchema.safeParse({ project: "p", location: "us-central1" }).success).toBe(true);
|
||||
});
|
||||
|
||||
it("localCredentialSchema accepts loopback and private-network baseUrl values", () => {
|
||||
for (const baseUrl of [
|
||||
"http://localhost:11434/v1",
|
||||
"http://127.0.0.1:11434/v1",
|
||||
"http://10.0.0.20:11434/v1",
|
||||
"https://172.16.0.10:11434/v1",
|
||||
"http://172.31.255.255:11434/v1",
|
||||
"http://192.168.1.20:11434/v1",
|
||||
"http://169.254.1.20:11434/v1",
|
||||
"http://[::1]:11434/v1",
|
||||
"http://[fc00::1]:11434/v1",
|
||||
"http://[fd12::1]:11434/v1",
|
||||
"http://[fe80::1]:11434/v1",
|
||||
]) {
|
||||
expect(localCredentialSchema.safeParse({ baseUrl }).success).toBe(true);
|
||||
expect(isLocalConnectorBaseUrl(baseUrl)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("localCredentialSchema rejects remote OpenAI-compatible endpoints", () => {
|
||||
for (const baseUrl of [
|
||||
"https://api.openai.com/v1",
|
||||
"https://example.com/v1",
|
||||
"http://172.32.0.1:11434/v1",
|
||||
"http://100.64.0.1:11434/v1",
|
||||
"ftp://localhost:11434/v1",
|
||||
"http://user:pass@127.0.0.1:11434/v1",
|
||||
]) {
|
||||
expect(localCredentialSchema.safeParse({ baseUrl }).success).toBe(false);
|
||||
expect(isLocalConnectorBaseUrl(baseUrl)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("credentialPayloadSchema discriminates on kind", () => {
|
||||
expect(
|
||||
credentialPayloadSchema.safeParse({
|
||||
kind: "api_key",
|
||||
value: { source: "literal", value: "sk-x" },
|
||||
}).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
credentialPayloadSchema.safeParse({
|
||||
kind: "azure",
|
||||
value: { source: "literal", value: "wrong-shape" },
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("saveCredentialInputSchema requires connectorId + payload", () => {
|
||||
expect(
|
||||
saveCredentialInputSchema.safeParse({
|
||||
connectorId: "anthropic",
|
||||
payload: { kind: "api_key", value: { source: "literal", value: "sk-x" } },
|
||||
}).success,
|
||||
).toBe(true);
|
||||
expect(saveCredentialInputSchema.safeParse({ connectorId: "" }).success).toBe(false);
|
||||
expect(saveCredentialInputSchema.safeParse({ payload: {} }).success).toBe(false);
|
||||
});
|
||||
|
||||
it("removeCredentialInputSchema rejects empty connectorId", () => {
|
||||
expect(removeCredentialInputSchema.safeParse({ connectorId: "openai" }).success).toBe(true);
|
||||
expect(removeCredentialInputSchema.safeParse({ connectorId: "" }).success).toBe(false);
|
||||
expect(removeCredentialInputSchema.safeParse({}).success).toBe(false);
|
||||
});
|
||||
|
||||
it("testCredentialInputSchema rejects empty connectorId", () => {
|
||||
expect(testCredentialInputSchema.safeParse({ connectorId: "openai" }).success).toBe(true);
|
||||
expect(testCredentialInputSchema.safeParse({ connectorId: "" }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("oauth Zod schemas", () => {
|
||||
it("oauthStartInputSchema rejects empty connectorId", () => {
|
||||
expect(oauthStartInputSchema.safeParse({ connectorId: "claude-pro" }).success).toBe(true);
|
||||
expect(oauthStartInputSchema.safeParse({ connectorId: "" }).success).toBe(false);
|
||||
});
|
||||
|
||||
it("oauthCancelInputSchema rejects missing connectorId", () => {
|
||||
expect(oauthCancelInputSchema.safeParse({ connectorId: "claude-pro" }).success).toBe(true);
|
||||
expect(oauthCancelInputSchema.safeParse({}).success).toBe(false);
|
||||
});
|
||||
|
||||
it("oauthPromptResponseSchema requires connectorId, requestId and a (possibly empty) value", () => {
|
||||
expect(
|
||||
oauthPromptResponseSchema.safeParse({
|
||||
connectorId: "github-copilot",
|
||||
requestId: "req-1",
|
||||
value: "github.com",
|
||||
}).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
oauthPromptResponseSchema.safeParse({ connectorId: "github-copilot", requestId: "" }).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
oauthPromptResponseSchema.safeParse({ connectorId: "github-copilot", value: "x" }).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("oauthManualCodeResponseSchema requires non-empty code", () => {
|
||||
expect(
|
||||
oauthManualCodeResponseSchema.safeParse({
|
||||
connectorId: "anthropic",
|
||||
requestId: "req-1",
|
||||
code: "abcd",
|
||||
}).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
oauthManualCodeResponseSchema.safeParse({
|
||||
connectorId: "anthropic",
|
||||
requestId: "req-1",
|
||||
code: "",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,136 +0,0 @@
|
||||
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
interface D1MigrationModule {
|
||||
applyD1Migrations(input: {
|
||||
landingDir: string;
|
||||
run: (
|
||||
command: string,
|
||||
args: string[],
|
||||
options: { cwd: string; input: string },
|
||||
) => Promise<unknown>;
|
||||
}): Promise<{ databaseName: string | null; applied: number }>;
|
||||
parseD1DatabaseName(source: string): string;
|
||||
validateD1MigrationFileName(fileName: string): string;
|
||||
}
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
async function loadModule(): Promise<D1MigrationModule> {
|
||||
const moduleUrl = pathToFileURL(
|
||||
join(process.cwd(), "scripts/release/lib/d1-migrations.mjs"),
|
||||
).href;
|
||||
return await import(moduleUrl) as D1MigrationModule;
|
||||
}
|
||||
|
||||
async function createLandingFixture() {
|
||||
const landingDir = await mkdtemp(join(tmpdir(), "agentcompany-d1-test-"));
|
||||
tempDirs.push(landingDir);
|
||||
await mkdir(join(landingDir, "migrations"), { recursive: true });
|
||||
await writeFile(
|
||||
join(landingDir, "wrangler.toml"),
|
||||
[
|
||||
"name = \"agentcompany\"",
|
||||
"",
|
||||
"[[d1_databases]]",
|
||||
"binding = \"DOWNLOAD_COUNTER_DB\"",
|
||||
"database_name = \"agentcompany-landing-downloads\"",
|
||||
"database_id = \"dfa3f0f3-009b-4436-b519-5b50ae6b1cf9\"",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
return landingDir;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
|
||||
describe("Cloudflare D1 migration release helper", () => {
|
||||
it("parses the D1 database name from the wrangler D1 table", async () => {
|
||||
const { parseD1DatabaseName } = await loadModule();
|
||||
|
||||
expect(parseD1DatabaseName([
|
||||
"[vars]",
|
||||
"DOWNLOAD_COUNTER_SALT = \"salt\"",
|
||||
"",
|
||||
"[[d1_databases]]",
|
||||
"binding = \"DOWNLOAD_COUNTER_DB\"",
|
||||
"database_name = \"agentcompany-landing-downloads\" # production",
|
||||
].join("\n"))).toBe("agentcompany-landing-downloads");
|
||||
});
|
||||
|
||||
it("rejects shell-shaped database names", async () => {
|
||||
const { parseD1DatabaseName } = await loadModule();
|
||||
|
||||
expect(() => parseD1DatabaseName([
|
||||
"[[d1_databases]]",
|
||||
"database_name = \"prod; rm -rf /\"",
|
||||
].join("\n"))).toThrow("Invalid Cloudflare D1 database_name");
|
||||
});
|
||||
|
||||
it("rejects path-shaped migration file names", async () => {
|
||||
const { validateD1MigrationFileName } = await loadModule();
|
||||
|
||||
expect(() => validateD1MigrationFileName("../0001.sql")).toThrow(
|
||||
"Invalid D1 migration file name",
|
||||
);
|
||||
expect(() => validateD1MigrationFileName("0001;touch.sql")).toThrow(
|
||||
"Invalid D1 migration file name",
|
||||
);
|
||||
});
|
||||
|
||||
it("executes wrangler through argv and confirms through stdin", async () => {
|
||||
const { applyD1Migrations } = await loadModule();
|
||||
const landingDir = await createLandingFixture();
|
||||
await writeFile(
|
||||
join(landingDir, "migrations", "0001_download_counter.sql"),
|
||||
"SELECT 1;",
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(
|
||||
join(landingDir, "migrations", "notes.txt"),
|
||||
"ignored",
|
||||
"utf8",
|
||||
);
|
||||
const calls: Array<{
|
||||
command: string;
|
||||
args: string[];
|
||||
options: { cwd: string; input: string };
|
||||
}> = [];
|
||||
|
||||
const result = await applyD1Migrations({
|
||||
landingDir,
|
||||
run: async (command, args, options) => {
|
||||
calls.push({ command, args, options });
|
||||
return {};
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
databaseName: "agentcompany-landing-downloads",
|
||||
applied: 1,
|
||||
});
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
command: "npx",
|
||||
args: [
|
||||
"wrangler",
|
||||
"d1",
|
||||
"execute",
|
||||
"agentcompany-landing-downloads",
|
||||
"--remote",
|
||||
"--file",
|
||||
"migrations/0001_download_counter.sql",
|
||||
],
|
||||
options: { cwd: landingDir, input: "y\n" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,267 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import type { RunRecord, CostRecord, AgentRecord } from "../../src/shared/types";
|
||||
import {
|
||||
aggregateRunsByDay,
|
||||
aggregateCostsByDay,
|
||||
aggregateAgentWorkload,
|
||||
formatShortDate,
|
||||
} from "../../src/renderer/components/dashboard/charts/chart-utils";
|
||||
import type {
|
||||
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
|
||||
function makeRun(overrides: Partial<RunRecord> = {}): RunRecord {
|
||||
return {
|
||||
id: "run-1",
|
||||
companyId: "co-1",
|
||||
taskId: "task-1",
|
||||
agentId: "agent-1",
|
||||
workspaceId: null,
|
||||
connectorId: "claude-pro",
|
||||
status: "succeeded",
|
||||
summary: "",
|
||||
errorMessage: null,
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
model: null,
|
||||
costUsd: null,
|
||||
unattributedCost: false,
|
||||
usageStatus: "ok",
|
||||
startedAt: null,
|
||||
finishedAt: null,
|
||||
logPath: null,
|
||||
createdAt: "2026-03-20T10:00:00Z",
|
||||
updatedAt: "2026-03-20T10:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper to create a minimal CostRecord for testing
|
||||
function makeCost(overrides: Partial<CostRecord> = {}): CostRecord {
|
||||
return {
|
||||
id: "cost-1",
|
||||
companyId: "co-1",
|
||||
runId: "run-1",
|
||||
taskId: null,
|
||||
agentId: null,
|
||||
connectorId: "claude-pro",
|
||||
amountUsd: 0.5,
|
||||
unattributed: false,
|
||||
createdAt: "2026-03-20T10:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper to create a minimal AgentRecord for testing
|
||||
function makeAgent(overrides: Partial<AgentRecord> = {}): AgentRecord {
|
||||
return {
|
||||
id: "agent-1",
|
||||
companyId: "co-1",
|
||||
name: "Alice",
|
||||
role: "engineer",
|
||||
title: "Software Engineer",
|
||||
roleLevel: "ic",
|
||||
department: "engineering",
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "claude-pro",
|
||||
workspaceId: null,
|
||||
model: null,
|
||||
capabilities: "[]",
|
||||
budgetMonthlyUsd: 100,
|
||||
spentMonthlyUsd: 0,
|
||||
metadataJson: "{}",
|
||||
heartbeatEnabled: false,
|
||||
heartbeatIntervalSec: 300,
|
||||
lastHeartbeatAt: null,
|
||||
createdAt: "2026-03-01T00:00:00Z",
|
||||
updatedAt: "2026-03-01T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Fix "today" to a known date so bucket generation is deterministic
|
||||
const FIXED_NOW = new Date("2026-03-27T12:00:00Z");
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(FIXED_NOW);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("aggregateRunsByDay", () => {
|
||||
it("returns 14 entries all zeroed when given empty runs array", () => {
|
||||
const result = aggregateRunsByDay([], 14);
|
||||
expect(result).toHaveLength(14);
|
||||
for (const entry of result) {
|
||||
expect(entry.total).toBe(0);
|
||||
expect(entry.succeeded).toBe(0);
|
||||
expect(entry.failed).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("counts 3 runs on same day: 2 succeeded, 1 failed", () => {
|
||||
const runs = [
|
||||
makeRun({ id: "r1", status: "succeeded", createdAt: "2026-03-25T08:00:00Z" }),
|
||||
makeRun({ id: "r2", status: "succeeded", createdAt: "2026-03-25T15:00:00Z" }),
|
||||
makeRun({ id: "r3", status: "failed", createdAt: "2026-03-25T20:00:00Z" }),
|
||||
];
|
||||
const result = aggregateRunsByDay(runs, 14);
|
||||
const day25 = result.find((d) => d.date === "2026-03-25");
|
||||
expect(day25).toBeDefined();
|
||||
expect(day25!.total).toBe(3);
|
||||
expect(day25!.succeeded).toBe(2);
|
||||
expect(day25!.failed).toBe(1);
|
||||
});
|
||||
|
||||
it("returns dates sorted ascending (oldest first)", () => {
|
||||
const result = aggregateRunsByDay([], 14);
|
||||
for (let i = 1; i < result.length; i++) {
|
||||
expect(result[i].date > result[i - 1].date).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("excludes runs outside the 14-day window", () => {
|
||||
const oldRun = makeRun({
|
||||
id: "old",
|
||||
status: "succeeded",
|
||||
createdAt: "2026-03-01T10:00:00Z", // well before 14 days ago
|
||||
});
|
||||
const result = aggregateRunsByDay([oldRun], 14);
|
||||
const total = result.reduce((sum, d) => sum + d.total, 0);
|
||||
expect(total).toBe(0);
|
||||
});
|
||||
|
||||
it("counts timed_out and interrupted as failed", () => {
|
||||
const runs = [
|
||||
makeRun({ id: "r1", status: "timed_out", createdAt: "2026-03-25T10:00:00Z" }),
|
||||
makeRun({ id: "r2", status: "interrupted", createdAt: "2026-03-25T11:00:00Z" }),
|
||||
];
|
||||
const result = aggregateRunsByDay(runs, 14);
|
||||
const day25 = result.find((d) => d.date === "2026-03-25");
|
||||
expect(day25!.total).toBe(2);
|
||||
expect(day25!.failed).toBe(2);
|
||||
expect(day25!.succeeded).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("aggregateCostsByDay", () => {
|
||||
it("returns 14 entries all zeroed when given empty costs array", () => {
|
||||
const result = aggregateCostsByDay([], 14);
|
||||
expect(result).toHaveLength(14);
|
||||
for (const entry of result) {
|
||||
expect(entry.total).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("sums costs per day correctly", () => {
|
||||
const costs = [
|
||||
makeCost({ id: "c1", amountUsd: 1.5, createdAt: "2026-03-25T08:00:00Z" }),
|
||||
makeCost({ id: "c2", amountUsd: 2.25, createdAt: "2026-03-25T12:00:00Z" }),
|
||||
makeCost({ id: "c3", amountUsd: 0.75, createdAt: "2026-03-26T10:00:00Z" }),
|
||||
];
|
||||
const result = aggregateCostsByDay(costs, 14);
|
||||
const day25 = result.find((d) => d.date === "2026-03-25");
|
||||
const day26 = result.find((d) => d.date === "2026-03-26");
|
||||
expect(day25!.total).toBeCloseTo(3.75);
|
||||
expect(day26!.total).toBeCloseTo(0.75);
|
||||
});
|
||||
|
||||
it("handles null amountUsd as 0", () => {
|
||||
const costs = [
|
||||
makeCost({ id: "c1", amountUsd: null, createdAt: "2026-03-25T10:00:00Z" }),
|
||||
makeCost({ id: "c2", amountUsd: 1.0, createdAt: "2026-03-25T11:00:00Z" }),
|
||||
];
|
||||
const result = aggregateCostsByDay(costs, 14);
|
||||
const day25 = result.find((d) => d.date === "2026-03-25");
|
||||
expect(day25!.total).toBeCloseTo(1.0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("aggregateAgentWorkload", () => {
|
||||
it("returns entries sorted by run count descending for 3 agents", () => {
|
||||
const agents = [
|
||||
makeAgent({ id: "a1", name: "Alice" }),
|
||||
makeAgent({ id: "a2", name: "Bob" }),
|
||||
makeAgent({ id: "a3", name: "Charlie" }),
|
||||
];
|
||||
const runs = [
|
||||
makeRun({ id: "r1", agentId: "a1" }),
|
||||
makeRun({ id: "r2", agentId: "a1" }),
|
||||
makeRun({ id: "r3", agentId: "a1" }),
|
||||
makeRun({ id: "r4", agentId: "a2" }),
|
||||
makeRun({ id: "r5", agentId: "a2" }),
|
||||
makeRun({ id: "r6", agentId: "a3" }),
|
||||
];
|
||||
const result = aggregateAgentWorkload(runs, agents);
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0].name).toBe("Alice");
|
||||
expect(result[0].runs).toBe(3);
|
||||
expect(result[1].name).toBe("Bob");
|
||||
expect(result[1].runs).toBe(2);
|
||||
expect(result[2].name).toBe("Charlie");
|
||||
expect(result[2].runs).toBe(1);
|
||||
});
|
||||
|
||||
it("aggregates into top 8 + Others when 10 agents exist", () => {
|
||||
const agents: AgentRecord[] = [];
|
||||
const runs: RunRecord[] = [];
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
agents.push(makeAgent({ id: `a${i}`, name: `Agent${i}` }));
|
||||
// Each agent gets (11 - i) runs so agent1 has most
|
||||
for (let j = 0; j < 11 - i; j++) {
|
||||
runs.push(makeRun({ id: `r${i}-${j}`, agentId: `a${i}` }));
|
||||
}
|
||||
}
|
||||
const result = aggregateAgentWorkload(runs, agents);
|
||||
expect(result).toHaveLength(9); // 8 top + Others
|
||||
expect(result[result.length - 1].name).toBe("Others");
|
||||
// Others should aggregate agent9 (2 runs) + agent10 (1 run) = 3 runs
|
||||
expect(result[result.length - 1].runs).toBe(3);
|
||||
});
|
||||
|
||||
it("resolves agentId to agent name", () => {
|
||||
const agents = [makeAgent({ id: "a1", name: "DeepResearcher" })];
|
||||
const runs = [makeRun({ id: "r1", agentId: "a1" })];
|
||||
const result = aggregateAgentWorkload(runs, agents);
|
||||
expect(result[0].name).toBe("DeepResear...");
|
||||
});
|
||||
|
||||
it("truncates agent names longer than 10 characters with ellipsis", () => {
|
||||
const agents = [makeAgent({ id: "a1", name: "VeryLongAgentName" })];
|
||||
const runs = [makeRun({ id: "r1", agentId: "a1" })];
|
||||
const result = aggregateAgentWorkload(runs, agents);
|
||||
expect(result[0].name).toBe("VeryLongAg...");
|
||||
expect(result[0].name.length).toBe(13); // 10 chars + "..."
|
||||
});
|
||||
|
||||
it("does not truncate short names", () => {
|
||||
const agents = [makeAgent({ id: "a1", name: "ShortName" })];
|
||||
const runs = [makeRun({ id: "r1", agentId: "a1" })];
|
||||
const result = aggregateAgentWorkload(runs, agents);
|
||||
expect(result[0].name).toBe("ShortName");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatShortDate", () => {
|
||||
it('formats "2026-03-15" as "Mar 15"', () => {
|
||||
const result = formatShortDate("2026-03-15");
|
||||
expect(result).toBe("Mar 15");
|
||||
});
|
||||
|
||||
it('formats "2026-01-01" as "Jan 1"', () => {
|
||||
const result = formatShortDate("2026-01-01");
|
||||
expect(result).toBe("Jan 1");
|
||||
});
|
||||
|
||||
it('formats "2026-12-25" as "Dec 25"', () => {
|
||||
const result = formatShortDate("2026-12-25");
|
||||
expect(result).toBe("Dec 25");
|
||||
});
|
||||
});
|
||||
@@ -1,78 +0,0 @@
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { AppDatabase } from "../../src/main/database";
|
||||
|
||||
const FLAT_MODULE_KEYS = [
|
||||
"threads",
|
||||
"company",
|
||||
"agents",
|
||||
"tasks",
|
||||
"messages",
|
||||
"runs",
|
||||
"snap",
|
||||
"settings",
|
||||
"connectorsCrud",
|
||||
"workspacesMod",
|
||||
"approvalsMod",
|
||||
"social",
|
||||
"activityMod",
|
||||
"skills",
|
||||
"budget",
|
||||
"entityCrud",
|
||||
"documentsMod",
|
||||
"automationMod",
|
||||
"knowledgeMod",
|
||||
"wakeup",
|
||||
"backupMod",
|
||||
"sessionEntries",
|
||||
"inboxProjections",
|
||||
"documentLifecycleEvents",
|
||||
"credentialsTable",
|
||||
"modelCapabilities",
|
||||
];
|
||||
|
||||
describe("AppDatabase module groups", () => {
|
||||
it("exposes domain modules through grouped namespaces", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "agentcompany-db-groups-"));
|
||||
const db = new AppDatabase(dir);
|
||||
try {
|
||||
expect(Object.keys(db.core).sort()).toEqual([
|
||||
"activity",
|
||||
"agents",
|
||||
"approvals",
|
||||
"backup",
|
||||
"budget",
|
||||
"company",
|
||||
"connectors",
|
||||
"credentials",
|
||||
"documents",
|
||||
"entity",
|
||||
"knowledge",
|
||||
"messages",
|
||||
"modelCapabilities",
|
||||
"runs",
|
||||
"settings",
|
||||
"skills",
|
||||
"social",
|
||||
"tasks",
|
||||
"threads",
|
||||
"workspaces",
|
||||
]);
|
||||
expect(Object.keys(db.automation).sort()).toEqual(["rules", "wakeup"]);
|
||||
expect(Object.keys(db.projection).sort()).toEqual([
|
||||
"documentLifecycleEvents",
|
||||
"inbox",
|
||||
"sessionEntries",
|
||||
"snapshot",
|
||||
]);
|
||||
for (const key of FLAT_MODULE_KEYS) {
|
||||
expect(Object.prototype.hasOwnProperty.call(db, key)).toBe(false);
|
||||
}
|
||||
} finally {
|
||||
db.close();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,62 +0,0 @@
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
beforeAll(() => {
|
||||
process.env.AGENTCOMPANY_ALLOW_INSECURE_VAULT = "true";
|
||||
});
|
||||
|
||||
vi.mock("electron", () => ({
|
||||
safeStorage: {
|
||||
isEncryptionAvailable: () => false,
|
||||
getSelectedStorageBackend: () => "basic_text",
|
||||
encryptString: (value: string) => Buffer.from(value, "utf8"),
|
||||
decryptString: (value: Buffer) => value.toString("utf8"),
|
||||
},
|
||||
}));
|
||||
|
||||
import { AppDatabase } from "../../src/main/database";
|
||||
|
||||
describe("AppDatabase transactions", () => {
|
||||
let tempDir: string;
|
||||
let db: AppDatabase;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "agentcompany-db-tx-"));
|
||||
db = new AppDatabase(tempDir);
|
||||
db.init();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
db.close();
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("rolls back nested transactions to their savepoint", () => {
|
||||
const outerCompanyId = db.withTransaction(() => {
|
||||
const companyId = db.core.company.saveCompany({
|
||||
name: "Nested Transaction Co",
|
||||
description: "Outer transaction should survive inner rollback.",
|
||||
status: "active",
|
||||
});
|
||||
|
||||
expect(() => {
|
||||
db.withTransaction(() => {
|
||||
db.core.company.saveCompany({
|
||||
name: "Nested Transaction Rolled Back",
|
||||
description: "Inner transaction should roll back.",
|
||||
status: "active",
|
||||
});
|
||||
throw new Error("inner write failed");
|
||||
});
|
||||
}).toThrow("inner write failed");
|
||||
|
||||
return companyId;
|
||||
});
|
||||
|
||||
const companies = db.queryAll<{ id: string; name: string }>("select id, name from companies order by created_at asc");
|
||||
expect(companies.map((company) => company.id)).toContain(outerCompanyId);
|
||||
expect(companies.some((company) => company.name === "Nested Transaction Rolled Back")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,764 +0,0 @@
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { approvalTypeEnum } from "../../src/shared/types";
|
||||
|
||||
beforeAll(() => {
|
||||
process.env.AGENTCOMPANY_ALLOW_INSECURE_VAULT = "true";
|
||||
});
|
||||
|
||||
vi.mock("electron", () => ({
|
||||
safeStorage: {
|
||||
isEncryptionAvailable: () => false,
|
||||
getSelectedStorageBackend: () => "basic_text",
|
||||
encryptString: (value: string) => Buffer.from(value, "utf8"),
|
||||
decryptString: (value: Buffer) => value.toString("utf8"),
|
||||
},
|
||||
}));
|
||||
|
||||
import { AppDatabase } from "../../src/main/database";
|
||||
import { installBaselineSchema } from "../../src/main/database/migrations/baseline-v56";
|
||||
import { markBaselineSchema } from "../../src/main/database/migrations/legacy-to-v56";
|
||||
|
||||
describe("AppDatabase", () => {
|
||||
let tempDir: string;
|
||||
let db: AppDatabase;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "agentcompany-db-"));
|
||||
db = new AppDatabase(tempDir);
|
||||
db.init();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
db.close();
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("keeps task runtime ownership in task_leases", () => {
|
||||
const leaseTables = db.queryAll<{ name: string }>(
|
||||
"select name from sqlite_master where type = 'table' and name = 'task_leases'",
|
||||
);
|
||||
const removedTaskColumns = new Set([
|
||||
"phase",
|
||||
"active_run_id",
|
||||
"checkout_run_id",
|
||||
"execution_locked_at",
|
||||
"active_session_id",
|
||||
]);
|
||||
const taskColumns = db.queryAll<{ name: string }>("pragma table_info(tasks)")
|
||||
.filter((column) => removedTaskColumns.has(column.name));
|
||||
const phaseTriggers = db.queryAll<{ name: string }>(
|
||||
"select name from sqlite_master where type = 'trigger' and name like 'trg_tasks_phase_from_status_%'",
|
||||
);
|
||||
|
||||
expect(leaseTables).toEqual([{ name: "task_leases" }]);
|
||||
expect(taskColumns).toEqual([]);
|
||||
expect(phaseTriggers).toEqual([]);
|
||||
});
|
||||
|
||||
it("persists company-scoped entities and resolves secret env bindings", () => {
|
||||
const companyId = db.core.company.saveCompany({
|
||||
name: "Northline Systems",
|
||||
description: "Operator company",
|
||||
status: "active",
|
||||
});
|
||||
const workspaceId = db.core.workspaces.saveWorkspace({
|
||||
companyId,
|
||||
name: "Primary Workspace",
|
||||
localPath: "/tmp/northline",
|
||||
repoUrl: "https://github.com/example/northline",
|
||||
repoRef: "main",
|
||||
isPrimary: true,
|
||||
});
|
||||
const agentId = db.core.agents.saveAgent({
|
||||
companyId,
|
||||
name: "Chief Operator",
|
||||
role: "CEO",
|
||||
title: "Lead operator",
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: "gpt-5",
|
||||
capabilities: "Directs execution",
|
||||
budgetMonthlyUsd: 250,
|
||||
});
|
||||
const goalId = db.core.entity.saveGoal({
|
||||
companyId,
|
||||
title: "Ship the desktop product",
|
||||
description: "Stabilize the local operator console.",
|
||||
ownerAgentId: agentId,
|
||||
status: "active",
|
||||
});
|
||||
const projectId = db.core.entity.saveProject({
|
||||
companyId,
|
||||
goalId,
|
||||
name: "Desktop Rebuild",
|
||||
description: "Main delivery lane",
|
||||
leadAgentId: agentId,
|
||||
status: "in_progress",
|
||||
targetDate: null,
|
||||
});
|
||||
const taskId = db.core.tasks.saveTask({
|
||||
companyId,
|
||||
projectId,
|
||||
goalId,
|
||||
title: "Validate connector health",
|
||||
description: "Run a local connector check and capture diagnostics.",
|
||||
assigneeAgentId: agentId,
|
||||
workspaceId,
|
||||
priority: "high",
|
||||
status: "todo",
|
||||
});
|
||||
|
||||
db.core.entity.saveSecret({
|
||||
companyId,
|
||||
name: "vault-openai",
|
||||
description: "OpenAI API token",
|
||||
value: "sk-test-secret",
|
||||
});
|
||||
db.core.connectors.saveConnector({
|
||||
id: "openai-codex",
|
||||
model: "gpt-5",
|
||||
envBindingText: "OPENAI_API_KEY=vault-openai",
|
||||
notes: "Primary coding connector",
|
||||
});
|
||||
|
||||
const snapshot = db.projection.snapshot.listSnapshot();
|
||||
|
||||
expect(snapshot.currentCompanyId).toBe(companyId);
|
||||
expect(snapshot.workspaces[0]?.id).toBe(workspaceId);
|
||||
expect(snapshot.agents[0]?.id).toBe(agentId);
|
||||
expect(snapshot.goals[0]?.id).toBe(goalId);
|
||||
expect(snapshot.projects[0]?.id).toBe(projectId);
|
||||
expect(snapshot.tasks[0]?.id).toBe(taskId);
|
||||
expect(db.core.entity.resolveSecretEnv("OPENAI_API_KEY=vault-openai", companyId)).toEqual({
|
||||
OPENAI_API_KEY: "sk-test-secret",
|
||||
});
|
||||
});
|
||||
|
||||
it("backs up and restores the profile", async () => {
|
||||
const companyId = db.core.company.saveCompany({
|
||||
name: "Backup Test Co",
|
||||
description: "Backup validation company",
|
||||
status: "active",
|
||||
});
|
||||
const backupDir = await mkdtemp(join(tmpdir(), "agentcompany-backup-"));
|
||||
|
||||
await db.core.backup.exportBackup(backupDir);
|
||||
db.core.company.saveCompany({
|
||||
id: companyId,
|
||||
name: "Mutated Company",
|
||||
description: "Changed after backup",
|
||||
status: "paused",
|
||||
});
|
||||
|
||||
await db.core.backup.restoreFromBackup(backupDir);
|
||||
|
||||
const snapshot = db.projection.snapshot.listSnapshot();
|
||||
expect(snapshot.companies[0]?.name).toBe("Backup Test Co");
|
||||
|
||||
await rm(backupDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("scopes secret resolution to the owning company", () => {
|
||||
const alphaCompanyId = db.core.company.saveCompany({
|
||||
name: "Alpha Company",
|
||||
description: "Alpha",
|
||||
status: "active",
|
||||
});
|
||||
const betaCompanyId = db.core.company.saveCompany({
|
||||
name: "Beta Company",
|
||||
description: "Beta",
|
||||
status: "active",
|
||||
});
|
||||
|
||||
db.core.entity.saveSecret({
|
||||
companyId: alphaCompanyId,
|
||||
name: "shared-secret-name",
|
||||
description: "Alpha secret",
|
||||
value: "alpha-secret",
|
||||
});
|
||||
|
||||
expect(db.core.entity.resolveSecretEnv("API_KEY=shared-secret-name", alphaCompanyId)).toEqual({
|
||||
API_KEY: "alpha-secret",
|
||||
});
|
||||
// Beta company has no secret named "shared-secret-name", so resolveSecretEnv
|
||||
// falls back to using the raw value literally (allows direct env bindings).
|
||||
expect(db.core.entity.resolveSecretEnv("API_KEY=shared-secret-name", betaCompanyId)).toEqual({
|
||||
API_KEY: "shared-secret-name",
|
||||
});
|
||||
});
|
||||
|
||||
it("atomically checks out unclaimed work via CAS with run ID", () => {
|
||||
const companyId = db.core.company.saveCompany({
|
||||
name: "Checkout Company",
|
||||
description: "Atomic checkout validation",
|
||||
status: "active",
|
||||
});
|
||||
const workspaceId = db.core.workspaces.saveWorkspace({
|
||||
companyId,
|
||||
name: "Execution Workspace",
|
||||
localPath: "/tmp/checkout",
|
||||
repoUrl: "https://github.com/example/checkout",
|
||||
repoRef: "main",
|
||||
isPrimary: true,
|
||||
});
|
||||
const leadId = db.core.agents.saveAgent({
|
||||
companyId,
|
||||
name: "Lead",
|
||||
role: "CEO",
|
||||
title: "Lead",
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Leads",
|
||||
budgetMonthlyUsd: 100,
|
||||
});
|
||||
db.core.connectors.saveConnector({
|
||||
id: "openai-codex",
|
||||
model: "gpt-5",
|
||||
envBindingText: "",
|
||||
notes: "Test connector",
|
||||
});
|
||||
const taskId = db.core.tasks.saveTask({
|
||||
companyId,
|
||||
title: "Claim me",
|
||||
description: "First agent wins the checkout.",
|
||||
assigneeAgentId: null,
|
||||
workspaceId,
|
||||
priority: "medium",
|
||||
status: "todo",
|
||||
});
|
||||
|
||||
const { id: runId1 } = db.core.runs.createRun({
|
||||
companyId,
|
||||
taskId,
|
||||
agentId: leadId,
|
||||
workspaceId,
|
||||
connectorId: "openai-codex",
|
||||
});
|
||||
const { id: runId2 } = db.core.runs.createRun({
|
||||
companyId,
|
||||
taskId,
|
||||
agentId: leadId,
|
||||
workspaceId,
|
||||
connectorId: "openai-codex",
|
||||
});
|
||||
|
||||
expect(db.core.tasks.checkoutTask(taskId, runId1)).toBe(true);
|
||||
expect(db.core.tasks.checkoutTask(taskId, runId2)).toBe(false);
|
||||
|
||||
const task = db.core.entity.getTask(taskId);
|
||||
expect(task.status).toBe("in_progress");
|
||||
});
|
||||
|
||||
it("auto-pauses an agent when a run exhausts the monthly budget", () => {
|
||||
const companyId = db.core.company.saveCompany({
|
||||
name: "Budget Company",
|
||||
description: "Budget hard stop validation",
|
||||
status: "active",
|
||||
});
|
||||
const workspaceId = db.core.workspaces.saveWorkspace({
|
||||
companyId,
|
||||
name: "Budget Workspace",
|
||||
localPath: "/tmp/budget",
|
||||
repoUrl: "https://github.com/example/budget",
|
||||
repoRef: "main",
|
||||
isPrimary: true,
|
||||
});
|
||||
const agentId = db.core.agents.saveAgent({
|
||||
companyId,
|
||||
name: "Budget Owner",
|
||||
role: "CTO",
|
||||
title: "Budget Owner",
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Ships work",
|
||||
budgetMonthlyUsd: 10,
|
||||
});
|
||||
const taskId = db.core.tasks.saveTask({
|
||||
companyId,
|
||||
title: "Spend budget",
|
||||
description: "One run should exhaust the allowance.",
|
||||
assigneeAgentId: agentId,
|
||||
workspaceId,
|
||||
priority: "high",
|
||||
status: "todo",
|
||||
});
|
||||
|
||||
const run = db.core.runs.createRun({
|
||||
companyId,
|
||||
taskId,
|
||||
agentId,
|
||||
workspaceId,
|
||||
connectorId: "openai-codex",
|
||||
});
|
||||
|
||||
// Simulate a per-message cost entry (written by CostWriter in prod).
|
||||
db.core.runs.recordCostEntry({
|
||||
runId: run.id,
|
||||
companyId,
|
||||
agentId,
|
||||
taskId,
|
||||
connectorId: "openai-codex",
|
||||
amountUsd: 12,
|
||||
unattributed: false,
|
||||
});
|
||||
|
||||
const outcome = db.core.runs.finishRun({
|
||||
runId: run.id,
|
||||
status: "succeeded",
|
||||
summary: "Budget consumed",
|
||||
errorMessage: null,
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
model: "gpt-5",
|
||||
unattributedCost: false,
|
||||
});
|
||||
|
||||
expect(outcome.budgetHardStopped).toBe(true);
|
||||
expect(db.core.entity.getAgent(agentId).status).toBe("paused");
|
||||
expect(db.core.entity.getAgent(agentId).spentMonthlyUsd).toBe(12);
|
||||
});
|
||||
|
||||
it("persists explicit run usage status", () => {
|
||||
const companyId = db.core.company.saveCompany({
|
||||
name: "Usage Status Company",
|
||||
description: "Tracks run usage status",
|
||||
status: "active",
|
||||
});
|
||||
const workspaceId = db.core.workspaces.saveWorkspace({
|
||||
companyId,
|
||||
name: "Usage Workspace",
|
||||
localPath: "/tmp/usage-status",
|
||||
repoUrl: "",
|
||||
repoRef: "",
|
||||
isPrimary: true,
|
||||
});
|
||||
const agentId = db.core.agents.saveAgent({
|
||||
companyId,
|
||||
name: "Usage Agent",
|
||||
role: "Engineer",
|
||||
title: "Engineer",
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: "gpt-5",
|
||||
capabilities: "Run tasks",
|
||||
budgetMonthlyUsd: 100,
|
||||
});
|
||||
const taskId = db.core.tasks.saveTask({
|
||||
companyId,
|
||||
title: "Usage task",
|
||||
description: "Exercise explicit usage status.",
|
||||
assigneeAgentId: agentId,
|
||||
workspaceId,
|
||||
priority: "medium",
|
||||
status: "in_progress",
|
||||
});
|
||||
const run = db.core.runs.createRun({ companyId, taskId, agentId, workspaceId, connectorId: "openai-codex" });
|
||||
|
||||
db.core.runs.finishRun({
|
||||
runId: run.id,
|
||||
status: "failed",
|
||||
summary: null,
|
||||
errorMessage: "stream setup failed",
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
model: "gpt-5",
|
||||
unattributedCost: true,
|
||||
usageStatus: "error_stop",
|
||||
});
|
||||
|
||||
expect(db.core.runs.getRun(run.id).usageStatus).toBe("error_stop");
|
||||
expect(db.projection.snapshot.listSnapshot().runs.find((record) => record.id === run.id)?.usageStatus).toBe("error_stop");
|
||||
});
|
||||
|
||||
it("activates pending hires when the corresponding approval is granted", () => {
|
||||
const companyId = db.core.company.saveCompany({
|
||||
name: "Hiring Company",
|
||||
description: "Approval-driven hiring",
|
||||
status: "active",
|
||||
});
|
||||
const managerId = db.core.agents.saveAgent({
|
||||
companyId,
|
||||
name: "Manager",
|
||||
role: "CEO",
|
||||
title: "Manager",
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "openai-codex",
|
||||
workspaceId: null,
|
||||
model: null,
|
||||
capabilities: "Manages",
|
||||
budgetMonthlyUsd: 100,
|
||||
});
|
||||
const pendingHireId = db.core.agents.saveAgent({
|
||||
companyId,
|
||||
name: "Pending Hire",
|
||||
role: "IC",
|
||||
title: "Pending Hire",
|
||||
status: "pending_approval",
|
||||
reportsTo: managerId,
|
||||
connectorId: "openai-codex",
|
||||
workspaceId: null,
|
||||
model: null,
|
||||
capabilities: "Will execute after approval",
|
||||
budgetMonthlyUsd: 50,
|
||||
});
|
||||
|
||||
const approvalId = db.core.approvals.requestApproval({
|
||||
companyId,
|
||||
relatedTaskId: null,
|
||||
requestedByAgentId: managerId,
|
||||
type: "hire_agent",
|
||||
payloadSummary: "Approve pending hire",
|
||||
impactSummary: "Activate the pending hire so they can begin receiving assignments.",
|
||||
payloadJson: JSON.stringify({ agentId: pendingHireId }),
|
||||
});
|
||||
|
||||
db.core.approvals.decideApproval({
|
||||
approvalId,
|
||||
state: "approved",
|
||||
decisionNote: "Approved",
|
||||
});
|
||||
|
||||
expect(db.core.entity.getAgent(pendingHireId).status).toBe("idle");
|
||||
});
|
||||
|
||||
it("detects pending-approval agents via isAgentPendingApproval", () => {
|
||||
const companyId = db.core.company.saveCompany({
|
||||
name: "Gate Test Co",
|
||||
description: "Approval gate validation",
|
||||
status: "active",
|
||||
});
|
||||
const activeAgentId = db.core.agents.saveAgent({
|
||||
companyId,
|
||||
name: "Active Agent",
|
||||
role: "CTO",
|
||||
title: "Active",
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "openai-codex",
|
||||
workspaceId: null,
|
||||
model: null,
|
||||
capabilities: "Ships",
|
||||
budgetMonthlyUsd: 100,
|
||||
});
|
||||
const pendingAgentId = db.core.agents.saveAgent({
|
||||
companyId,
|
||||
name: "Pending Agent",
|
||||
role: "IC",
|
||||
title: "Pending",
|
||||
status: "pending_approval",
|
||||
reportsTo: activeAgentId,
|
||||
connectorId: "openai-codex",
|
||||
workspaceId: null,
|
||||
model: null,
|
||||
capabilities: "Waiting",
|
||||
budgetMonthlyUsd: 50,
|
||||
});
|
||||
|
||||
expect(db.core.agents.isAgentPendingApproval(activeAgentId)).toBe(false);
|
||||
expect(db.core.agents.isAgentPendingApproval(pendingAgentId)).toBe(true);
|
||||
});
|
||||
|
||||
it("installs the v56 baseline and v57 task leases once on a fresh profile", () => {
|
||||
const migrations = db.queryAll<{ version: number; name: string }>(
|
||||
"select version, name from schema_migrations order by version",
|
||||
);
|
||||
expect(migrations).toEqual([
|
||||
{ version: 56, name: "baseline_v56" },
|
||||
{ version: 57, name: "task_leases_runtime_ownership" },
|
||||
{ version: 58, name: "approval_policy_table" },
|
||||
{ version: 59, name: "social_company_scope" },
|
||||
{ version: 60, name: "filesystem_task_workspaces" },
|
||||
]);
|
||||
|
||||
db.close();
|
||||
db = new AppDatabase(tempDir);
|
||||
db.init();
|
||||
|
||||
const migrationsAfterReinit = db.queryAll<{ version: number; name: string }>(
|
||||
"select version, name from schema_migrations order by version",
|
||||
);
|
||||
expect(migrationsAfterReinit).toEqual([
|
||||
{ version: 56, name: "baseline_v56" },
|
||||
{ version: 57, name: "task_leases_runtime_ownership" },
|
||||
{ version: 58, name: "approval_policy_table" },
|
||||
{ version: 59, name: "social_company_scope" },
|
||||
{ version: 60, name: "filesystem_task_workspaces" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("migrates v56 task ownership columns into task_leases", async () => {
|
||||
const legacyDir = await mkdtemp(join(tmpdir(), "agentcompany-v56-lease-"));
|
||||
const rawDb = new DatabaseSync(join(legacyDir, "profile.sqlite"));
|
||||
const ts = "2026-04-24T00:00:00.000Z";
|
||||
try {
|
||||
installBaselineSchema(rawDb);
|
||||
markBaselineSchema(rawDb);
|
||||
rawDb
|
||||
.prepare("insert into companies (id, name, description, status, created_at, updated_at) values (?, ?, ?, ?, ?, ?)")
|
||||
.run("company-1", "Lease Co", "", "active", ts, ts);
|
||||
rawDb
|
||||
.prepare("insert into agents (id, company_id, name, role, title, status, reports_to, connector_id, workspace_id, capabilities, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
|
||||
.run("agent-1", "company-1", "Worker", "IC", "Worker", "active", null, "openai-codex", null, "Executes", ts, ts);
|
||||
rawDb
|
||||
.prepare("insert into tasks (id, company_id, title, description, assignee_agent_id, status, priority, active_run_id, checkout_run_id, execution_locked_at, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
|
||||
.run("task-1", "company-1", "Lease task", "", "agent-1", "in_progress", "medium", "run-1", "run-1", ts, ts, ts);
|
||||
rawDb
|
||||
.prepare("insert into runs (id, company_id, task_id, agent_id, connector_id, status, summary, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?)")
|
||||
.run("run-1", "company-1", "task-1", "agent-1", "openai-codex", "queued", "", ts, ts);
|
||||
} finally {
|
||||
rawDb.close();
|
||||
}
|
||||
|
||||
const legacyAppDb = new AppDatabase(legacyDir);
|
||||
try {
|
||||
legacyAppDb.init();
|
||||
expect(
|
||||
legacyAppDb.queryAll<{ taskId: string; runId: string; agentId: string }>(
|
||||
"select task_id as taskId, run_id as runId, agent_id as agentId from task_leases",
|
||||
),
|
||||
).toEqual([{ taskId: "task-1", runId: "run-1", agentId: "agent-1" }]);
|
||||
const columns = legacyAppDb.queryAll<{ name: string }>("pragma table_info(tasks)").map((column) => column.name);
|
||||
expect(columns).not.toContain("active_run_id");
|
||||
expect(columns).not.toContain("checkout_run_id");
|
||||
expect(columns).not.toContain("execution_locked_at");
|
||||
} finally {
|
||||
legacyAppDb.close();
|
||||
await rm(legacyDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("seeds approval policies for every approval type", () => {
|
||||
const rows = db.queryAll<{
|
||||
approvalType: string;
|
||||
scopeType: string;
|
||||
blocksExecution: number;
|
||||
requiredMode: string;
|
||||
autoApprovable: number;
|
||||
}>(
|
||||
"select approval_type as approvalType, scope_type as scopeType, blocks_execution as blocksExecution, required_mode as requiredMode, auto_approvable as autoApprovable from approval_policies order by approval_type",
|
||||
);
|
||||
expect(rows.map((row) => row.approvalType).sort()).toEqual([...approvalTypeEnum.options].sort());
|
||||
expect(rows.find((row) => row.approvalType === "plan_review")).toMatchObject({
|
||||
scopeType: "requester_or_task",
|
||||
blocksExecution: 1,
|
||||
requiredMode: "ai_cofounder",
|
||||
autoApprovable: 0,
|
||||
});
|
||||
expect(rows.find((row) => row.approvalType === "dangerous_command")).toMatchObject({
|
||||
scopeType: "requester_or_task",
|
||||
blocksExecution: 1,
|
||||
requiredMode: "any",
|
||||
});
|
||||
expect(rows.find((row) => row.approvalType === "hire_agent")).toMatchObject({
|
||||
scopeType: "related_agent",
|
||||
blocksExecution: 1,
|
||||
requiredMode: "any",
|
||||
autoApprovable: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("acquires and releases workspace locks", () => {
|
||||
const companyId = db.core.company.saveCompany({
|
||||
name: "Lock Test Co",
|
||||
description: "Workspace lock test",
|
||||
status: "active",
|
||||
});
|
||||
const wsId = db.core.workspaces.saveWorkspace({
|
||||
companyId,
|
||||
name: "Lockable WS",
|
||||
localPath: "/tmp/lock-test",
|
||||
repoUrl: "",
|
||||
repoRef: "",
|
||||
isPrimary: true,
|
||||
});
|
||||
|
||||
// Acquire lock for run-1
|
||||
expect(db.core.workspaces.acquireWorkspaceLock(wsId, "run-1")).toBe(true);
|
||||
let ws = db.core.workspaces.getWorkspace(wsId);
|
||||
expect(ws?.state).toBe("busy");
|
||||
|
||||
// Same run can re-acquire (idempotent)
|
||||
expect(db.core.workspaces.acquireWorkspaceLock(wsId, "run-1")).toBe(true);
|
||||
|
||||
// Different run cannot acquire while run-1 holds the lock
|
||||
expect(db.core.workspaces.acquireWorkspaceLock(wsId, "run-2")).toBe(false);
|
||||
|
||||
// Release lock
|
||||
db.core.workspaces.releaseWorkspaceLock(wsId, "run-1");
|
||||
ws = db.core.workspaces.getWorkspace(wsId);
|
||||
expect(ws?.state).toBe("ready");
|
||||
|
||||
// Now run-2 can acquire
|
||||
expect(db.core.workspaces.acquireWorkspaceLock(wsId, "run-2")).toBe(true);
|
||||
db.core.workspaces.releaseWorkspaceLock(wsId, "run-2");
|
||||
});
|
||||
|
||||
it("releases all workspace locks on crash recovery", () => {
|
||||
const companyId = db.core.company.saveCompany({
|
||||
name: "Recovery Co",
|
||||
description: "Lock recovery test",
|
||||
status: "active",
|
||||
});
|
||||
const wsId = db.core.workspaces.saveWorkspace({
|
||||
companyId,
|
||||
name: "Recovery WS",
|
||||
localPath: "/tmp/recovery-test",
|
||||
repoUrl: "",
|
||||
repoRef: "",
|
||||
isPrimary: true,
|
||||
});
|
||||
|
||||
db.core.workspaces.acquireWorkspaceLock(wsId, "stale-run");
|
||||
expect(db.core.workspaces.getWorkspace(wsId)?.state).toBe("busy");
|
||||
|
||||
db.core.workspaces.releaseAllWorkspaceLocks();
|
||||
expect(db.core.workspaces.getWorkspace(wsId)?.state).toBe("ready");
|
||||
});
|
||||
|
||||
it("purges expired run logs based on retention setting", async () => {
|
||||
const companyId = db.core.company.saveCompany({
|
||||
name: "Purge Co",
|
||||
description: "Log purge test",
|
||||
status: "active",
|
||||
});
|
||||
const wsId = db.core.workspaces.saveWorkspace({
|
||||
companyId,
|
||||
name: "PurgeWS",
|
||||
localPath: "/tmp/purge",
|
||||
repoUrl: "",
|
||||
repoRef: "",
|
||||
isPrimary: true,
|
||||
});
|
||||
const agentId = db.core.agents.saveAgent({
|
||||
companyId,
|
||||
name: "Purge Agent",
|
||||
role: "IC",
|
||||
title: "Test",
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "openai-codex",
|
||||
workspaceId: wsId,
|
||||
model: null,
|
||||
capabilities: "test",
|
||||
budgetMonthlyUsd: 100,
|
||||
});
|
||||
const taskId = db.core.tasks.saveTask({
|
||||
companyId,
|
||||
title: "Purge task",
|
||||
description: "test",
|
||||
assigneeAgentId: agentId,
|
||||
workspaceId: wsId,
|
||||
priority: "medium",
|
||||
status: "todo",
|
||||
});
|
||||
|
||||
const run = db.core.runs.createRun({ companyId, taskId, agentId, workspaceId: wsId, connectorId: "openai-codex" });
|
||||
await db.core.runs.appendRunLog(run.id, "stdout", "test log line\n");
|
||||
|
||||
// Finish the run with a very old timestamp
|
||||
db.core.runs.finishRun({
|
||||
runId: run.id,
|
||||
status: "succeeded",
|
||||
summary: "done",
|
||||
errorMessage: null,
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
model: null,
|
||||
unattributedCost: true,
|
||||
});
|
||||
|
||||
// Log should exist before purge
|
||||
expect(db.core.runs.readRunLog(run.id)).toContain("test log line");
|
||||
|
||||
// Set retention to 1 day and manually backdate the run's finished_at
|
||||
db.core.settings.updateSettings({ logRetentionDays: 1 });
|
||||
// Backdate finished_at to 10 days ago
|
||||
const tenDaysAgo = new Date(Date.now() - 10 * 86_400_000).toISOString();
|
||||
// Direct SQL update for test only
|
||||
(db as unknown as { db: { prepare: (sql: string) => { run: (...args: unknown[]) => void } } }).db
|
||||
.prepare("update runs set finished_at = ? where id = ?")
|
||||
.run(tenDaysAgo, run.id);
|
||||
|
||||
const purged = db.core.settings.purgeExpiredRunLogs();
|
||||
expect(purged).toBe(1);
|
||||
|
||||
// Log should be gone after purge
|
||||
expect(db.core.runs.readRunLog(run.id)).toBe("");
|
||||
});
|
||||
|
||||
it("populates related_agent_id on hire approvals", () => {
|
||||
const companyId = db.core.company.saveCompany({
|
||||
name: "Agent Approval Co",
|
||||
description: "test",
|
||||
status: "active",
|
||||
});
|
||||
const managerId = db.core.agents.saveAgent({
|
||||
companyId,
|
||||
name: "Manager",
|
||||
role: "CEO",
|
||||
title: "CEO",
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "openai-codex",
|
||||
workspaceId: null,
|
||||
model: null,
|
||||
capabilities: "manage",
|
||||
budgetMonthlyUsd: 100,
|
||||
});
|
||||
const hireId = db.core.agents.saveAgent({
|
||||
companyId,
|
||||
name: "New Hire",
|
||||
role: "IC",
|
||||
title: "IC",
|
||||
status: "pending_approval",
|
||||
reportsTo: managerId,
|
||||
connectorId: "openai-codex",
|
||||
workspaceId: null,
|
||||
model: null,
|
||||
capabilities: "code",
|
||||
budgetMonthlyUsd: 50,
|
||||
});
|
||||
|
||||
const approvalId = db.core.approvals.requestApproval({
|
||||
companyId,
|
||||
requestedByAgentId: managerId,
|
||||
type: "hire_agent",
|
||||
payloadSummary: "Hire New Hire",
|
||||
impactSummary: "test",
|
||||
payloadJson: JSON.stringify({ agentId: hireId }),
|
||||
});
|
||||
|
||||
const approval = db.core.approvals.getApprovalRecord(approvalId);
|
||||
// related_agent_id should be populated from payloadJson extraction
|
||||
expect(approval.relatedAgentId).toBe(hireId);
|
||||
|
||||
// Explicit relatedAgentId param should also work
|
||||
const approvalId2 = db.core.approvals.requestApproval({
|
||||
companyId,
|
||||
requestedByAgentId: managerId,
|
||||
relatedAgentId: hireId,
|
||||
type: "hire_agent",
|
||||
payloadSummary: "Hire New Hire v2",
|
||||
impactSummary: "test",
|
||||
});
|
||||
|
||||
const approval2 = db.core.approvals.getApprovalRecord(approvalId2);
|
||||
expect(approval2.relatedAgentId).toBe(hireId);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -1,43 +0,0 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
incrementDeprecationCall,
|
||||
readDeprecationCounters,
|
||||
resetDeprecationCounters,
|
||||
} from "../../src/main/api-server/deprecation-counter";
|
||||
|
||||
/**
|
||||
* C4-followup #3: the in-process deprecation counter backs the
|
||||
* "has legacy traffic hit zero?" decision. Keep these tests tight —
|
||||
* the module has one job.
|
||||
*/
|
||||
describe("deprecation counter", () => {
|
||||
beforeEach(() => {
|
||||
resetDeprecationCounters();
|
||||
});
|
||||
|
||||
it("increments and reads back the count for a single route", () => {
|
||||
incrementDeprecationCall("POST /legacy/hire");
|
||||
incrementDeprecationCall("POST /legacy/hire");
|
||||
expect(readDeprecationCounters()).toEqual({ "POST /legacy/hire": 2 });
|
||||
});
|
||||
|
||||
it("tracks multiple route labels independently", () => {
|
||||
incrementDeprecationCall("POST /legacy/hire");
|
||||
incrementDeprecationCall("POST /legacy/approvals");
|
||||
incrementDeprecationCall("POST /legacy/hire");
|
||||
expect(readDeprecationCounters()).toEqual({
|
||||
"POST /legacy/hire": 2,
|
||||
"POST /legacy/approvals": 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("resets all counters back to empty", () => {
|
||||
incrementDeprecationCall("POST /legacy/hire");
|
||||
resetDeprecationCounters();
|
||||
expect(readDeprecationCounters()).toEqual({});
|
||||
});
|
||||
|
||||
it("throws on an empty routeLabel instead of silently recording", () => {
|
||||
expect(() => incrementDeprecationCall("")).toThrow(/non-empty routeLabel/);
|
||||
});
|
||||
});
|
||||
@@ -1,108 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type { DesktopResult } from "@shared/contracts";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createAppErrorNotice,
|
||||
describeDesktopError,
|
||||
DesktopError,
|
||||
formatDesktopResultError,
|
||||
formatDesktopResultNotice,
|
||||
normalizeAppErrorNotice,
|
||||
unwrap,
|
||||
} from "../../src/renderer/lib/desktop";
|
||||
|
||||
describe("renderer desktop error notices", () => {
|
||||
it("keeps DesktopResult error codes when unwrap throws", () => {
|
||||
const result: DesktopResult<string> = {
|
||||
ok: false,
|
||||
error: {
|
||||
code: "CONNECTOR_MODEL_NOT_ALLOWED",
|
||||
message: "The assigned agent model is disabled.",
|
||||
},
|
||||
};
|
||||
|
||||
expect(() => unwrap(result)).toThrow(DesktopError);
|
||||
|
||||
try {
|
||||
unwrap(result);
|
||||
throw new Error("unwrap should have thrown");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(DesktopError);
|
||||
expect((error as DesktopError).code).toBe("CONNECTOR_MODEL_NOT_ALLOWED");
|
||||
expect((error as DesktopError).message).toBe("The assigned agent model is disabled.");
|
||||
}
|
||||
});
|
||||
|
||||
it("turns IPC error envelopes into actionable notices", () => {
|
||||
const notice = formatDesktopResultError({
|
||||
code: "CONNECTOR_MODEL_NOT_ALLOWED",
|
||||
message: "The assigned agent model is disabled or invalid for its connector.",
|
||||
});
|
||||
|
||||
expect(notice).toEqual({
|
||||
code: "CONNECTOR_MODEL_NOT_ALLOWED",
|
||||
message: "The assigned agent model is disabled or invalid for its connector.",
|
||||
actionHint: "Open Connectors, enable an allowed model, then retry the run.",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps checkout races actionable for retry", () => {
|
||||
const notice = formatDesktopResultNotice({
|
||||
code: "CHECKOUT_RACE_LOST",
|
||||
message: "Another run claimed this task first.",
|
||||
});
|
||||
|
||||
expect(notice).toEqual({
|
||||
code: "CHECKOUT_RACE_LOST",
|
||||
message: "Another run claimed this task first.",
|
||||
actionHint: "Refresh tasks, review the current assignee, then try again.",
|
||||
});
|
||||
});
|
||||
|
||||
it("suppresses shutdown noise from DesktopResult notices", () => {
|
||||
expect(formatDesktopResultNotice({
|
||||
code: "APP_SHUTTING_DOWN",
|
||||
message: "The app is shutting down.",
|
||||
})).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps App cancelRun on the DesktopResult envelope path", () => {
|
||||
const source = readFileSync(join(process.cwd(), "src/renderer/App.tsx"), "utf8");
|
||||
|
||||
expect(source).not.toContain("unwrap(await window.agentCompany.cancelRun");
|
||||
expect(source).toContain("formatDesktopResultNotice(result.error)");
|
||||
});
|
||||
|
||||
it("describes thrown DesktopError instances with their original code", () => {
|
||||
const notice = describeDesktopError(new DesktopError("TASK_UNASSIGNED", "Assign an agent before starting a run."));
|
||||
|
||||
expect(notice).toEqual({
|
||||
code: "TASK_UNASSIGNED",
|
||||
message: "Assign an agent before starting a run.",
|
||||
actionHint: "Assign an agent to the task, then start the run.",
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes local string errors into action-required notices", () => {
|
||||
expect(normalizeAppErrorNotice("Select a company before starting a run.")).toEqual({
|
||||
code: "ACTION_REQUIRED",
|
||||
message: "Select a company before starting a run.",
|
||||
actionHint: "Complete the required selection, then retry the action.",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves explicit local action hints", () => {
|
||||
expect(
|
||||
createAppErrorNotice(
|
||||
"Select a company before starting a run.",
|
||||
"COMPANY_REQUIRED",
|
||||
"Select a company in the sidebar, then start the run.",
|
||||
),
|
||||
).toEqual({
|
||||
code: "COMPANY_REQUIRED",
|
||||
message: "Select a company before starting a run.",
|
||||
actionHint: "Select a company in the sidebar, then start the run.",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,752 @@
|
||||
import { mkdirSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { DatabaseConnection } from "../../src/main/database/connection";
|
||||
import { FileService } from "../../src/main/services/file-service";
|
||||
import { OrganizationService } from "../../src/main/services/organization-service";
|
||||
import { SnapshotService } from "../../src/main/services/snapshot-service";
|
||||
import { SkillService } from "../../src/main/services/skill-service";
|
||||
import { ChatService } from "../../src/main/services/chat-service";
|
||||
import { MigrationService } from "../../src/main/services/migration-service";
|
||||
import { OnboardingService } from "../../src/main/services/onboarding-service";
|
||||
import { RISK_LEVEL, TASK_STATUS } from "../../src/shared/domain";
|
||||
import { createId } from "../../src/shared/ids";
|
||||
|
||||
function createConnection(): DatabaseConnection {
|
||||
return new DatabaseConnection(join(tmpdir(), `agentcompany-new-${randomUUID()}.db`));
|
||||
}
|
||||
|
||||
describe("domain services", () => {
|
||||
test("records every applied schema migration version", () => {
|
||||
const connection = createConnection();
|
||||
|
||||
const rows = connection.db.prepare("select version from schema_migrations order by version asc").all() as Array<{ version: number }>;
|
||||
expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45]);
|
||||
connection.close();
|
||||
});
|
||||
|
||||
test("creates an organization with providers, chat spaces, employee, and audit", () => {
|
||||
const connection = createConnection();
|
||||
const organization = new OrganizationService(connection.db);
|
||||
const snapshot = new SnapshotService(connection.db);
|
||||
|
||||
connection.transaction(() => organization.createOrganization({
|
||||
organizationName: "Acme Operations",
|
||||
employeeName: "Dana Lee",
|
||||
mission: "Coordinate agents with auditable approvals.",
|
||||
goal: "Ship a verified control plane.",
|
||||
}));
|
||||
|
||||
const state = snapshot.load();
|
||||
expect(state.organizations).toHaveLength(1);
|
||||
expect(state.employees[0]?.displayName).toBe("Dana Lee");
|
||||
expect(state.providers.length).toBeGreaterThanOrEqual(6);
|
||||
expect(state.employeeIdentities).toHaveLength(1);
|
||||
expect(state.roleGrants.map((grant) => grant.roleKey).sort()).toEqual(["approval_decider", "hiring_manager", "org_admin"]);
|
||||
expect(state.orgDepartments.map((department) => department.name)).toContain("Engineering");
|
||||
expect(state.hiringPolicies.some((policy) => policy.departmentName === "Engineering")).toBe(true);
|
||||
expect(state.approvalRoutingRules[0]).toMatchObject({ kind: "*", minimumRisk: "low", routeToType: "employee" });
|
||||
expect(state.featureFlags.map((flag) => flag.key)).toContain("chaos_drills");
|
||||
expect(state.sloTargets.map((target) => target.signalKey)).toContain("provider_request_success");
|
||||
expect(state.chaosDrills.map((drill) => drill.scenarioKey)).toContain("approval_backlog_gate");
|
||||
expect(state.sdlcReviewAgents.map((agent) => agent.key)).toContain("code_reviewer");
|
||||
expect(state.evalSuites.map((suite) => suite.key)).toContain("contract");
|
||||
expect(state.prBotGates.map((gate) => gate.key)).toContain("static_checks");
|
||||
expect(state.chatSpaces.map((space) => space.name)).toContain("board");
|
||||
expect(state.policyRules[0]?.action).toBe("task_create");
|
||||
expect(state.auditEvents[0]?.action).toBe("created");
|
||||
connection.close();
|
||||
});
|
||||
|
||||
test("high risk work creates a pending approval", () => {
|
||||
const connection = createConnection();
|
||||
const organization = new OrganizationService(connection.db);
|
||||
const snapshot = new SnapshotService(connection.db);
|
||||
let orgId = "";
|
||||
|
||||
connection.transaction(() => {
|
||||
orgId = organization.createOrganization({
|
||||
organizationName: "Acme Operations",
|
||||
employeeName: "Dana Lee",
|
||||
mission: "Coordinate agents with auditable approvals.",
|
||||
goal: "Ship a verified control plane.",
|
||||
executionMode: "ai_cofounder",
|
||||
});
|
||||
organization.createTask({
|
||||
orgId,
|
||||
title: "Production deploy",
|
||||
description: "Deploy a high-risk change.",
|
||||
status: TASK_STATUS.BACKLOG,
|
||||
assigneeAgentId: null,
|
||||
risk: RISK_LEVEL.HIGH,
|
||||
projectId: null,
|
||||
goalId: null,
|
||||
workspaceId: null,
|
||||
priority: "normal",
|
||||
requiresFilesystem: false,
|
||||
});
|
||||
});
|
||||
|
||||
const state = snapshot.load();
|
||||
expect(state.tasks).toHaveLength(1);
|
||||
expect(state.approvals).toHaveLength(1);
|
||||
expect(state.approvalAssignments).toHaveLength(1);
|
||||
expect(state.approvals[0]?.state).toBe("pending");
|
||||
expect(state.approvals[0]).toMatchObject({ sourceEntityType: "task", sourceEntityId: state.tasks[0]?.id });
|
||||
connection.close();
|
||||
});
|
||||
|
||||
test("ai ceo mode auto-approves high risk work without operator review", () => {
|
||||
const connection = createConnection();
|
||||
const organization = new OrganizationService(connection.db);
|
||||
const snapshot = new SnapshotService(connection.db);
|
||||
let orgId = "";
|
||||
|
||||
connection.transaction(() => {
|
||||
orgId = organization.createOrganization({
|
||||
organizationName: "Autonomy Inc",
|
||||
employeeName: "Robin Park",
|
||||
mission: "Run the company autonomously.",
|
||||
goal: "Ship without manual gates.",
|
||||
executionMode: "ai_ceo",
|
||||
});
|
||||
organization.createTask({
|
||||
orgId,
|
||||
title: "Production deploy",
|
||||
description: "Deploy a high-risk change.",
|
||||
status: TASK_STATUS.BACKLOG,
|
||||
assigneeAgentId: null,
|
||||
risk: RISK_LEVEL.HIGH,
|
||||
projectId: null,
|
||||
goalId: null,
|
||||
workspaceId: null,
|
||||
priority: "normal",
|
||||
requiresFilesystem: false,
|
||||
});
|
||||
});
|
||||
|
||||
const state = snapshot.load();
|
||||
const employeeId = state.employees.find((employee) => employee.orgId === orgId)?.id;
|
||||
expect(state.approvals).toHaveLength(1);
|
||||
expect(state.approvals[0]?.state).toBe("approved");
|
||||
expect(state.approvals[0]?.decidedByEmployeeId).toBe(employeeId);
|
||||
expect(state.approvalAssignments).toHaveLength(0);
|
||||
connection.close();
|
||||
});
|
||||
|
||||
test("imports a versioned skill revision from a local directory", () => {
|
||||
const connection = createConnection();
|
||||
const organization = new OrganizationService(connection.db);
|
||||
const skills = new SkillService(connection.db, join(tmpdir(), `agentcompany-skill-artifacts-${randomUUID()}`));
|
||||
const snapshot = new SnapshotService(connection.db);
|
||||
const skillDir = join(tmpdir(), `agentcompany-skill-${randomUUID()}`);
|
||||
mkdirSync(skillDir, { recursive: true });
|
||||
mkdirSync(join(skillDir, "tests"), { recursive: true });
|
||||
writeFileSync(join(skillDir, "SKILL.md"), "# Review Routes\n");
|
||||
writeFileSync(join(skillDir, "tests", "happy_path.yaml"), "input: review\n");
|
||||
writeFileSync(join(skillDir, "skill.manifest.json"), JSON.stringify({
|
||||
name: "review-routes",
|
||||
version: "1.0.0",
|
||||
entrypoint: "SKILL.md",
|
||||
capabilities: ["architecture-review"],
|
||||
required_tools: ["file_search"],
|
||||
compatible_models: ["openai/*", "anthropic/*"],
|
||||
risk_level: "medium",
|
||||
inputs: { diff: "markdown" },
|
||||
outputs: { recommendation: "markdown" },
|
||||
tests: ["tests/happy_path.yaml"],
|
||||
}));
|
||||
let orgId = "";
|
||||
let agentId = "";
|
||||
let revisionId = "";
|
||||
|
||||
connection.transaction(() => {
|
||||
orgId = organization.createOrganization({
|
||||
organizationName: "Acme Operations",
|
||||
employeeName: "Dana Lee",
|
||||
mission: "Coordinate agents with auditable approvals.",
|
||||
goal: "Ship a verified control plane.",
|
||||
});
|
||||
agentId = organization.createAgent({
|
||||
orgId,
|
||||
name: "Ada",
|
||||
role: "Architecture reviewer",
|
||||
department: "Engineering",
|
||||
modelProviderId: null,
|
||||
modelId: null,
|
||||
budgetCents: 10000,
|
||||
managerAgentId: null,
|
||||
});
|
||||
revisionId = skills.importDirectory({ orgId, directoryPath: skillDir, versionSemver: "1.0.0" });
|
||||
const skill = connection.db.prepare("select id from skill_definitions where org_id = ? and slug = ?")
|
||||
.get(orgId, "review-routes") as { id: string };
|
||||
skills.bind({ orgId, agentId, skillId: skill.id, revisionId });
|
||||
rmSync(skillDir, { recursive: true, force: true });
|
||||
skills.runTests({ orgId, revisionId });
|
||||
skills.resolveRuntimeSnapshot({ orgId, agentId, runId: "run-review-1" });
|
||||
});
|
||||
|
||||
const state = snapshot.load();
|
||||
expect(state.skillDefinitions[0]?.slug).toBe("review-routes");
|
||||
expect(state.skillRevisions[0]?.versionSemver).toBe("1.0.0");
|
||||
expect(state.skillRevisions[0]?.immutableChecksum).toHaveLength(64);
|
||||
expect(state.skillRevisions[0]?.manifestJson).toContain("architecture-review");
|
||||
expect(state.skillTests[0]?.lastResult).toContain("\"status\":\"passed\"");
|
||||
expect(state.skillTests[0]?.lastRunAt).toBeTruthy();
|
||||
expect(state.skillRuntimeSnapshots[0]?.sourceRevisionIds).toContain(revisionId);
|
||||
expect(state.skillRuntimeSnapshots[0]?.compiledPrompt).toContain("# Review Routes");
|
||||
expect(state.auditEvents.some((event) => event.entityType === "skill_revision" && event.action === "tests_run")).toBe(true);
|
||||
connection.close();
|
||||
});
|
||||
|
||||
test("rejects cross-organization skill binding and duplicate runtime snapshots", () => {
|
||||
const connection = createConnection();
|
||||
const organization = new OrganizationService(connection.db);
|
||||
const skills = new SkillService(connection.db, join(tmpdir(), `agentcompany-skill-artifacts-${randomUUID()}`));
|
||||
const skillDir = join(tmpdir(), `agentcompany-skill-${randomUUID()}`);
|
||||
mkdirSync(skillDir, { recursive: true });
|
||||
writeFileSync(join(skillDir, "SKILL.md"), "# Review Routes\n");
|
||||
writeFileSync(join(skillDir, "skill.manifest.json"), JSON.stringify({
|
||||
name: "review-routes",
|
||||
version: "1.0.0",
|
||||
entrypoint: "SKILL.md",
|
||||
capabilities: [],
|
||||
required_tools: [],
|
||||
compatible_models: ["openai/*"],
|
||||
risk_level: "low",
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
tests: [],
|
||||
}));
|
||||
let orgA = "";
|
||||
let orgB = "";
|
||||
let agentA = "";
|
||||
let agentB = "";
|
||||
let skillId = "";
|
||||
let revisionId = "";
|
||||
|
||||
connection.transaction(() => {
|
||||
orgA = organization.createOrganization({ organizationName: "Acme A", employeeName: "Dana Lee", mission: "A", goal: "A" });
|
||||
orgB = organization.createOrganization({ organizationName: "Acme B", employeeName: "Riley Chen", mission: "B", goal: "B" });
|
||||
agentA = organization.createAgent({ orgId: orgA, name: "Ada", role: "Reviewer", department: "Engineering", modelProviderId: null, modelId: null, budgetCents: 10000, managerAgentId: null });
|
||||
agentB = organization.createAgent({ orgId: orgB, name: "Ben", role: "Reviewer", department: "Engineering", modelProviderId: null, modelId: null, budgetCents: 10000, managerAgentId: null });
|
||||
revisionId = skills.importDirectory({ orgId: orgA, directoryPath: skillDir, versionSemver: "1.0.0" });
|
||||
skillId = (connection.db.prepare("select id from skill_definitions where org_id = ? and slug = ?")
|
||||
.get(orgA, "review-routes") as { id: string }).id;
|
||||
expect(() => skills.bind({ orgId: orgA, agentId: agentB, skillId, revisionId })).toThrow("Agent was not found.");
|
||||
skills.bind({ orgId: orgA, agentId: agentA, skillId, revisionId });
|
||||
skills.resolveRuntimeSnapshot({ orgId: orgA, agentId: agentA, runId: "run-review-1" });
|
||||
expect(() => skills.resolveRuntimeSnapshot({ orgId: orgA, agentId: agentA, runId: "run-review-1" }))
|
||||
.toThrow("Skill runtime snapshot already exists for this run.");
|
||||
});
|
||||
|
||||
expect(agentA).not.toBe(agentB);
|
||||
connection.close();
|
||||
});
|
||||
|
||||
test("records collaborative chat semantics for threads, commands, mentions, and files", () => {
|
||||
const connection = createConnection();
|
||||
const organization = new OrganizationService(connection.db);
|
||||
const chat = new ChatService(connection.db);
|
||||
const snapshot = new SnapshotService(connection.db);
|
||||
let orgId = "";
|
||||
let fileId = "";
|
||||
|
||||
connection.transaction(() => {
|
||||
orgId = organization.createOrganization({
|
||||
organizationName: "Acme Operations",
|
||||
employeeName: "Dana Lee",
|
||||
mission: "Coordinate agents with auditable approvals.",
|
||||
goal: "Ship a verified control plane.",
|
||||
});
|
||||
const spaceId = chat.createSpace({
|
||||
orgId,
|
||||
spaceType: "project",
|
||||
name: "release",
|
||||
topic: "Release coordination",
|
||||
visibility: "workspace",
|
||||
});
|
||||
const employee = connection.db.prepare("select id from employees where org_id = ?")
|
||||
.get(orgId) as { id: string };
|
||||
fileId = createId("file");
|
||||
connection.db.prepare(`
|
||||
insert into file_objects(id, org_id, name, kind, size_bytes, sha256, storage_path, owner_type, owner_id, created_at)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(fileId, orgId, "runbook.md", "text/markdown", 16, "8f3a", "artifacts/runbook.md", "employee", employee.id, new Date().toISOString());
|
||||
|
||||
chat.sendMessage({
|
||||
orgId,
|
||||
spaceId,
|
||||
threadId: null,
|
||||
senderType: "employee",
|
||||
senderId: employee.id,
|
||||
bodyMd: "/handoff @Board review the deployment runbook",
|
||||
attachmentFileIds: [fileId],
|
||||
});
|
||||
});
|
||||
|
||||
const state = snapshot.load();
|
||||
expect(state.chatSpaces.map((space) => space.name)).toContain("release");
|
||||
expect(state.chatMessages).toHaveLength(1);
|
||||
expect(state.chatThreads).toHaveLength(1);
|
||||
expect(state.chatAttachments).toHaveLength(1);
|
||||
expect(state.files[0]?.scanState).toBe("approved");
|
||||
expect(state.fileAccessGrants[0]).toMatchObject({ grantedToType: "board", grantedToId: orgId, accessLevel: "read" });
|
||||
expect(state.fileHandoffRecords[0]).toMatchObject({
|
||||
fileId,
|
||||
targetPrincipalType: "board",
|
||||
targetPrincipalId: orgId,
|
||||
checksum: "8f3a",
|
||||
scanState: "approved",
|
||||
policyDecision: "approved_for_handoff",
|
||||
objectRef: "artifacts/runbook.md",
|
||||
});
|
||||
expect(state.chatMentions[0]).toMatchObject({ targetType: "board", targetId: orgId });
|
||||
expect(state.chatSlashInvocations[0]).toMatchObject({ command: "handoff" });
|
||||
expect(state.chatPresence).toHaveLength(1);
|
||||
const slash = connection.db.prepare("select command from chat_slash_invocations").get() as { command: string };
|
||||
const mention = connection.db.prepare("select target_type, target_id from chat_mentions").get() as { target_type: string; target_id: string };
|
||||
expect(slash.command).toBe("handoff");
|
||||
expect(mention.target_type).toBe("board");
|
||||
expect(mention.target_id).toBe(orgId);
|
||||
connection.close();
|
||||
});
|
||||
|
||||
test("enforces scanned file grants for agent handoff", () => {
|
||||
const connection = createConnection();
|
||||
const organization = new OrganizationService(connection.db);
|
||||
const chat = new ChatService(connection.db);
|
||||
const snapshot = new SnapshotService(connection.db);
|
||||
let orgId = "";
|
||||
let spaceId = "";
|
||||
let adaId = "";
|
||||
let benId = "";
|
||||
let approvedFileId = "";
|
||||
let blockedFileId = "";
|
||||
|
||||
connection.transaction(() => {
|
||||
orgId = organization.createOrganization({
|
||||
organizationName: "Acme Operations",
|
||||
employeeName: "Dana Lee",
|
||||
mission: "Coordinate agents with auditable approvals.",
|
||||
goal: "Ship a verified control plane.",
|
||||
});
|
||||
adaId = organization.createAgent({
|
||||
orgId,
|
||||
name: "Ada",
|
||||
role: "Release reviewer",
|
||||
department: "Engineering",
|
||||
modelProviderId: null,
|
||||
modelId: null,
|
||||
budgetCents: 10000,
|
||||
managerAgentId: null,
|
||||
});
|
||||
benId = organization.createAgent({
|
||||
orgId,
|
||||
name: "Ben",
|
||||
role: "Runtime owner",
|
||||
department: "Engineering",
|
||||
modelProviderId: null,
|
||||
modelId: null,
|
||||
budgetCents: 10000,
|
||||
managerAgentId: null,
|
||||
});
|
||||
spaceId = chat.createSpace({ orgId, spaceType: "project", name: "release", topic: "Release coordination", visibility: "workspace" });
|
||||
approvedFileId = createId("fil");
|
||||
blockedFileId = createId("fil");
|
||||
const now = new Date().toISOString();
|
||||
connection.db.prepare(`
|
||||
insert into file_objects(
|
||||
id, org_id, name, kind, size_bytes, sha256, storage_path,
|
||||
owner_type, owner_id, space_id, pinned, scan_state, scan_labels_json, scanned_at, created_at, updated_at
|
||||
)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(approvedFileId, orgId, "release-notes.md", "md", 24, "sha-approved", "artifacts/release-notes.md", "agent", adaId, null, 0, "approved", "[]", now, now, now);
|
||||
connection.db.prepare(`
|
||||
insert into file_objects(
|
||||
id, org_id, name, kind, size_bytes, sha256, storage_path,
|
||||
owner_type, owner_id, space_id, pinned, scan_state, scan_labels_json, scanned_at, created_at, updated_at
|
||||
)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(blockedFileId, orgId, "quarantine.bin", "file", 8, "sha-blocked", "artifacts/quarantine.bin", "agent", adaId, null, 0, "blocked", "[\"signature\"]", now, now, now);
|
||||
});
|
||||
|
||||
expect(() => connection.transaction(() => chat.sendMessage({
|
||||
orgId,
|
||||
spaceId,
|
||||
threadId: null,
|
||||
senderType: "agent",
|
||||
senderId: benId,
|
||||
bodyMd: "Attach release notes",
|
||||
attachmentFileIds: [approvedFileId],
|
||||
}))).toThrow("Attachment file access is required.");
|
||||
|
||||
connection.transaction(() => chat.sendMessage({
|
||||
orgId,
|
||||
spaceId,
|
||||
threadId: null,
|
||||
senderType: "agent",
|
||||
senderId: adaId,
|
||||
bodyMd: "/handoff @Ben release notes",
|
||||
attachmentFileIds: [approvedFileId],
|
||||
}));
|
||||
|
||||
expect(() => connection.transaction(() => chat.sendMessage({
|
||||
orgId,
|
||||
spaceId,
|
||||
threadId: null,
|
||||
senderType: "agent",
|
||||
senderId: adaId,
|
||||
bodyMd: "/handoff @Ben blocked payload",
|
||||
attachmentFileIds: [blockedFileId],
|
||||
}))).toThrow("Attachment file is not approved for handoff.");
|
||||
|
||||
connection.transaction(() => chat.sendMessage({
|
||||
orgId,
|
||||
spaceId,
|
||||
threadId: null,
|
||||
senderType: "agent",
|
||||
senderId: benId,
|
||||
bodyMd: "Received release notes",
|
||||
attachmentFileIds: [approvedFileId],
|
||||
}));
|
||||
|
||||
const state = snapshot.load();
|
||||
const benGrant = state.fileAccessGrants.find((grant) => grant.fileId === approvedFileId && grant.grantedToId === benId);
|
||||
if (!benGrant) throw new Error("Agent handoff grant was not created.");
|
||||
const benHandoff = state.fileHandoffRecords.find((record) => (
|
||||
record.fileId === approvedFileId && record.sourcePrincipalId === adaId && record.targetPrincipalId === benId
|
||||
));
|
||||
if (!benHandoff) throw new Error("Agent handoff record was not created.");
|
||||
expect(benGrant).toMatchObject({ grantedToType: "agent", accessLevel: "read" });
|
||||
expect(benHandoff).toMatchObject({
|
||||
sourcePrincipalType: "agent",
|
||||
sourcePrincipalId: adaId,
|
||||
targetPrincipalType: "agent",
|
||||
checksum: "sha-approved",
|
||||
objectRef: "artifacts/release-notes.md",
|
||||
scanState: "approved",
|
||||
policyDecision: "approved_for_handoff",
|
||||
});
|
||||
expect(state.chatMessages).toHaveLength(2);
|
||||
expect(state.auditEvents.some((event) => event.entityType === "file_object" && event.action === "access_granted")).toBe(true);
|
||||
connection.close();
|
||||
});
|
||||
|
||||
test("imports files with checksum, scan labels, object refs, and workspace grants", () => {
|
||||
const connection = createConnection();
|
||||
const organization = new OrganizationService(connection.db);
|
||||
const snapshot = new SnapshotService(connection.db);
|
||||
const artifactRoot = join(tmpdir(), `agentcompany-file-artifacts-${randomUUID()}`);
|
||||
const fileService = new FileService(connection.db, artifactRoot);
|
||||
const sourceDir = join(tmpdir(), `agentcompany-file-sources-${randomUUID()}`);
|
||||
mkdirSync(sourceDir, { recursive: true });
|
||||
const cleanBody = "Release checklist for dana@example.com\n";
|
||||
const secretBody = "-----BEGIN OPENSSH PRIVATE KEY-----\nabc123\n-----END OPENSSH PRIVATE KEY-----\n";
|
||||
const cleanPath = join(sourceDir, "release-notes.md");
|
||||
const secretPath = join(sourceDir, "deploy.env");
|
||||
writeFileSync(cleanPath, cleanBody);
|
||||
writeFileSync(secretPath, secretBody);
|
||||
let orgId = "";
|
||||
|
||||
connection.transaction(() => {
|
||||
orgId = organization.createOrganization({
|
||||
organizationName: "Acme Operations",
|
||||
employeeName: "Dana Lee",
|
||||
mission: "Coordinate agents with auditable approvals.",
|
||||
goal: "Ship a verified control plane.",
|
||||
});
|
||||
});
|
||||
const cleanFileId = fileService.importPath(orgId, cleanPath);
|
||||
const blockedFileId = fileService.importPath(orgId, secretPath);
|
||||
|
||||
const state = snapshot.load();
|
||||
const cleanFile = state.files.find((file) => file.id === cleanFileId);
|
||||
const blockedFile = state.files.find((file) => file.id === blockedFileId);
|
||||
if (!cleanFile || !blockedFile) throw new Error("Imported files were not recorded.");
|
||||
expect(cleanFile.sha256).toBe(createHash("sha256").update(cleanBody).digest("hex"));
|
||||
expect(cleanFile.storagePath).toContain(cleanFileId);
|
||||
expect(cleanFile.scanState).toBe("approved");
|
||||
expect(JSON.parse(cleanFile.scanLabelsJson)).toEqual(expect.arrayContaining(["classification:confidential", "dlp:email", "kind:md"]));
|
||||
expect(blockedFile.scanState).toBe("blocked");
|
||||
expect(JSON.parse(blockedFile.scanLabelsJson)).toEqual(expect.arrayContaining(["classification:restricted", "dlp:private-key", "kind:env"]));
|
||||
expect(state.fileAccessGrants).toContainEqual(expect.objectContaining({
|
||||
fileId: cleanFileId,
|
||||
grantedToType: "org_group",
|
||||
grantedToId: `${orgId}:workspace`,
|
||||
accessLevel: "manage",
|
||||
}));
|
||||
expect(state.fileAccessGrants).toContainEqual(expect.objectContaining({
|
||||
fileId: blockedFileId,
|
||||
grantedToType: "org_group",
|
||||
grantedToId: `${orgId}:workspace`,
|
||||
accessLevel: "manage",
|
||||
}));
|
||||
const importAudit = state.auditEvents.find((event) => event.entityId === blockedFileId && event.action === "imported");
|
||||
if (!importAudit) throw new Error("File import audit event was not recorded.");
|
||||
expect(importAudit.payloadJson).toContain("\"scanState\":\"blocked\"");
|
||||
expect(importAudit.payloadJson).toContain("dlp:private-key");
|
||||
expect(importAudit.payloadJson).toContain("objectRef");
|
||||
rmSync(sourceDir, { recursive: true, force: true });
|
||||
rmSync(artifactRoot, { recursive: true, force: true });
|
||||
connection.close();
|
||||
});
|
||||
|
||||
test("creates approval and waiting presence from chat escalation", () => {
|
||||
const connection = createConnection();
|
||||
const organization = new OrganizationService(connection.db);
|
||||
const chat = new ChatService(connection.db);
|
||||
const snapshot = new SnapshotService(connection.db);
|
||||
let orgId = "";
|
||||
let employeeId = "";
|
||||
let spaceId = "";
|
||||
|
||||
connection.transaction(() => {
|
||||
orgId = organization.createOrganization({
|
||||
organizationName: "Acme Operations",
|
||||
employeeName: "Dana Lee",
|
||||
mission: "Coordinate agents with auditable approvals.",
|
||||
goal: "Ship a verified control plane.",
|
||||
});
|
||||
employeeId = (connection.db.prepare("select id from employees where org_id = ?").get(orgId) as { id: string }).id;
|
||||
spaceId = chat.createSpace({
|
||||
orgId,
|
||||
spaceType: "project",
|
||||
name: "release",
|
||||
topic: "Release coordination",
|
||||
visibility: "workspace",
|
||||
});
|
||||
chat.sendMessage({
|
||||
orgId,
|
||||
spaceId,
|
||||
threadId: null,
|
||||
senderType: "employee",
|
||||
senderId: employeeId,
|
||||
bodyMd: "/escalate @Board freeze deployment",
|
||||
attachmentFileIds: [],
|
||||
});
|
||||
});
|
||||
|
||||
const state = snapshot.load();
|
||||
const approval = state.approvals.find((item) => item.kind === "chat_escalation");
|
||||
if (!approval) throw new Error("Chat escalation approval was not created.");
|
||||
expect(approval.title).toBe("Escalate release");
|
||||
expect(approval.details).toBe("@Board freeze deployment");
|
||||
expect(approval.risk).toBe("high");
|
||||
expect(state.chatSlashInvocations[0]?.resultRef).toBe(approval.id);
|
||||
expect(state.chatPresence[0]).toMatchObject({ principalId: employeeId, status: "waiting_approval" });
|
||||
const audit = state.auditEvents.find((event) => event.entityId === approval.id && event.action === "requested");
|
||||
if (!audit) throw new Error("Chat escalation audit event was not created.");
|
||||
expect(audit.payloadJson).toContain(state.chatMessages[0]!.id);
|
||||
connection.close();
|
||||
});
|
||||
|
||||
test("creates a read-only migration report from a legacy database", () => {
|
||||
const connection = createConnection();
|
||||
const organization = new OrganizationService(connection.db);
|
||||
const migrations = new MigrationService(connection.db);
|
||||
const snapshot = new SnapshotService(connection.db);
|
||||
const legacyPath = join(tmpdir(), `agentcompany-legacy-${randomUUID()}.db`);
|
||||
const legacy = new DatabaseSync(legacyPath);
|
||||
legacy.exec("create table agents(id text primary key); insert into agents(id) values ('agent-1');");
|
||||
legacy.close();
|
||||
let orgId = "";
|
||||
|
||||
connection.transaction(() => {
|
||||
orgId = organization.createOrganization({
|
||||
organizationName: "Acme Operations",
|
||||
employeeName: "Dana Lee",
|
||||
mission: "Coordinate agents with auditable approvals.",
|
||||
goal: "Ship a verified control plane.",
|
||||
});
|
||||
migrations.createReport({ orgId, sourceDatabasePath: legacyPath });
|
||||
});
|
||||
|
||||
const state = snapshot.load();
|
||||
expect(state.migrationReports).toHaveLength(1);
|
||||
expect(state.migrationReports[0]?.reportJson).toContain("\"agents\":1");
|
||||
const report = JSON.parse(state.migrationReports[0]!.reportJson) as {
|
||||
tableChecksums: Record<string, string>;
|
||||
idMaps: Record<string, string[]>;
|
||||
failures: Array<{ table: string; reason: string }>;
|
||||
validationSummary: { missingTableCount: number; passed: boolean };
|
||||
rollbackPack: { sourceDatabasePath: string };
|
||||
};
|
||||
expect(report.tableChecksums.agents).toHaveLength(64);
|
||||
expect(report.idMaps.agents).toEqual(["agent-1"]);
|
||||
expect(report.failures.some((failure) => failure.reason === "missing_table")).toBe(true);
|
||||
expect(report.validationSummary.missingTableCount).toBeGreaterThan(0);
|
||||
expect(report.validationSummary.passed).toBe(false);
|
||||
expect(report.rollbackPack.sourceDatabasePath).toBe(realpathSync.native(legacyPath));
|
||||
connection.close();
|
||||
});
|
||||
|
||||
test("bootstraps the original onboarding chain with company, CEO, goal, project, task, and run", () => {
|
||||
const connection = createConnection();
|
||||
const onboarding = new OnboardingService({
|
||||
db: connection.db,
|
||||
saveProviderCredential: ({ orgId, providerId, model }) => {
|
||||
connection.db.prepare("update providers set credential_id = ?, model = ?, enabled = 1 where id = ? and org_id = ?")
|
||||
.run("cred_test", model, providerId, orgId);
|
||||
},
|
||||
});
|
||||
const snapshot = new SnapshotService(connection.db);
|
||||
|
||||
connection.transaction(() => onboarding.bootstrap({
|
||||
companyName: "Acme Operations",
|
||||
companyDescription: "Coordinate agents with auditable approvals.",
|
||||
employeeName: "Dana Lee",
|
||||
workspacePath: join(tmpdir(), `agentcompany-workspace-${randomUUID()}`),
|
||||
executionMode: "ai_ceo",
|
||||
autoApproveHires: true,
|
||||
reviewDeliverables: false,
|
||||
goalTitle: "Ship a verified control plane",
|
||||
goalDescription: "Bring the desktop operating loop online.",
|
||||
providerKey: "openai",
|
||||
providerSecret: "test-secret",
|
||||
providerModel: "gpt-5.1",
|
||||
}));
|
||||
|
||||
const state = snapshot.load();
|
||||
expect(state.organizations[0]?.executionMode).toBe("ai_ceo");
|
||||
expect(state.agents[0]?.roleLevel).toBe("c_suite");
|
||||
expect(state.agents[0]?.modelStrategy).toBe("explicit");
|
||||
expect(state.workspaces).toHaveLength(1);
|
||||
expect(state.goals[0]?.status).toBe("active");
|
||||
expect(state.projects[0]?.leadAgentId).toBe(state.agents[0]?.id);
|
||||
const projectRoom = state.chatSpaces.find((space) => space.spaceType === "project" && space.topic === state.projects[0]?.name);
|
||||
expect(projectRoom).toMatchObject({ name: "execution-plan-ship-a-verified-control-plane", visibility: "workspace" });
|
||||
expect(state.chatMemberships).toContainEqual(expect.objectContaining({ spaceId: projectRoom?.id, principalType: "employee" }));
|
||||
expect(state.chatMemberships).toContainEqual(expect.objectContaining({ spaceId: projectRoom?.id, principalType: "agent", principalId: state.agents[0]?.id }));
|
||||
expect(state.tasks[0]?.assigneeAgentId).toBe(state.agents[0]?.id);
|
||||
expect(state.taskRuns[0]?.trigger).toBe("bootstrap");
|
||||
expect(state.taskRuns[0]?.executionContextId).toMatch(/^ctx_/);
|
||||
expect(state.taskRuns[0]?.serviceIdentityId).toMatch(/^sid_/);
|
||||
connection.close();
|
||||
});
|
||||
|
||||
test("creating a goal kicks off an operationalize task for its owner or the CEO", () => {
|
||||
const connection = createConnection();
|
||||
const organization = new OrganizationService(connection.db);
|
||||
let orgId = "";
|
||||
|
||||
connection.transaction(() => {
|
||||
orgId = organization.createOrganization({
|
||||
organizationName: "Acme Operations",
|
||||
employeeName: "Dana Lee",
|
||||
mission: "Coordinate agents with auditable approvals.",
|
||||
goal: "Ship a verified control plane.",
|
||||
});
|
||||
});
|
||||
|
||||
// Without any agent there is nobody to operationalize the goal yet.
|
||||
let waitingGoalId = "";
|
||||
connection.transaction(() => {
|
||||
waitingGoalId = organization.createGoal({ orgId, title: "Open the Berlin market", description: "Win the first ten customers.", ownerAgentId: null });
|
||||
});
|
||||
expect(connection.db.prepare("select count(*) as n from tasks where org_id = ?").get(orgId)).toMatchObject({ n: 0 });
|
||||
|
||||
let ceoId = "";
|
||||
let ownerId = "";
|
||||
connection.transaction(() => {
|
||||
ceoId = organization.createAgent({
|
||||
orgId,
|
||||
name: "CEO",
|
||||
role: "CEO",
|
||||
department: "Executive",
|
||||
modelProviderId: null,
|
||||
modelId: null,
|
||||
budgetCents: 25000,
|
||||
managerAgentId: null,
|
||||
roleLevel: "c_suite",
|
||||
});
|
||||
ownerId = organization.createAgent({
|
||||
orgId,
|
||||
name: "Mara Quinn",
|
||||
role: "Growth Marketer",
|
||||
department: "Marketing",
|
||||
modelProviderId: null,
|
||||
modelId: null,
|
||||
budgetCents: 25000,
|
||||
managerAgentId: ceoId,
|
||||
});
|
||||
});
|
||||
|
||||
// The first hire operationalizes the goal that was waiting, exactly once.
|
||||
expect(connection.db.prepare("select count(*) as n from tasks where goal_id = ?").get(waitingGoalId)).toMatchObject({ n: 1 });
|
||||
const revived = connection.db.prepare("select status, assignee_agent_id from tasks where goal_id = ?")
|
||||
.get(waitingGoalId) as { status: string; assignee_agent_id: string };
|
||||
expect(revived).toMatchObject({ status: TASK_STATUS.BACKLOG, assignee_agent_id: ceoId });
|
||||
|
||||
// Ownerless goals land with the top-level agent; the kickoff re-enables its heartbeat.
|
||||
let ceoGoalId = "";
|
||||
connection.transaction(() => {
|
||||
ceoGoalId = organization.createGoal({ orgId, title: "Launch the loyalty program", description: "Ship the first reward tier.", ownerAgentId: null });
|
||||
});
|
||||
const ceoKickoff = connection.db.prepare("select title, status, assignee_agent_id from tasks where goal_id = ?")
|
||||
.get(ceoGoalId) as { title: string; status: string; assignee_agent_id: string };
|
||||
expect(ceoKickoff).toMatchObject({
|
||||
title: "Operationalize goal: Launch the loyalty program",
|
||||
status: TASK_STATUS.BACKLOG,
|
||||
assignee_agent_id: ceoId,
|
||||
});
|
||||
const ceo = connection.db.prepare("select heartbeat_enabled, heartbeat_interval_sec from agents where id = ?")
|
||||
.get(ceoId) as { heartbeat_enabled: number; heartbeat_interval_sec: number };
|
||||
expect(ceo).toMatchObject({ heartbeat_enabled: 1, heartbeat_interval_sec: 120 });
|
||||
|
||||
// Goals with an explicit owner land with that owner.
|
||||
let ownedGoalId = "";
|
||||
connection.transaction(() => {
|
||||
ownedGoalId = organization.createGoal({ orgId, title: "Win the spring campaign", description: "Double qualified leads.", ownerAgentId: ownerId });
|
||||
});
|
||||
const ownedKickoff = connection.db.prepare("select assignee_agent_id from tasks where goal_id = ?")
|
||||
.get(ownedGoalId) as { assignee_agent_id: string };
|
||||
expect(ownedKickoff.assignee_agent_id).toBe(ownerId);
|
||||
connection.close();
|
||||
});
|
||||
|
||||
test("hiring normalizes department case and rejects unknown departments with guidance", () => {
|
||||
const connection = createConnection();
|
||||
const organization = new OrganizationService(connection.db);
|
||||
let orgId = "";
|
||||
connection.transaction(() => {
|
||||
orgId = organization.createOrganization({
|
||||
organizationName: "Acme Operations",
|
||||
employeeName: "Dana Lee",
|
||||
mission: "Coordinate agents with auditable approvals.",
|
||||
goal: "Ship a verified control plane.",
|
||||
});
|
||||
});
|
||||
|
||||
let agentId = "";
|
||||
connection.transaction(() => {
|
||||
agentId = organization.createAgent({
|
||||
orgId,
|
||||
name: "Zhang Hao",
|
||||
role: "Algorithm Lead",
|
||||
department: "engineering",
|
||||
modelProviderId: null,
|
||||
modelId: null,
|
||||
budgetCents: 25000,
|
||||
managerAgentId: null,
|
||||
});
|
||||
});
|
||||
const hired = connection.db.prepare("select department from agents where id = ?").get(agentId) as { department: string };
|
||||
expect(hired.department).toBe("Engineering");
|
||||
|
||||
expect(() => connection.transaction(() => organization.createAgent({
|
||||
orgId,
|
||||
name: "Chen Wei",
|
||||
role: "Research Lead",
|
||||
department: "general",
|
||||
modelProviderId: null,
|
||||
modelId: null,
|
||||
budgetCents: 25000,
|
||||
managerAgentId: null,
|
||||
}))).toThrow(/Unknown department "general"\. Departments: .*Engineering.*Executive/);
|
||||
connection.close();
|
||||
});
|
||||
});
|
||||
@@ -1,449 +0,0 @@
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("electron", () => ({
|
||||
safeStorage: {
|
||||
isEncryptionAvailable: () => false,
|
||||
getSelectedStorageBackend: () => "basic_text",
|
||||
encryptString: (value: string) => Buffer.from(value, "utf8"),
|
||||
decryptString: (value: Buffer) => value.toString("utf8"),
|
||||
},
|
||||
}));
|
||||
|
||||
import { AppDatabase } from "../../src/main/database";
|
||||
import { createBudgetEnforcementService } from "../../src/main/budget-enforcement-service";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function scaffold(db: AppDatabase) {
|
||||
const companyId = db.core.company.saveCompany({
|
||||
name: "Budget Edge Case Co",
|
||||
description: "Budget exhaustion mid-run edge case tests (D-05.2)",
|
||||
status: "active",
|
||||
});
|
||||
db.core.connectors.saveConnector({
|
||||
id: "gemini-cli",
|
||||
model: "gemini-pro",
|
||||
envBindingText: "",
|
||||
notes: "Test connector",
|
||||
});
|
||||
const workspaceId = db.core.workspaces.saveWorkspace({
|
||||
companyId,
|
||||
name: "Primary",
|
||||
localPath: "/tmp/budget-edge-test",
|
||||
repoUrl: "",
|
||||
repoRef: "",
|
||||
isPrimary: true,
|
||||
});
|
||||
const agentId = db.core.agents.saveAgent({
|
||||
companyId,
|
||||
name: "Worker Agent",
|
||||
role: "developer",
|
||||
title: "Developer",
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "gemini-cli",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Executes tasks",
|
||||
budgetMonthlyUsd: 100,
|
||||
});
|
||||
const goalId = db.core.entity.saveGoal({
|
||||
companyId,
|
||||
title: "Ship feature",
|
||||
description: "Build budget edge case test",
|
||||
ownerAgentId: agentId,
|
||||
status: "active",
|
||||
});
|
||||
const projectId = db.core.entity.saveProject({
|
||||
companyId,
|
||||
goalId,
|
||||
name: "Budget Project",
|
||||
description: "Testing budget exhaustion",
|
||||
leadAgentId: agentId,
|
||||
status: "in_progress",
|
||||
targetDate: null,
|
||||
});
|
||||
const taskId = db.core.tasks.saveTask({
|
||||
companyId,
|
||||
projectId,
|
||||
goalId,
|
||||
title: "Implement feature",
|
||||
description: "Build something to test budget edge cases.",
|
||||
assigneeAgentId: agentId,
|
||||
workspaceId,
|
||||
priority: "high",
|
||||
status: "todo",
|
||||
});
|
||||
return { companyId, workspaceId, agentId, goalId, projectId, taskId };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests — Edge Case: Budget Exhaustion Mid-Run (D-05.2)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("Edge Case: Budget Exhaustion Mid-Run (D-05.2)", () => {
|
||||
let tempDir: string;
|
||||
let db: AppDatabase;
|
||||
const mockLogger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
||||
const mockNotify = vi.fn();
|
||||
const mockPublish = vi.fn();
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "agentcompany-budget-edge-"));
|
||||
db = new AppDatabase(tempDir);
|
||||
db.init();
|
||||
mockLogger.info.mockClear();
|
||||
mockLogger.warn.mockClear();
|
||||
mockLogger.error.mockClear();
|
||||
mockNotify.mockClear();
|
||||
mockPublish.mockClear();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
db.close();
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("in-flight run completes normally when budget is exhausted during execution", () => {
|
||||
const { companyId, workspaceId, agentId, projectId, taskId } = scaffold(db);
|
||||
const budgetService = createBudgetEnforcementService({
|
||||
db,
|
||||
logger: mockLogger,
|
||||
notify: mockNotify,
|
||||
publishDomainChanged: mockPublish,
|
||||
});
|
||||
|
||||
// Create company budget policy: 500 cents ($5), hard stop enabled
|
||||
db.core.budget.saveBudgetPolicy({
|
||||
companyId,
|
||||
scopeType: "company",
|
||||
scopeId: companyId,
|
||||
amount: 500,
|
||||
hardStopEnabled: true,
|
||||
});
|
||||
|
||||
// Start Run-1 (the in-flight run)
|
||||
const run1 = db.core.runs.createRun({
|
||||
companyId,
|
||||
taskId,
|
||||
agentId,
|
||||
workspaceId,
|
||||
connectorId: "gemini-cli",
|
||||
});
|
||||
db.core.tasks.checkoutTask(taskId, run1.id);
|
||||
db.core.runs.markRunStatus(run1.id, "running", "");
|
||||
|
||||
// Create a second task and run that exhausts budget WHILE Run-1 is in-flight
|
||||
const taskId2 = db.core.tasks.saveTask({
|
||||
companyId,
|
||||
title: "Other Task",
|
||||
description: "Another task that uses up budget",
|
||||
assigneeAgentId: agentId,
|
||||
workspaceId,
|
||||
priority: "medium",
|
||||
status: "todo",
|
||||
});
|
||||
const run0 = db.core.runs.createRun({
|
||||
companyId,
|
||||
taskId: taskId2,
|
||||
agentId,
|
||||
workspaceId,
|
||||
connectorId: "gemini-cli",
|
||||
});
|
||||
db.core.tasks.checkoutTask(taskId2, run0.id);
|
||||
db.core.runs.markRunStatus(run0.id, "running", "");
|
||||
// Complete Run-0 with $6 cost (exceeds $5 budget)
|
||||
db.core.runs.recordCostEntry({ runId: run0.id, companyId, agentId, taskId: taskId2, connectorId: "gemini-cli", amountUsd: 6.0, unattributed: false });
|
||||
db.core.runs.finishRun({
|
||||
runId: run0.id,
|
||||
status: "succeeded",
|
||||
summary: "Expensive run",
|
||||
errorMessage: null,
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
model: "gemini-pro",
|
||||
unattributedCost: false,
|
||||
});
|
||||
|
||||
// Now complete Run-1 (the in-flight run) -- it should succeed normally
|
||||
// Per D-04/D-05: in-flight runs are unaffected by budget exhaustion
|
||||
db.core.runs.recordCostEntry({ runId: run1.id, companyId, agentId, taskId, connectorId: "gemini-cli", amountUsd: 0.5, unattributed: false });
|
||||
db.core.runs.finishRun({
|
||||
runId: run1.id,
|
||||
status: "succeeded",
|
||||
summary: "In-flight run completed",
|
||||
errorMessage: null,
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
model: "gemini-pro",
|
||||
unattributedCost: false,
|
||||
});
|
||||
|
||||
// Verify Run-1 completed successfully
|
||||
const finishedRun = db.core.runs.getRun(run1.id);
|
||||
expect(finishedRun.status).toBe("succeeded");
|
||||
|
||||
// Verify Run-1's cost was recorded (total spend now $6.50)
|
||||
const gate = budgetService.evaluateBudgetGate(companyId, agentId, projectId);
|
||||
expect(gate.allowed).toBe(false);
|
||||
// Spent should be at least 650 cents ($6.50 = $6 + $0.50)
|
||||
expect(gate.blockedBy[0].spent).toBeGreaterThanOrEqual(650);
|
||||
});
|
||||
|
||||
it("budget gate blocks next run after exhaustion", () => {
|
||||
const { companyId, workspaceId, agentId, projectId, taskId } = scaffold(db);
|
||||
const budgetService = createBudgetEnforcementService({
|
||||
db,
|
||||
logger: mockLogger,
|
||||
notify: mockNotify,
|
||||
publishDomainChanged: mockPublish,
|
||||
});
|
||||
|
||||
// Create company budget policy: 500 cents ($5)
|
||||
db.core.budget.saveBudgetPolicy({
|
||||
companyId,
|
||||
scopeType: "company",
|
||||
scopeId: companyId,
|
||||
amount: 500,
|
||||
hardStopEnabled: true,
|
||||
});
|
||||
|
||||
// Complete a run that exceeds budget
|
||||
const run = db.core.runs.createRun({
|
||||
companyId,
|
||||
taskId,
|
||||
agentId,
|
||||
workspaceId,
|
||||
connectorId: "gemini-cli",
|
||||
});
|
||||
db.core.tasks.checkoutTask(taskId, run.id);
|
||||
db.core.runs.markRunStatus(run.id, "running", "");
|
||||
db.core.runs.recordCostEntry({ runId: run.id, companyId, agentId, taskId, connectorId: "gemini-cli", amountUsd: 6.0, unattributed: false });
|
||||
db.core.runs.finishRun({
|
||||
runId: run.id,
|
||||
status: "succeeded",
|
||||
summary: "Done",
|
||||
errorMessage: null,
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
model: "gemini-pro",
|
||||
unattributedCost: false,
|
||||
});
|
||||
|
||||
// Budget gate should now block
|
||||
const result = budgetService.evaluateBudgetGate(companyId, agentId, projectId);
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.blockedBy.length).toBeGreaterThanOrEqual(1);
|
||||
expect(result.blockedBy[0].spent).toBeGreaterThanOrEqual(500);
|
||||
expect(result.blockedBy[0].limit).toBe(500);
|
||||
});
|
||||
|
||||
it("budget gate returns detailed blockedBy information", () => {
|
||||
const { companyId, workspaceId, agentId, projectId, taskId } = scaffold(db);
|
||||
const budgetService = createBudgetEnforcementService({
|
||||
db,
|
||||
logger: mockLogger,
|
||||
notify: mockNotify,
|
||||
publishDomainChanged: mockPublish,
|
||||
});
|
||||
|
||||
// Create company budget policy with known ID
|
||||
const policyId = db.core.budget.saveBudgetPolicy({
|
||||
companyId,
|
||||
scopeType: "company",
|
||||
scopeId: companyId,
|
||||
amount: 500,
|
||||
hardStopEnabled: true,
|
||||
});
|
||||
|
||||
// Exhaust budget
|
||||
const run = db.core.runs.createRun({
|
||||
companyId,
|
||||
taskId,
|
||||
agentId,
|
||||
workspaceId,
|
||||
connectorId: "gemini-cli",
|
||||
});
|
||||
db.core.tasks.checkoutTask(taskId, run.id);
|
||||
db.core.runs.markRunStatus(run.id, "running", "");
|
||||
db.core.runs.recordCostEntry({ runId: run.id, companyId, agentId, taskId, connectorId: "gemini-cli", amountUsd: 6.0, unattributed: false });
|
||||
db.core.runs.finishRun({
|
||||
runId: run.id,
|
||||
status: "succeeded",
|
||||
summary: "Done",
|
||||
errorMessage: null,
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
model: "gemini-pro",
|
||||
unattributedCost: false,
|
||||
});
|
||||
|
||||
const result = budgetService.evaluateBudgetGate(companyId, agentId, projectId);
|
||||
expect(result.allowed).toBe(false);
|
||||
// Verify blockedBy contains correct details
|
||||
expect(result.blockedBy[0].policyId).toBe(policyId);
|
||||
expect(result.blockedBy[0].scopeType).toBe("company");
|
||||
expect(result.blockedBy[0].scopeId).toBe(companyId);
|
||||
});
|
||||
|
||||
it("agent-scope budget exhaustion blocks even when company budget has room", () => {
|
||||
const { companyId, workspaceId, agentId, projectId, taskId } = scaffold(db);
|
||||
const budgetService = createBudgetEnforcementService({
|
||||
db,
|
||||
logger: mockLogger,
|
||||
notify: mockNotify,
|
||||
publishDomainChanged: mockPublish,
|
||||
});
|
||||
|
||||
// Company-level: plenty of room (10000 cents = $100)
|
||||
db.core.budget.saveBudgetPolicy({
|
||||
companyId,
|
||||
scopeType: "company",
|
||||
scopeId: companyId,
|
||||
amount: 10000,
|
||||
hardStopEnabled: true,
|
||||
});
|
||||
|
||||
// Agent-level: tight budget (100 cents = $1)
|
||||
db.core.budget.saveBudgetPolicy({
|
||||
companyId,
|
||||
scopeType: "agent",
|
||||
scopeId: agentId,
|
||||
amount: 100,
|
||||
hardStopEnabled: true,
|
||||
});
|
||||
|
||||
// Complete a run costing $1.50 (150 cents > 100 agent limit)
|
||||
const run = db.core.runs.createRun({
|
||||
companyId,
|
||||
taskId,
|
||||
agentId,
|
||||
workspaceId,
|
||||
connectorId: "gemini-cli",
|
||||
});
|
||||
db.core.tasks.checkoutTask(taskId, run.id);
|
||||
db.core.runs.markRunStatus(run.id, "running", "");
|
||||
db.core.runs.recordCostEntry({ runId: run.id, companyId, agentId, taskId, connectorId: "gemini-cli", amountUsd: 1.5, unattributed: false });
|
||||
db.core.runs.finishRun({
|
||||
runId: run.id,
|
||||
status: "succeeded",
|
||||
summary: "Done",
|
||||
errorMessage: null,
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
model: "gemini-pro",
|
||||
unattributedCost: false,
|
||||
});
|
||||
|
||||
const result = budgetService.evaluateBudgetGate(companyId, agentId, projectId);
|
||||
expect(result.allowed).toBe(false);
|
||||
// Should be blocked by agent scope, not company scope
|
||||
expect(result.blockedBy.length).toBe(1);
|
||||
expect(result.blockedBy[0].scopeType).toBe("agent");
|
||||
expect(result.blockedBy[0].scopeId).toBe(agentId);
|
||||
});
|
||||
|
||||
it("budget gate transitions from allowed to blocked after spending", () => {
|
||||
const { companyId, workspaceId, agentId, projectId, taskId } = scaffold(db);
|
||||
const budgetService = createBudgetEnforcementService({
|
||||
db,
|
||||
logger: mockLogger,
|
||||
notify: mockNotify,
|
||||
publishDomainChanged: mockPublish,
|
||||
});
|
||||
|
||||
// Create policy with 300 cents ($3) budget
|
||||
db.core.budget.saveBudgetPolicy({
|
||||
companyId,
|
||||
scopeType: "company",
|
||||
scopeId: companyId,
|
||||
amount: 300,
|
||||
hardStopEnabled: true,
|
||||
});
|
||||
|
||||
// Budget gate initially allows (nothing spent yet)
|
||||
const before = budgetService.evaluateBudgetGate(companyId, agentId, projectId);
|
||||
expect(before.allowed).toBe(true);
|
||||
|
||||
// Complete a run costing $4 (400 cents > 300 limit)
|
||||
const run = db.core.runs.createRun({
|
||||
companyId,
|
||||
taskId,
|
||||
agentId,
|
||||
workspaceId,
|
||||
connectorId: "gemini-cli",
|
||||
});
|
||||
db.core.tasks.checkoutTask(taskId, run.id);
|
||||
db.core.runs.markRunStatus(run.id, "running", "");
|
||||
db.core.runs.recordCostEntry({ runId: run.id, companyId, agentId, taskId, connectorId: "gemini-cli", amountUsd: 4.0, unattributed: false });
|
||||
db.core.runs.finishRun({
|
||||
runId: run.id,
|
||||
status: "succeeded",
|
||||
summary: "Done",
|
||||
errorMessage: null,
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
model: "gemini-pro",
|
||||
unattributedCost: false,
|
||||
});
|
||||
|
||||
// Budget gate now blocks
|
||||
const after = budgetService.evaluateBudgetGate(companyId, agentId, projectId);
|
||||
expect(after.allowed).toBe(false);
|
||||
expect(after.blockedBy.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("in-flight run cost entry is recorded even when budget would be exceeded", () => {
|
||||
const { companyId, workspaceId, agentId, projectId, taskId } = scaffold(db);
|
||||
const budgetService = createBudgetEnforcementService({
|
||||
db,
|
||||
logger: mockLogger,
|
||||
notify: mockNotify,
|
||||
publishDomainChanged: mockPublish,
|
||||
});
|
||||
|
||||
// Create tight budget
|
||||
db.core.budget.saveBudgetPolicy({
|
||||
companyId,
|
||||
scopeType: "company",
|
||||
scopeId: companyId,
|
||||
amount: 100,
|
||||
hardStopEnabled: true,
|
||||
});
|
||||
|
||||
// Run that will exceed budget on completion
|
||||
const run = db.core.runs.createRun({
|
||||
companyId,
|
||||
taskId,
|
||||
agentId,
|
||||
workspaceId,
|
||||
connectorId: "gemini-cli",
|
||||
});
|
||||
db.core.tasks.checkoutTask(taskId, run.id);
|
||||
db.core.runs.markRunStatus(run.id, "running", "");
|
||||
|
||||
// Complete with cost that exceeds budget -- cost entry is still recorded
|
||||
db.core.runs.recordCostEntry({ runId: run.id, companyId, agentId, taskId, connectorId: "gemini-cli", amountUsd: 5.0, unattributed: false });
|
||||
db.core.runs.finishRun({
|
||||
runId: run.id,
|
||||
status: "succeeded",
|
||||
summary: "Done",
|
||||
errorMessage: null,
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
model: "gemini-pro",
|
||||
unattributedCost: false,
|
||||
});
|
||||
|
||||
// Verify cost was recorded by checking budget gate sees the spend
|
||||
const result = budgetService.evaluateBudgetGate(companyId, agentId, projectId);
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.blockedBy[0].spent).toBe(500); // 500 cents = $5
|
||||
expect(result.blockedBy[0].limit).toBe(100);
|
||||
});
|
||||
});
|
||||
@@ -1,344 +0,0 @@
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("electron", () => ({
|
||||
safeStorage: {
|
||||
isEncryptionAvailable: () => false,
|
||||
getSelectedStorageBackend: () => "basic_text",
|
||||
encryptString: (value: string) => Buffer.from(value, "utf8"),
|
||||
decryptString: (value: Buffer) => value.toString("utf8"),
|
||||
},
|
||||
}));
|
||||
|
||||
import { AppDatabase, STALE_LOCK_TIMEOUT_MS } from "../../src/main/database";
|
||||
|
||||
type RawDb = {
|
||||
db: {
|
||||
prepare: (sql: string) => {
|
||||
run: (...args: unknown[]) => void;
|
||||
get: (...args: unknown[]) => unknown;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type TaskLeaseRow = {
|
||||
task_id: string;
|
||||
run_id: string;
|
||||
expires_at: string;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createTestCompany(db: AppDatabase): string {
|
||||
return db.core.company.saveCompany({
|
||||
name: "Edge Case Company",
|
||||
description: "Concurrent checkout edge case tests (D-05.1)",
|
||||
status: "active",
|
||||
});
|
||||
}
|
||||
|
||||
function createTestConnector(db: AppDatabase): void {
|
||||
db.core.connectors.saveConnector({
|
||||
id: "gemini-cli",
|
||||
model: "gemini-pro",
|
||||
envBindingText: "",
|
||||
notes: "Test connector for concurrent checkout",
|
||||
});
|
||||
}
|
||||
|
||||
function createTestWorkspace(db: AppDatabase, companyId: string): string {
|
||||
return db.core.workspaces.saveWorkspace({
|
||||
companyId,
|
||||
name: "Test Workspace",
|
||||
localPath: "/tmp/concurrent-test",
|
||||
repoUrl: "https://github.com/example/concurrent",
|
||||
repoRef: "main",
|
||||
isPrimary: true,
|
||||
});
|
||||
}
|
||||
|
||||
function createTestAgent(db: AppDatabase, companyId: string, workspaceId: string, name = "Agent"): string {
|
||||
return db.core.agents.saveAgent({
|
||||
companyId,
|
||||
name,
|
||||
role: "IC",
|
||||
title: name,
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "gemini-cli",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Executes tasks",
|
||||
budgetMonthlyUsd: 100,
|
||||
});
|
||||
}
|
||||
|
||||
function createTestTask(db: AppDatabase, companyId: string, workspaceId: string): string {
|
||||
return db.core.tasks.saveTask({
|
||||
companyId,
|
||||
title: "Concurrent Test Task",
|
||||
description: "A task for testing concurrent checkout edge cases",
|
||||
assigneeAgentId: null,
|
||||
workspaceId,
|
||||
priority: "medium",
|
||||
status: "todo",
|
||||
});
|
||||
}
|
||||
|
||||
function getTaskLease(db: AppDatabase, taskId: string): TaskLeaseRow | null {
|
||||
return (
|
||||
((db as unknown as RawDb).db
|
||||
.prepare("SELECT task_id, run_id, expires_at FROM task_leases WHERE task_id = ?")
|
||||
.get(taskId) as TaskLeaseRow | undefined) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests — Edge Case: Concurrent Task Checkout (D-05.1)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("Edge Case: Concurrent Task Checkout (D-05.1)", () => {
|
||||
let tempDir: string;
|
||||
let db: AppDatabase;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "agentcompany-concurrent-"));
|
||||
db = new AppDatabase(tempDir);
|
||||
db.init();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
db.close();
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("exactly one agent wins when two attempt simultaneous checkout", () => {
|
||||
const companyId = createTestCompany(db);
|
||||
createTestConnector(db);
|
||||
const workspaceId = createTestWorkspace(db, companyId);
|
||||
const agentA = createTestAgent(db, companyId, workspaceId, "Agent-A");
|
||||
const agentB = createTestAgent(db, companyId, workspaceId, "Agent-B");
|
||||
const taskId = createTestTask(db, companyId, workspaceId);
|
||||
|
||||
const { id: runIdA } = db.core.runs.createRun({
|
||||
companyId,
|
||||
taskId,
|
||||
agentId: agentA,
|
||||
workspaceId,
|
||||
connectorId: "gemini-cli",
|
||||
});
|
||||
const { id: runIdB } = db.core.runs.createRun({
|
||||
companyId,
|
||||
taskId,
|
||||
agentId: agentB,
|
||||
workspaceId,
|
||||
connectorId: "gemini-cli",
|
||||
});
|
||||
|
||||
// Agent-A claims first
|
||||
const resultA = db.core.tasks.checkoutTask(taskId, runIdA);
|
||||
expect(resultA).toBe(true);
|
||||
|
||||
// Agent-B tries to claim the same task
|
||||
const resultB = db.core.tasks.checkoutTask(taskId, runIdB);
|
||||
expect(resultB).toBe(false);
|
||||
|
||||
// Task status must be in_progress (set by first successful checkout)
|
||||
const task = db.core.entity.getTask(taskId);
|
||||
expect(task.status).toBe("in_progress");
|
||||
});
|
||||
|
||||
it("losing agent can checkout a different task", () => {
|
||||
const companyId = createTestCompany(db);
|
||||
createTestConnector(db);
|
||||
const workspaceId = createTestWorkspace(db, companyId);
|
||||
const agentA = createTestAgent(db, companyId, workspaceId, "Agent-A");
|
||||
const agentB = createTestAgent(db, companyId, workspaceId, "Agent-B");
|
||||
const taskId1 = createTestTask(db, companyId, workspaceId);
|
||||
|
||||
const { id: runIdA } = db.core.runs.createRun({
|
||||
companyId,
|
||||
taskId: taskId1,
|
||||
agentId: agentA,
|
||||
workspaceId,
|
||||
connectorId: "gemini-cli",
|
||||
});
|
||||
const { id: runIdB1 } = db.core.runs.createRun({
|
||||
companyId,
|
||||
taskId: taskId1,
|
||||
agentId: agentB,
|
||||
workspaceId,
|
||||
connectorId: "gemini-cli",
|
||||
});
|
||||
|
||||
// Agent-A wins first task
|
||||
expect(db.core.tasks.checkoutTask(taskId1, runIdA)).toBe(true);
|
||||
// Agent-B loses
|
||||
expect(db.core.tasks.checkoutTask(taskId1, runIdB1)).toBe(false);
|
||||
|
||||
// Agent-B can still checkout a DIFFERENT task
|
||||
const taskId2 = createTestTask(db, companyId, workspaceId);
|
||||
const { id: runIdB2 } = db.core.runs.createRun({
|
||||
companyId,
|
||||
taskId: taskId2,
|
||||
agentId: agentB,
|
||||
workspaceId,
|
||||
connectorId: "gemini-cli",
|
||||
});
|
||||
const resultB2 = db.core.tasks.checkoutTask(taskId2, runIdB2);
|
||||
expect(resultB2).toBe(true);
|
||||
|
||||
// Verify second task is now in_progress
|
||||
const task2 = db.core.entity.getTask(taskId2);
|
||||
expect(task2.status).toBe("in_progress");
|
||||
});
|
||||
|
||||
it("checkout of in_progress task is rejected", () => {
|
||||
const companyId = createTestCompany(db);
|
||||
createTestConnector(db);
|
||||
const workspaceId = createTestWorkspace(db, companyId);
|
||||
const agentA = createTestAgent(db, companyId, workspaceId, "Agent-A");
|
||||
const agentB = createTestAgent(db, companyId, workspaceId, "Agent-B");
|
||||
const taskId = createTestTask(db, companyId, workspaceId);
|
||||
|
||||
// Agent-A checks out successfully
|
||||
const { id: runIdA } = db.core.runs.createRun({
|
||||
companyId,
|
||||
taskId,
|
||||
agentId: agentA,
|
||||
workspaceId,
|
||||
connectorId: "gemini-cli",
|
||||
});
|
||||
expect(db.core.tasks.checkoutTask(taskId, runIdA)).toBe(true);
|
||||
|
||||
// Task is now in_progress. A new run from Agent-B tries to checkout
|
||||
const { id: runIdB } = db.core.runs.createRun({
|
||||
companyId,
|
||||
taskId,
|
||||
agentId: agentB,
|
||||
workspaceId,
|
||||
connectorId: "gemini-cli",
|
||||
});
|
||||
const result = db.core.tasks.checkoutTask(taskId, runIdB);
|
||||
expect(result).toBe(false);
|
||||
|
||||
// Confirm status is still in_progress
|
||||
expect(db.core.entity.getTask(taskId).status).toBe("in_progress");
|
||||
});
|
||||
|
||||
it("after lock release, CAS guard still checks task status", () => {
|
||||
const companyId = createTestCompany(db);
|
||||
createTestConnector(db);
|
||||
const workspaceId = createTestWorkspace(db, companyId);
|
||||
const agentA = createTestAgent(db, companyId, workspaceId, "Agent-A");
|
||||
const agentB = createTestAgent(db, companyId, workspaceId, "Agent-B");
|
||||
const taskId = createTestTask(db, companyId, workspaceId);
|
||||
|
||||
// Agent-A checks out task
|
||||
const { id: runIdA } = db.core.runs.createRun({
|
||||
companyId,
|
||||
taskId,
|
||||
agentId: agentA,
|
||||
workspaceId,
|
||||
connectorId: "gemini-cli",
|
||||
});
|
||||
expect(db.core.tasks.checkoutTask(taskId, runIdA)).toBe(true);
|
||||
|
||||
// Release the lease while leaving the task lifecycle unchanged.
|
||||
db.core.tasks.releaseTaskLock(taskId, runIdA);
|
||||
|
||||
expect(getTaskLease(db, taskId)).toBeNull();
|
||||
|
||||
// Task is still in_progress though, so a new checkout fails
|
||||
// (CAS checks status IN ('todo', 'backlog'))
|
||||
const { id: runIdB } = db.core.runs.createRun({
|
||||
companyId,
|
||||
taskId,
|
||||
agentId: agentB,
|
||||
workspaceId,
|
||||
connectorId: "gemini-cli",
|
||||
});
|
||||
const result = db.core.tasks.checkoutTask(taskId, runIdB);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("stale lock recovery frees locks older than timeout", () => {
|
||||
const companyId = createTestCompany(db);
|
||||
createTestConnector(db);
|
||||
const workspaceId = createTestWorkspace(db, companyId);
|
||||
const agentA = createTestAgent(db, companyId, workspaceId, "Agent-A");
|
||||
const taskId = createTestTask(db, companyId, workspaceId);
|
||||
|
||||
const { id: runIdA } = db.core.runs.createRun({
|
||||
companyId,
|
||||
taskId,
|
||||
agentId: agentA,
|
||||
workspaceId,
|
||||
connectorId: "gemini-cli",
|
||||
});
|
||||
expect(db.core.tasks.checkoutTask(taskId, runIdA)).toBe(true);
|
||||
|
||||
// Backdate the lease expiry past the stale timeout.
|
||||
(db as unknown as RawDb).db
|
||||
.prepare("UPDATE task_leases SET expires_at = '2020-01-01T00:00:00Z' WHERE task_id = ?")
|
||||
.run(taskId);
|
||||
|
||||
const recovered = db.recoverStaleLocks();
|
||||
expect(recovered.length).toBe(1);
|
||||
expect(recovered[0].taskId).toBe(taskId);
|
||||
expect(recovered[0].companyId).toBe(companyId);
|
||||
|
||||
expect(getTaskLease(db, taskId)).toBeNull();
|
||||
});
|
||||
|
||||
it("after stale recovery, different agent can checkout the recovered task", () => {
|
||||
const companyId = createTestCompany(db);
|
||||
createTestConnector(db);
|
||||
const workspaceId = createTestWorkspace(db, companyId);
|
||||
const agentA = createTestAgent(db, companyId, workspaceId, "Agent-A");
|
||||
const agentB = createTestAgent(db, companyId, workspaceId, "Agent-B");
|
||||
const taskId = createTestTask(db, companyId, workspaceId);
|
||||
|
||||
// Agent-A checks out, then lock goes stale
|
||||
const { id: runIdA } = db.core.runs.createRun({
|
||||
companyId,
|
||||
taskId,
|
||||
agentId: agentA,
|
||||
workspaceId,
|
||||
connectorId: "gemini-cli",
|
||||
});
|
||||
expect(db.core.tasks.checkoutTask(taskId, runIdA)).toBe(true);
|
||||
|
||||
// Backdate the lease to make it stale.
|
||||
(db as unknown as RawDb).db
|
||||
.prepare("UPDATE task_leases SET expires_at = '2020-01-01T00:00:00Z' WHERE task_id = ?")
|
||||
.run(taskId);
|
||||
|
||||
// Recover stale locks (sets status back to "todo")
|
||||
const recovered = db.recoverStaleLocks();
|
||||
expect(recovered.length).toBe(1);
|
||||
|
||||
// Verify task is back to "todo"
|
||||
expect(db.core.entity.getTask(taskId).status).toBe("todo");
|
||||
|
||||
// Agent-B can now checkout the recovered task
|
||||
const { id: runIdB } = db.core.runs.createRun({
|
||||
companyId,
|
||||
taskId,
|
||||
agentId: agentB,
|
||||
workspaceId,
|
||||
connectorId: "gemini-cli",
|
||||
});
|
||||
const result = db.core.tasks.checkoutTask(taskId, runIdB);
|
||||
expect(result).toBe(true);
|
||||
expect(db.core.entity.getTask(taskId).status).toBe("in_progress");
|
||||
});
|
||||
|
||||
it("STALE_LOCK_TIMEOUT_MS matches expected 15 minute value", () => {
|
||||
expect(STALE_LOCK_TIMEOUT_MS).toBe(15 * 60 * 1000);
|
||||
});
|
||||
});
|
||||
@@ -1,336 +0,0 @@
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("electron", () => ({
|
||||
safeStorage: {
|
||||
isEncryptionAvailable: () => false,
|
||||
getSelectedStorageBackend: () => "basic_text",
|
||||
encryptString: (value: string) => Buffer.from(value, "utf8"),
|
||||
decryptString: (value: Buffer) => value.toString("utf8"),
|
||||
},
|
||||
}));
|
||||
|
||||
import { AppDatabase } from "../../src/main/database";
|
||||
import { createSessionManagementService } from "../../src/main/session-management-service";
|
||||
import type { ConnectorId } from "@shared/types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function scaffold(db: AppDatabase, connectorId: ConnectorId = "gemini-cli") {
|
||||
const companyId = db.core.company.saveCompany({
|
||||
name: "Session Edge Case Co",
|
||||
description: "Session rotation edge case tests (D-05.3)",
|
||||
status: "active",
|
||||
});
|
||||
|
||||
// Always save gemini-cli (needed for most tests)
|
||||
db.core.connectors.saveConnector({
|
||||
id: "gemini-cli",
|
||||
model: "gemini-pro",
|
||||
envBindingText: "",
|
||||
notes: "Test connector - no native context management",
|
||||
});
|
||||
|
||||
const workspaceId = db.core.workspaces.saveWorkspace({
|
||||
companyId,
|
||||
name: "Primary",
|
||||
localPath: "/tmp/session-edge-test",
|
||||
repoUrl: "",
|
||||
repoRef: "",
|
||||
isPrimary: true,
|
||||
});
|
||||
const agentId = db.core.agents.saveAgent({
|
||||
companyId,
|
||||
name: "Worker Agent",
|
||||
role: "developer",
|
||||
title: "Developer",
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId,
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Executes tasks",
|
||||
budgetMonthlyUsd: 100,
|
||||
});
|
||||
const goalId = db.core.entity.saveGoal({
|
||||
companyId,
|
||||
title: "Ship feature",
|
||||
description: "Build session rotation edge case test",
|
||||
ownerAgentId: agentId,
|
||||
status: "active",
|
||||
});
|
||||
const projectId = db.core.entity.saveProject({
|
||||
companyId,
|
||||
goalId,
|
||||
name: "Session Project",
|
||||
description: "Testing session rotation during execution",
|
||||
leadAgentId: agentId,
|
||||
status: "in_progress",
|
||||
targetDate: null,
|
||||
});
|
||||
const taskId = db.core.tasks.saveTask({
|
||||
companyId,
|
||||
projectId,
|
||||
goalId,
|
||||
title: "Implement session rotation feature",
|
||||
description: "Build the session rotation logic for task execution edge cases.",
|
||||
assigneeAgentId: agentId,
|
||||
workspaceId,
|
||||
priority: "high",
|
||||
status: "in_progress",
|
||||
});
|
||||
return { companyId, workspaceId, agentId, goalId, projectId, taskId };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests -- Edge Case: Session Rotation During Execution (D-05.3)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("Edge Case: Session Rotation During Execution (D-05.3)", () => {
|
||||
let tempDir: string;
|
||||
let db: AppDatabase;
|
||||
const mockLogger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "agentcompany-session-edge-"));
|
||||
db = new AppDatabase(tempDir);
|
||||
db.init();
|
||||
mockLogger.info.mockClear();
|
||||
mockLogger.warn.mockClear();
|
||||
mockLogger.error.mockClear();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
db.close();
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("rotation triggered when run count exceeds maxRuns threshold", () => {
|
||||
const { companyId, agentId } = scaffold(db);
|
||||
const sessionService = createSessionManagementService({ db, logger: mockLogger });
|
||||
|
||||
// Create a session
|
||||
const sessionId = db.core.tasks.createNewSession({
|
||||
companyId,
|
||||
agentId,
|
||||
adapterType: "gemini-cli",
|
||||
});
|
||||
|
||||
// Simulate runs until we reach the maxRuns threshold (20)
|
||||
for (let i = 0; i < 20; i++) {
|
||||
db.core.tasks.incrementSessionUsage(sessionId, 1000, 500, `run-${i}`);
|
||||
}
|
||||
|
||||
// Get the updated session from DB
|
||||
const session = db.core.tasks.getSessionById(sessionId);
|
||||
expect(session).not.toBeNull();
|
||||
expect(session!.runCount).toBe(20); // matches maxRuns threshold in session-management-service
|
||||
|
||||
// Evaluate rotation -- should trigger
|
||||
const result = sessionService.evaluateRotation(session!);
|
||||
expect(result.shouldRotate).toBe(true);
|
||||
expect(result.reason).toBe("max_runs");
|
||||
});
|
||||
|
||||
it("rotation triggered when token count exceeds maxTokens threshold", () => {
|
||||
const { companyId, agentId } = scaffold(db);
|
||||
const sessionService = createSessionManagementService({ db, logger: mockLogger });
|
||||
|
||||
const sessionId = db.core.tasks.createNewSession({
|
||||
companyId,
|
||||
agentId,
|
||||
adapterType: "gemini-cli",
|
||||
});
|
||||
|
||||
// Accumulate tokens past the 500_000 maxTokens threshold in one big call
|
||||
db.core.tasks.incrementSessionUsage(sessionId, 300000, 250000, "big-run");
|
||||
|
||||
const session = db.core.tasks.getSessionById(sessionId);
|
||||
expect(session).not.toBeNull();
|
||||
const totalTokens = session!.totalInputTokens + session!.totalOutputTokens;
|
||||
expect(totalTokens).toBe(550000);
|
||||
|
||||
const result = sessionService.evaluateRotation(session!);
|
||||
expect(result.shouldRotate).toBe(true);
|
||||
expect(result.reason).toBe("max_tokens");
|
||||
});
|
||||
|
||||
it("handoff markdown generated before next run starts", () => {
|
||||
const { companyId, agentId, taskId, workspaceId } = scaffold(db);
|
||||
const sessionService = createSessionManagementService({ db, logger: mockLogger });
|
||||
|
||||
const sessionId = db.core.tasks.createNewSession({
|
||||
companyId,
|
||||
agentId,
|
||||
adapterType: "gemini-cli",
|
||||
});
|
||||
|
||||
// Accumulate enough to trigger rotation
|
||||
for (let i = 0; i < 20; i++) {
|
||||
db.core.tasks.incrementSessionUsage(sessionId, 1000, 500, `run-${i}`);
|
||||
}
|
||||
|
||||
// Create a completed run for history (so generateHandoff has run data)
|
||||
const run = db.core.runs.createRun({
|
||||
companyId,
|
||||
taskId,
|
||||
agentId,
|
||||
workspaceId,
|
||||
connectorId: "gemini-cli" as ConnectorId,
|
||||
});
|
||||
db.core.runs.finishRun({
|
||||
runId: run.id,
|
||||
status: "succeeded",
|
||||
summary: "Completed initial implementation",
|
||||
errorMessage: null,
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
model: "gemini-pro",
|
||||
unattributedCost: false,
|
||||
});
|
||||
|
||||
const session = db.core.tasks.getSessionById(sessionId)!;
|
||||
// generateHandoff is agent-scoped: it walks the agent's runs across
|
||||
// all tasks + taskless heartbeats, not a single task.
|
||||
const handoff = sessionService.generateHandoff(session);
|
||||
|
||||
// Verify handoff markdown content
|
||||
expect(handoff).toContain("# Session Handoff");
|
||||
expect(handoff).toContain("## Recent Run History");
|
||||
|
||||
// Archive the old session with handoff
|
||||
db.core.tasks.archiveSession(sessionId, "max_runs", handoff);
|
||||
|
||||
// Verify archived session has handoff stored
|
||||
const archivedRows = db.queryAll<{ handoffMarkdown: string | null }>(
|
||||
"SELECT handoff_markdown as handoffMarkdown FROM task_sessions WHERE id = ?",
|
||||
sessionId,
|
||||
);
|
||||
expect(archivedRows[0].handoffMarkdown).toContain("# Session Handoff");
|
||||
});
|
||||
|
||||
it("new session created after rotation with clean counters", () => {
|
||||
const { companyId, agentId } = scaffold(db);
|
||||
|
||||
// Create initial session
|
||||
const oldSessionId = db.core.tasks.createNewSession({
|
||||
companyId,
|
||||
agentId,
|
||||
adapterType: "gemini-cli",
|
||||
});
|
||||
|
||||
// Add usage to the old session
|
||||
db.core.tasks.incrementSessionUsage(oldSessionId, 300000, 250000, "run-final");
|
||||
|
||||
// Archive the old session
|
||||
db.core.tasks.archiveSession(oldSessionId, "max_tokens", "# Session Handoff\nPrevious work summary.");
|
||||
|
||||
// Create new session
|
||||
const newSessionId = db.core.tasks.createNewSession({
|
||||
companyId,
|
||||
agentId,
|
||||
adapterType: "gemini-cli",
|
||||
});
|
||||
|
||||
// Get the new active session
|
||||
const newSession = db.core.tasks.getSessionById(newSessionId);
|
||||
expect(newSession).not.toBeNull();
|
||||
expect(newSession!.id).toBe(newSessionId);
|
||||
expect(newSession!.id).not.toBe(oldSessionId);
|
||||
|
||||
// Verify clean counters
|
||||
expect(newSession!.runCount).toBe(0);
|
||||
expect(newSession!.totalInputTokens).toBe(0);
|
||||
expect(newSession!.totalOutputTokens).toBe(0);
|
||||
});
|
||||
|
||||
it("claude connector triggers rotation under pi-mono (no native exemption)", () => {
|
||||
const { companyId, agentId } = scaffold(db, "gemini-cli");
|
||||
const sessionService = createSessionManagementService({ db, logger: mockLogger });
|
||||
|
||||
const sessionId = db.core.tasks.createNewSession({
|
||||
companyId,
|
||||
agentId,
|
||||
adapterType: "claude-pro",
|
||||
});
|
||||
|
||||
// Accumulate way past all thresholds
|
||||
for (let i = 0; i < 25; i++) {
|
||||
db.core.tasks.incrementSessionUsage(sessionId, 30000, 20000, `run-${i}`);
|
||||
}
|
||||
|
||||
const session = db.core.tasks.getSessionById(sessionId)!;
|
||||
// Pi-mono delegates compaction to pi-agent-core; AgentCompany still applies rotation uniformly.
|
||||
const result = sessionService.evaluateRotation(session);
|
||||
expect(result.shouldRotate).toBe(true);
|
||||
expect(result.reason).not.toBeNull();
|
||||
});
|
||||
|
||||
it("codex connector triggers rotation under pi-mono (no native exemption)", () => {
|
||||
const { companyId, agentId } = scaffold(db, "gemini-cli");
|
||||
const sessionService = createSessionManagementService({ db, logger: mockLogger });
|
||||
|
||||
const sessionId = db.core.tasks.createNewSession({
|
||||
companyId,
|
||||
agentId,
|
||||
adapterType: "openai-codex",
|
||||
});
|
||||
|
||||
// Accumulate way past all thresholds
|
||||
for (let i = 0; i < 25; i++) {
|
||||
db.core.tasks.incrementSessionUsage(sessionId, 30000, 20000, `run-${i}`);
|
||||
}
|
||||
|
||||
const session = db.core.tasks.getSessionById(sessionId)!;
|
||||
// Pi-mono delegates compaction to pi-agent-core; AgentCompany still applies rotation uniformly.
|
||||
const result = sessionService.evaluateRotation(session);
|
||||
expect(result.shouldRotate).toBe(true);
|
||||
expect(result.reason).not.toBeNull();
|
||||
});
|
||||
|
||||
it("handoff markdown available for next run context after rotation", () => {
|
||||
const { companyId, agentId, taskId, workspaceId } = scaffold(db);
|
||||
const sessionService = createSessionManagementService({ db, logger: mockLogger });
|
||||
|
||||
// Create session and add usage
|
||||
const sessionId = db.core.tasks.createNewSession({
|
||||
companyId,
|
||||
agentId,
|
||||
adapterType: "gemini-cli",
|
||||
});
|
||||
db.core.tasks.incrementSessionUsage(sessionId, 300000, 200000, "run-1");
|
||||
|
||||
// Create a completed run for run history
|
||||
const run = db.core.runs.createRun({
|
||||
companyId,
|
||||
taskId,
|
||||
agentId,
|
||||
workspaceId,
|
||||
connectorId: "gemini-cli" as ConnectorId,
|
||||
});
|
||||
db.core.runs.finishRun({
|
||||
runId: run.id,
|
||||
status: "succeeded",
|
||||
summary: "Previous work done",
|
||||
errorMessage: null,
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
model: "gemini-pro",
|
||||
unattributedCost: false,
|
||||
});
|
||||
|
||||
// Rotate the session (archives old, creates new)
|
||||
const oldSession = db.core.tasks.getSessionById(sessionId)!;
|
||||
sessionService.rotateSession(oldSession);
|
||||
|
||||
// After rotation, the handoff should be retrievable for the next run
|
||||
const handoff = sessionService.getHandoffContext(taskId);
|
||||
expect(handoff).not.toBeNull();
|
||||
expect(handoff).toContain("# Session Handoff");
|
||||
expect(handoff).toContain("Implement session rotation feature"); // task title from scaffold
|
||||
});
|
||||
});
|
||||
@@ -1,19 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ErrorBoundary } from "../../src/renderer/components/ui";
|
||||
|
||||
describe("ErrorBoundary", () => {
|
||||
it("resets a captured page error when the section changes", () => {
|
||||
const error = new Error("boom");
|
||||
const failedState = {
|
||||
hasError: true,
|
||||
error,
|
||||
resetKey: "runs",
|
||||
};
|
||||
|
||||
expect(ErrorBoundary.getDerivedStateFromProps({ children: null, resetKey: "tasks" }, failedState)).toEqual({
|
||||
hasError: false,
|
||||
error: null,
|
||||
resetKey: "tasks",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,42 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("electron-log/main.js", () => ({
|
||||
default: {
|
||||
warn: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import log from "electron-log/main.js";
|
||||
import { classifyUnknownError } from "../../src/main/errors/taxonomy";
|
||||
|
||||
const warn = vi.mocked(log.warn);
|
||||
|
||||
describe("error taxonomy", () => {
|
||||
beforeEach(() => {
|
||||
warn.mockClear();
|
||||
});
|
||||
|
||||
it("warns once when raw thrown values cannot be JSON stringified", () => {
|
||||
const raw: { self?: unknown } = {};
|
||||
raw.self = raw;
|
||||
|
||||
const first = classifyUnknownError(raw, {
|
||||
kind: "recoverable",
|
||||
code: "test.raw.cyclic",
|
||||
message: "Cyclic raw value.",
|
||||
});
|
||||
const second = classifyUnknownError(raw, {
|
||||
kind: "recoverable",
|
||||
code: "test.raw.cyclic",
|
||||
message: "Cyclic raw value.",
|
||||
});
|
||||
|
||||
expect(first.context?.raw).toBe("[object Object]");
|
||||
expect(second.context?.raw).toBe("[object Object]");
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
"[errors] stringifyRaw failed; using String(value) for raw thrown value.",
|
||||
expect.stringContaining("circular"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { DatabaseConnection } from "../../src/main/database/connection";
|
||||
import { OrganizationService } from "../../src/main/services/organization-service";
|
||||
import { ChatService } from "../../src/main/services/chat-service";
|
||||
import { dispatchAgentTool, type AgentToolContext } from "../../src/main/services/agent-tools";
|
||||
|
||||
function call(name: string, args: Record<string, unknown>) {
|
||||
return { id: `call_${randomUUID()}`, name, arguments: args };
|
||||
}
|
||||
|
||||
interface Harness {
|
||||
connection: DatabaseConnection;
|
||||
orgId: string;
|
||||
ceoId: string;
|
||||
ctx: AgentToolContext;
|
||||
deps: { db: DatabaseConnection["db"]; organization: OrganizationService; chat: ChatService };
|
||||
}
|
||||
|
||||
function harness(mode: "ai_ceo" | "ai_cofounder"): Harness {
|
||||
const connection = new DatabaseConnection(join(tmpdir(), `ac-mode-${randomUUID()}.db`));
|
||||
const organization = new OrganizationService(connection.db);
|
||||
const workspaceRoot = join(tmpdir(), `ac-mode-ws-${randomUUID()}`);
|
||||
mkdirSync(workspaceRoot, { recursive: true });
|
||||
let orgId = "";
|
||||
let ceoId = "";
|
||||
let workspaceId = "";
|
||||
connection.transaction(() => {
|
||||
orgId = organization.createOrganization({
|
||||
organizationName: "OrbitOps",
|
||||
employeeName: "Yang",
|
||||
mission: "m",
|
||||
goal: "g",
|
||||
executionMode: mode,
|
||||
autoApproveHires: mode === "ai_ceo",
|
||||
reviewDeliverables: mode === "ai_cofounder",
|
||||
});
|
||||
const providerId = (connection.db.prepare("select id from providers where org_id = ? and provider_key = 'openai'").get(orgId) as { id: string }).id;
|
||||
ceoId = organization.createAgent({
|
||||
orgId, name: "CEO", role: "CEO", department: "Executive",
|
||||
modelProviderId: providerId, modelId: "gpt-5.4", budgetCents: 25000, managerAgentId: null, roleLevel: "c_suite",
|
||||
});
|
||||
workspaceId = organization.createWorkspace({ orgId, name: "HQ", localPath: workspaceRoot, projectId: null });
|
||||
});
|
||||
return {
|
||||
connection,
|
||||
orgId,
|
||||
ceoId,
|
||||
ctx: {
|
||||
orgId,
|
||||
agent: { id: ceoId, modelProviderId: null, modelId: null },
|
||||
taskProjectId: null,
|
||||
taskGoalId: null,
|
||||
workspace: { id: workspaceId, localPath: workspaceRoot },
|
||||
},
|
||||
deps: { db: connection.db, organization, chat: new ChatService(connection.db) },
|
||||
};
|
||||
}
|
||||
|
||||
function pendingApprovals(h: Harness, kind: string): Array<{ state: string; source_entity_type: string | null }> {
|
||||
return h.connection.db.prepare(
|
||||
"select state, source_entity_type from approvals where org_id = ? and kind = ?",
|
||||
).all(h.orgId, kind) as Array<{ state: string; source_entity_type: string | null }>;
|
||||
}
|
||||
|
||||
describe("execution modes", () => {
|
||||
test("AI Co-Founder mode queues every deliverable and document for board review", () => {
|
||||
const h = harness("ai_cofounder");
|
||||
const wrote = dispatchAgentTool(h.deps, h.ctx, call("write_deliverable", { path: "plan/business-plan.md", content: "# Plan" }));
|
||||
expect(wrote).toContain("queued for board review");
|
||||
const doc = dispatchAgentTool(h.deps, h.ctx, call("create_document", { title: "Launch memo", doc_type: "memo", content: "Ship it." }));
|
||||
expect(doc).toContain("queued for board review");
|
||||
|
||||
const reviews = pendingApprovals(h, "deliverable_review");
|
||||
expect(reviews).toHaveLength(2);
|
||||
expect(reviews.every((review) => review.state === "pending")).toBe(true);
|
||||
expect(reviews.map((review) => review.source_entity_type).sort()).toEqual(["document", "file"]);
|
||||
});
|
||||
|
||||
test("AI CEO mode ships deliverables without review", () => {
|
||||
const h = harness("ai_ceo");
|
||||
const wrote = dispatchAgentTool(h.deps, h.ctx, call("write_deliverable", { path: "plan/business-plan.md", content: "# Plan" }));
|
||||
expect(wrote).not.toContain("board review");
|
||||
dispatchAgentTool(h.deps, h.ctx, call("create_document", { title: "Launch memo", doc_type: "memo", content: "Ship it." }));
|
||||
expect(pendingApprovals(h, "deliverable_review")).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("AI Co-Founder mode holds above-threshold hires; AI CEO mode clears them", () => {
|
||||
// Hiring policies require review above $5,000/month; the mode decides
|
||||
// whether that review waits for the operator or clears automatically.
|
||||
const hireAva = (h: Harness) => {
|
||||
const providerId = (h.connection.db.prepare("select id from providers where org_id = ? and provider_key = 'openai'").get(h.orgId) as { id: string }).id;
|
||||
h.connection.transaction(() => h.deps.organization.createAgent({
|
||||
orgId: h.orgId, name: "Ava", role: "Designer", department: "Product",
|
||||
modelProviderId: providerId, modelId: "gpt-5.4", budgetCents: 600000, managerAgentId: h.ceoId,
|
||||
}));
|
||||
};
|
||||
|
||||
const cofounder = harness("ai_cofounder");
|
||||
hireAva(cofounder);
|
||||
const held = cofounder.connection.db.prepare(
|
||||
"select state from approvals where org_id = ? and kind = 'agent_hire' and title like '%Ava%'",
|
||||
).get(cofounder.orgId) as { state: string };
|
||||
expect(held.state).toBe("pending");
|
||||
|
||||
const ceoMode = harness("ai_ceo");
|
||||
hireAva(ceoMode);
|
||||
const cleared = ceoMode.connection.db.prepare(
|
||||
"select state from approvals where org_id = ? and kind = 'agent_hire' and title like '%Ava%'",
|
||||
).get(ceoMode.orgId) as { state: string };
|
||||
expect(cleared.state).toBe("approved");
|
||||
});
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
classifyExternalUrl,
|
||||
normalizeExternalHttpUrl,
|
||||
} from "../../src/main/external-url-policy";
|
||||
|
||||
describe("external URL policy", () => {
|
||||
it("normalizes http and https URLs", () => {
|
||||
expect(normalizeExternalHttpUrl(" https://github.com/agentcompany ")).toBe("https://github.com/agentcompany");
|
||||
expect(normalizeExternalHttpUrl("http://example.com/path?q=1")).toBe("http://example.com/path?q=1");
|
||||
});
|
||||
|
||||
it("blocks custom schemes and credential-bearing URLs", () => {
|
||||
expect(classifyExternalUrl("vscode://file/etc/passwd")).toMatchObject({ kind: "blocked" });
|
||||
expect(classifyExternalUrl("https://user:pass@example.com/report")).toMatchObject({ kind: "blocked" });
|
||||
});
|
||||
|
||||
it("separates trusted hosts from confirmation hosts", () => {
|
||||
expect(classifyExternalUrl("https://github.com/acme/repo")).toMatchObject({ kind: "trusted", host: "github.com" });
|
||||
expect(classifyExternalUrl("http://github.com/acme/repo")).toMatchObject({ kind: "confirm", host: "github.com" });
|
||||
expect(classifyExternalUrl("https://example.com/report")).toMatchObject({ kind: "confirm", host: "example.com" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { normalizeExternalBrowserUrl } from "../../src/main/app/external-url";
|
||||
|
||||
describe("external browser URL validation", () => {
|
||||
test("normalizes HTTPS URLs and preserves query strings", () => {
|
||||
expect(normalizeExternalBrowserUrl("https://example.com/docs?q=agent")).toBe("https://example.com/docs?q=agent");
|
||||
});
|
||||
|
||||
test("rejects non-HTTPS and credential-bearing URLs", () => {
|
||||
expect(() => normalizeExternalBrowserUrl("http://example.com")).toThrow("External URLs must use HTTPS.");
|
||||
expect(() => normalizeExternalBrowserUrl("mailto:security@example.com")).toThrow("External URLs must use HTTPS.");
|
||||
expect(() => normalizeExternalBrowserUrl("https://user:token@example.com")).toThrow("External URLs must not include credentials.");
|
||||
});
|
||||
});
|
||||
@@ -1,241 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
handleFailureEscalation,
|
||||
handleSuccessReset,
|
||||
type EscalationDeps,
|
||||
} from "@main/worker/observability/failure-escalation";
|
||||
|
||||
/**
|
||||
* Tests for the retry-aware failure escalation pipeline.
|
||||
*
|
||||
* The escalation state machine:
|
||||
* 1. Task fails with retry_count < MAX_RETRY_ATTEMPTS (2) -> retry same agent after cooldown
|
||||
* 2. Task fails with retry_count >= MAX_RETRY_ATTEMPTS -> escalate to manager chain
|
||||
* 3. No manager in chain -> fall through to CEO
|
||||
* 4. No CEO available -> notify user
|
||||
* 5. Task succeeds -> reset retry count
|
||||
*/
|
||||
|
||||
const MAX_RETRY_ATTEMPTS = 2;
|
||||
const RETRY_COOLDOWN_MS = 30_000;
|
||||
|
||||
function createMockDeps(): EscalationDeps {
|
||||
return {
|
||||
db: {
|
||||
core: {
|
||||
tasks: {
|
||||
getTaskRetryCount: vi.fn().mockReturnValue(0),
|
||||
incrementTaskRetryCount: vi.fn(),
|
||||
resetTaskRetryCount: vi.fn(),
|
||||
advanceTaskStatus: vi.fn().mockReturnValue({ previousStatus: "in_progress" }),
|
||||
},
|
||||
agents: {
|
||||
getChainOfCommand: vi.fn().mockReturnValue([]),
|
||||
getCeoForCompany: vi.fn().mockReturnValue(null),
|
||||
},
|
||||
},
|
||||
},
|
||||
wakeAgentIfPossible: vi.fn().mockReturnValue("wake-id"),
|
||||
notify: vi.fn(),
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const RUN = { taskId: "task-1", agentId: "agent-1", companyId: "company-1" };
|
||||
const TASK = { title: "Implement feature X" };
|
||||
|
||||
describe("Failure Escalation Pipeline", () => {
|
||||
let deps: EscalationDeps;
|
||||
let scheduleRetry: ReturnType<typeof vi.fn<(fn: () => void, delayMs: number) => void>>;
|
||||
|
||||
beforeEach(() => {
|
||||
deps = createMockDeps();
|
||||
scheduleRetry = vi.fn<(fn: () => void, delayMs: number) => void>();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("Retry path (count < MAX_RETRY_ATTEMPTS)", () => {
|
||||
it("retries on first failure (retry_count=0)", () => {
|
||||
(deps.db.core.tasks.getTaskRetryCount as ReturnType<typeof vi.fn>).mockReturnValue(0);
|
||||
|
||||
const result = handleFailureEscalation(
|
||||
deps, RUN, TASK, "failed", MAX_RETRY_ATTEMPTS, RETRY_COOLDOWN_MS, scheduleRetry,
|
||||
);
|
||||
|
||||
expect(result.action).toBe("retry");
|
||||
expect(deps.db.core.tasks.incrementTaskRetryCount).toHaveBeenCalledWith("task-1");
|
||||
expect(deps.db.core.tasks.advanceTaskStatus).toHaveBeenCalledWith(
|
||||
"task-1",
|
||||
"todo",
|
||||
expect.stringContaining("attempt 1/2"),
|
||||
);
|
||||
expect(scheduleRetry).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("retries on second failure (retry_count=1)", () => {
|
||||
(deps.db.core.tasks.getTaskRetryCount as ReturnType<typeof vi.fn>).mockReturnValue(1);
|
||||
|
||||
const result = handleFailureEscalation(
|
||||
deps, RUN, TASK, "failed", MAX_RETRY_ATTEMPTS, RETRY_COOLDOWN_MS, scheduleRetry,
|
||||
);
|
||||
|
||||
expect(result.action).toBe("retry");
|
||||
expect(deps.db.core.tasks.incrementTaskRetryCount).toHaveBeenCalledWith("task-1");
|
||||
expect(deps.db.core.tasks.advanceTaskStatus).toHaveBeenCalledWith(
|
||||
"task-1",
|
||||
"todo",
|
||||
expect.stringContaining("attempt 2/2"),
|
||||
);
|
||||
expect(scheduleRetry).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls wakeAgentIfPossible via scheduleRetry callback", () => {
|
||||
(deps.db.core.tasks.getTaskRetryCount as ReturnType<typeof vi.fn>).mockReturnValue(0);
|
||||
|
||||
handleFailureEscalation(
|
||||
deps, RUN, TASK, "failed", MAX_RETRY_ATTEMPTS, RETRY_COOLDOWN_MS, scheduleRetry,
|
||||
);
|
||||
|
||||
// Invoke the scheduled callback synchronously to verify it wakes the agent
|
||||
const [fn] = scheduleRetry.mock.calls[0]!;
|
||||
fn();
|
||||
expect(deps.wakeAgentIfPossible).toHaveBeenCalledWith("agent-1", "company-1", "retry_failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Escalation path (count >= MAX_RETRY_ATTEMPTS)", () => {
|
||||
it("escalates to chain[0] manager after max retries", () => {
|
||||
(deps.db.core.tasks.getTaskRetryCount as ReturnType<typeof vi.fn>).mockReturnValue(2);
|
||||
(deps.db.core.agents.getChainOfCommand as ReturnType<typeof vi.fn>).mockReturnValue([
|
||||
{ id: "manager-1", name: "Manager Alpha" },
|
||||
]);
|
||||
|
||||
const result = handleFailureEscalation(
|
||||
deps, RUN, TASK, "failed", MAX_RETRY_ATTEMPTS, RETRY_COOLDOWN_MS, scheduleRetry,
|
||||
);
|
||||
|
||||
expect(result.action).toBe("escalated");
|
||||
expect(deps.db.core.tasks.resetTaskRetryCount).toHaveBeenCalledWith("task-1");
|
||||
expect(deps.wakeAgentIfPossible).toHaveBeenCalledWith("manager-1", "company-1", "report_failed");
|
||||
expect(deps.notify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls through to CEO when chain is empty", () => {
|
||||
(deps.db.core.tasks.getTaskRetryCount as ReturnType<typeof vi.fn>).mockReturnValue(2);
|
||||
(deps.db.core.agents.getChainOfCommand as ReturnType<typeof vi.fn>).mockReturnValue([]);
|
||||
(deps.db.core.agents.getCeoForCompany as ReturnType<typeof vi.fn>).mockReturnValue({ id: "ceo-1", name: "CEO Boss" });
|
||||
|
||||
const result = handleFailureEscalation(
|
||||
deps, RUN, TASK, "failed", MAX_RETRY_ATTEMPTS, RETRY_COOLDOWN_MS, scheduleRetry,
|
||||
);
|
||||
|
||||
expect(result.action).toBe("escalated");
|
||||
expect(deps.db.core.tasks.resetTaskRetryCount).toHaveBeenCalledWith("task-1");
|
||||
expect(deps.wakeAgentIfPossible).toHaveBeenCalledWith("ceo-1", "company-1", "report_failed");
|
||||
expect(deps.notify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls through to CEO when chain lookup throws", () => {
|
||||
(deps.db.core.tasks.getTaskRetryCount as ReturnType<typeof vi.fn>).mockReturnValue(2);
|
||||
(deps.db.core.agents.getChainOfCommand as ReturnType<typeof vi.fn>).mockImplementation(() => {
|
||||
throw new Error("Agent deleted");
|
||||
});
|
||||
(deps.db.core.agents.getCeoForCompany as ReturnType<typeof vi.fn>).mockReturnValue({ id: "ceo-1", name: "CEO Boss" });
|
||||
|
||||
const result = handleFailureEscalation(
|
||||
deps, RUN, TASK, "failed", MAX_RETRY_ATTEMPTS, RETRY_COOLDOWN_MS, scheduleRetry,
|
||||
);
|
||||
|
||||
expect(result.action).toBe("escalated");
|
||||
expect(deps.wakeAgentIfPossible).toHaveBeenCalledWith("ceo-1", "company-1", "report_failed");
|
||||
});
|
||||
|
||||
it("notifies user when both chain and CEO are unavailable", () => {
|
||||
(deps.db.core.tasks.getTaskRetryCount as ReturnType<typeof vi.fn>).mockReturnValue(2);
|
||||
(deps.db.core.agents.getChainOfCommand as ReturnType<typeof vi.fn>).mockReturnValue([]);
|
||||
(deps.db.core.agents.getCeoForCompany as ReturnType<typeof vi.fn>).mockReturnValue(null);
|
||||
|
||||
const result = handleFailureEscalation(
|
||||
deps, RUN, TASK, "failed", MAX_RETRY_ATTEMPTS, RETRY_COOLDOWN_MS, scheduleRetry,
|
||||
);
|
||||
|
||||
expect(result.action).toBe("user_notified");
|
||||
expect(deps.notify).toHaveBeenCalledWith(
|
||||
expect.stringContaining("manual intervention"),
|
||||
expect.stringContaining("Implement feature X"),
|
||||
);
|
||||
expect(deps.notify).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.stringContaining("no manager is available"),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses taskId as fallback when task is null", () => {
|
||||
(deps.db.core.tasks.getTaskRetryCount as ReturnType<typeof vi.fn>).mockReturnValue(2);
|
||||
(deps.db.core.agents.getChainOfCommand as ReturnType<typeof vi.fn>).mockReturnValue([]);
|
||||
(deps.db.core.agents.getCeoForCompany as ReturnType<typeof vi.fn>).mockReturnValue(null);
|
||||
|
||||
const result = handleFailureEscalation(
|
||||
deps, RUN, null, "failed", MAX_RETRY_ATTEMPTS, RETRY_COOLDOWN_MS, scheduleRetry,
|
||||
);
|
||||
|
||||
expect(result.action).toBe("user_notified");
|
||||
expect(deps.notify).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.stringContaining("task-1"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Timed out runs follow same path", () => {
|
||||
it("retries timed_out runs the same as failed runs", () => {
|
||||
(deps.db.core.tasks.getTaskRetryCount as ReturnType<typeof vi.fn>).mockReturnValue(0);
|
||||
|
||||
const result = handleFailureEscalation(
|
||||
deps, RUN, TASK, "timed_out", MAX_RETRY_ATTEMPTS, RETRY_COOLDOWN_MS, scheduleRetry,
|
||||
);
|
||||
|
||||
expect(result.action).toBe("retry");
|
||||
expect(deps.db.core.tasks.incrementTaskRetryCount).toHaveBeenCalledWith("task-1");
|
||||
});
|
||||
|
||||
it("escalates timed_out runs after max retries", () => {
|
||||
(deps.db.core.tasks.getTaskRetryCount as ReturnType<typeof vi.fn>).mockReturnValue(2);
|
||||
(deps.db.core.agents.getChainOfCommand as ReturnType<typeof vi.fn>).mockReturnValue([
|
||||
{ id: "manager-1", name: "Manager Alpha" },
|
||||
]);
|
||||
|
||||
const result = handleFailureEscalation(
|
||||
deps, RUN, TASK, "timed_out", MAX_RETRY_ATTEMPTS, RETRY_COOLDOWN_MS, scheduleRetry,
|
||||
);
|
||||
|
||||
expect(result.action).toBe("escalated");
|
||||
expect(deps.db.core.tasks.resetTaskRetryCount).toHaveBeenCalledWith("task-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Success resets retry count", () => {
|
||||
it("resets retry_count on successful completion", () => {
|
||||
handleSuccessReset(deps.db, "task-1");
|
||||
expect(deps.db.core.tasks.resetTaskRetryCount).toHaveBeenCalledWith("task-1");
|
||||
});
|
||||
|
||||
it("handles reset errors gracefully", () => {
|
||||
(deps.db.core.tasks.resetTaskRetryCount as ReturnType<typeof vi.fn>).mockImplementation(() => {
|
||||
throw new Error("DB error");
|
||||
});
|
||||
|
||||
expect(() => {
|
||||
handleSuccessReset(deps.db, "task-1");
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { collectDepartments } from "../../src/renderer/src/pages/files/file-helpers";
|
||||
import type { AgentRecord, FileObjectRecord } from "../../src/shared/domain";
|
||||
|
||||
function agent(id: string, department: string): AgentRecord {
|
||||
return {
|
||||
id, orgId: "org", name: id, role: "engineer", department,
|
||||
modelProviderId: null, modelId: null, budgetCents: 0, managerAgentId: null,
|
||||
title: id, roleLevel: "ic", modelStrategy: "single", workspaceId: null,
|
||||
heartbeatEnabled: false, heartbeatIntervalSec: 0, lifecycle: "active", createdAt: "2026-01-01",
|
||||
};
|
||||
}
|
||||
|
||||
function file(id: string, ownerType: FileObjectRecord["ownerType"], ownerId: string | null): FileObjectRecord {
|
||||
return {
|
||||
id, orgId: "org", name: id, kind: "doc", sizeBytes: 1, sha256: "x", storagePath: `/tmp/${id}`,
|
||||
ownerType, ownerId, spaceId: null, pinned: false, scanState: "approved", scanLabelsJson: "[]",
|
||||
scannedAt: null, createdAt: "2026-01-01", updatedAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("collectDepartments", () => {
|
||||
test("counts files per department, not agents, and omits empty departments", () => {
|
||||
const agents = [agent("a1", "Engineering"), agent("a2", "Engineering"), agent("a3", "Design"), agent("a4", "Sales")];
|
||||
const files = [
|
||||
file("f1", "agent", "a1"),
|
||||
file("f2", "agent", "a1"),
|
||||
file("f3", "agent", "a3"),
|
||||
file("f4", "employee", "emp1"), // not owned by an agent → ignored
|
||||
];
|
||||
// Engineering has two agents but only a1 holds files (2); Design has 1; Sales has agents but no files.
|
||||
expect(collectDepartments(files, agents)).toEqual([
|
||||
{ name: "Design", count: 1 },
|
||||
{ name: "Engineering", count: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("returns nothing when no agent owns a file", () => {
|
||||
expect(collectDepartments([], [agent("a1", "Engineering")])).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { DatabaseConnection } from "../../src/main/database/connection";
|
||||
import { ApprovalService } from "../../src/main/services/approval-service";
|
||||
import { IdentityAccessService } from "../../src/main/services/identity-access-service";
|
||||
import { OrganizationService } from "../../src/main/services/organization-service";
|
||||
import { SnapshotService } from "../../src/main/services/snapshot-service";
|
||||
import { RISK_LEVEL, ROLE_KEY } from "../../src/shared/domain";
|
||||
|
||||
function createConnection(): DatabaseConnection {
|
||||
return new DatabaseConnection(join(tmpdir(), `agentcompany-new-identity-${randomUUID()}.db`));
|
||||
}
|
||||
|
||||
function createOrganization(organization: OrganizationService): string {
|
||||
return organization.createOrganization({
|
||||
organizationName: "Acme Operations",
|
||||
employeeName: "Dana Lee",
|
||||
mission: "Coordinate agents with auditable approvals.",
|
||||
goal: "Ship a verified control plane.",
|
||||
});
|
||||
}
|
||||
|
||||
function firstEmployeeId(connection: DatabaseConnection, orgId: string): string {
|
||||
return (connection.db.prepare("select id from employees where org_id = ? order by created_at asc limit 1")
|
||||
.get(orgId) as { id: string }).id;
|
||||
}
|
||||
|
||||
describe("identity access service", () => {
|
||||
test("syncs SSO employee identities and grants scoped roles", () => {
|
||||
const connection = createConnection();
|
||||
const organization = new OrganizationService(connection.db);
|
||||
const identityAccess = new IdentityAccessService(connection.db);
|
||||
const snapshot = new SnapshotService(connection.db);
|
||||
let orgId = "";
|
||||
let employeeId = "";
|
||||
|
||||
connection.transaction(() => {
|
||||
orgId = createOrganization(organization);
|
||||
employeeId = firstEmployeeId(connection, orgId);
|
||||
identityAccess.syncEmployeeIdentity({
|
||||
orgId,
|
||||
employeeId,
|
||||
source: "sso",
|
||||
externalSubject: "okta-00u1",
|
||||
email: "dana@example.com",
|
||||
});
|
||||
identityAccess.grantRole({
|
||||
orgId,
|
||||
principalType: "employee",
|
||||
principalId: employeeId,
|
||||
roleKey: ROLE_KEY.APPROVAL_DECIDER,
|
||||
scopeType: "organization",
|
||||
scopeId: orgId,
|
||||
grantedByEmployeeId: employeeId,
|
||||
});
|
||||
});
|
||||
|
||||
const state = snapshot.load();
|
||||
expect(state.employeeIdentities.some((identity) => identity.source === "sso" && identity.email === "dana@example.com")).toBe(true);
|
||||
expect(state.roleGrants.some((grant) => grant.principalId === employeeId && grant.roleKey === ROLE_KEY.APPROVAL_DECIDER)).toBe(true);
|
||||
expect(state.auditEvents.some((event) => event.entityType === "employee_identity" && event.action === "synced")).toBe(true);
|
||||
connection.close();
|
||||
});
|
||||
|
||||
test("records denied access decisions when approval decider role is missing", () => {
|
||||
const connection = createConnection();
|
||||
const organization = new OrganizationService(connection.db);
|
||||
const approvals = new ApprovalService(connection.db);
|
||||
let orgId = "";
|
||||
let employeeId = "";
|
||||
let approvalId = "";
|
||||
|
||||
connection.transaction(() => {
|
||||
orgId = createOrganization(organization);
|
||||
employeeId = firstEmployeeId(connection, orgId);
|
||||
connection.db.prepare("delete from role_grants where org_id = ? and principal_id = ?").run(orgId, employeeId);
|
||||
approvalId = approvals.create({
|
||||
orgId,
|
||||
kind: "release",
|
||||
title: "Release approval",
|
||||
details: "Approve release train.",
|
||||
risk: RISK_LEVEL.MEDIUM,
|
||||
requestedByType: "system",
|
||||
requestedById: null,
|
||||
});
|
||||
});
|
||||
|
||||
expect(() => approvals.decide({
|
||||
orgId,
|
||||
approvalId,
|
||||
state: "approved",
|
||||
decidedByEmployeeId: employeeId,
|
||||
})).toThrow("Employee requires approval_decider role.");
|
||||
expect(connection.db.prepare("select decision, reason from access_decisions where org_id = ?").all(orgId))
|
||||
.toEqual([{ decision: "deny", reason: "missing_role" }]);
|
||||
connection.close();
|
||||
});
|
||||
});
|
||||
@@ -1,50 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { InboundCorrelationRegistryStore } from "../../src/main/agent-runtime/inbound-correlation-registry";
|
||||
import type { AgentMessageRecord } from "../../src/shared/types";
|
||||
|
||||
function makeMessage(index: number): AgentMessageRecord {
|
||||
return {
|
||||
id: `message-${index}`,
|
||||
companyId: "company-1",
|
||||
fromAgentId: `agent-${index}`,
|
||||
toAgentId: "agent-recipient",
|
||||
senderKind: "agent",
|
||||
channel: "direct",
|
||||
channelTargetId: null,
|
||||
subject: `message ${index}`,
|
||||
body: `body ${index}`,
|
||||
priority: "normal",
|
||||
readAt: null,
|
||||
parentMessageId: null,
|
||||
attachmentsJson: "[]",
|
||||
createdAt: "2026-04-22T00:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
describe("InboundCorrelationRegistryStore", () => {
|
||||
it("preserves every inbound correlation for the active run", () => {
|
||||
const store = new InboundCorrelationRegistryStore();
|
||||
const timestamps: number[] = [];
|
||||
|
||||
for (let i = 0; i < 700; i += 1) {
|
||||
timestamps.push(store.register("run-1", makeMessage(i), 1_000));
|
||||
}
|
||||
|
||||
expect(new Set(timestamps).size).toBe(700);
|
||||
for (let i = 0; i < timestamps.length; i += 1) {
|
||||
expect(store.lookup("run-1", timestamps[i])).toMatchObject({
|
||||
fromAgentId: `agent-${i}`,
|
||||
messageId: `message-${i}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("drops correlations only when the run is released", () => {
|
||||
const store = new InboundCorrelationRegistryStore();
|
||||
const timestamp = store.register("run-1", makeMessage(1), 1_000);
|
||||
|
||||
expect(store.lookup("run-1", timestamp)?.messageId).toBe("message-1");
|
||||
store.deleteRun("run-1");
|
||||
expect(store.lookup("run-1", timestamp)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,137 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { AppDatabase } from "@main/database";
|
||||
import { resolveInternalToolsForAgent } from "@main/orchestration/internal-tool-selector";
|
||||
import type { AgentRecord } from "@shared/types";
|
||||
|
||||
function makeAgent(overrides: Partial<AgentRecord>): AgentRecord {
|
||||
return {
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
name: "Agent",
|
||||
role: "Engineer",
|
||||
title: "Engineer",
|
||||
roleLevel: "ic",
|
||||
department: "engineering",
|
||||
status: "idle",
|
||||
reportsTo: "ceo-1",
|
||||
connectorId: "openai-codex",
|
||||
workspaceId: null,
|
||||
model: null,
|
||||
capabilities: "",
|
||||
budgetMonthlyUsd: 100,
|
||||
spentMonthlyUsd: 0,
|
||||
metadataJson: "{}",
|
||||
heartbeatEnabled: true,
|
||||
heartbeatIntervalSec: 120,
|
||||
lastHeartbeatAt: null,
|
||||
createdAt: "2026-04-22T00:00:00.000Z",
|
||||
updatedAt: "2026-04-22T00:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeDb(
|
||||
agent: AgentRecord,
|
||||
reports: AgentRecord[],
|
||||
approvals: Record<string, Array<{ id: string; state: string }>> = {},
|
||||
): AppDatabase {
|
||||
return {
|
||||
core: {
|
||||
entity: {
|
||||
getAgent: vi.fn((agentId: string) => {
|
||||
const found = [agent, ...reports].find((candidate) => candidate.id === agentId);
|
||||
if (!found) throw new Error(`Agent not found: ${agentId}`);
|
||||
return found;
|
||||
}),
|
||||
},
|
||||
agents: {
|
||||
hasAgentPermission: vi.fn(() => false),
|
||||
getDirectReports: vi.fn(() => reports),
|
||||
getChainOfCommand: vi.fn(() => []),
|
||||
},
|
||||
approvals: {
|
||||
listCompanyApprovalsByType: vi.fn((_companyId: string, type: string) => approvals[type] ?? []),
|
||||
},
|
||||
},
|
||||
} as unknown as AppDatabase;
|
||||
}
|
||||
|
||||
describe("resolveInternalToolsForAgent", () => {
|
||||
it("exposes only hire_agent during initial CEO team building", () => {
|
||||
const ceo = makeAgent({
|
||||
id: "ceo-1",
|
||||
role: "CEO",
|
||||
title: "CEO",
|
||||
roleLevel: "c_suite",
|
||||
department: "executive",
|
||||
reportsTo: null,
|
||||
});
|
||||
const db = makeDb(ceo, []);
|
||||
|
||||
expect(resolveInternalToolsForAgent(db, ceo)).toEqual(["hire_agent"]);
|
||||
});
|
||||
|
||||
it("exposes file_approval after a hire is in flight", () => {
|
||||
const ceo = makeAgent({
|
||||
id: "ceo-1",
|
||||
role: "CEO",
|
||||
title: "CEO",
|
||||
roleLevel: "c_suite",
|
||||
department: "executive",
|
||||
reportsTo: null,
|
||||
});
|
||||
const pendingHire = makeAgent({
|
||||
id: "pending-cto",
|
||||
role: "CTO",
|
||||
title: "CTO",
|
||||
reportsTo: ceo.id,
|
||||
status: "pending_approval",
|
||||
});
|
||||
const db = makeDb(ceo, [pendingHire], {
|
||||
hire_agent: [{ id: "approval-1", state: "pending" }],
|
||||
});
|
||||
|
||||
expect(resolveInternalToolsForAgent(db, ceo)).toEqual(["hire_agent", "file_approval"]);
|
||||
});
|
||||
|
||||
it("exposes file_approval when the CEO is operating with active reports", () => {
|
||||
const ceo = makeAgent({
|
||||
id: "ceo-1",
|
||||
role: "CEO",
|
||||
title: "CEO",
|
||||
roleLevel: "c_suite",
|
||||
department: "executive",
|
||||
reportsTo: null,
|
||||
});
|
||||
const cto = makeAgent({
|
||||
id: "cto-1",
|
||||
role: "CTO",
|
||||
title: "CTO",
|
||||
reportsTo: ceo.id,
|
||||
status: "idle",
|
||||
});
|
||||
const db = makeDb(ceo, [cto]);
|
||||
|
||||
expect(resolveInternalToolsForAgent(db, ceo)).toEqual(["hire_agent", "file_approval"]);
|
||||
});
|
||||
|
||||
it("keeps CEO strategy approval scoped to CEO agents", () => {
|
||||
const manager = makeAgent({
|
||||
id: "manager-1",
|
||||
role: "VP Engineering",
|
||||
title: "VP Engineering",
|
||||
roleLevel: "vp",
|
||||
reportsTo: "ceo-1",
|
||||
});
|
||||
const ic = makeAgent({
|
||||
id: "ic-1",
|
||||
role: "Backend Engineer",
|
||||
title: "Backend Engineer",
|
||||
reportsTo: manager.id,
|
||||
status: "idle",
|
||||
});
|
||||
const db = makeDb(manager, [ic]);
|
||||
|
||||
expect(resolveInternalToolsForAgent(db, manager)).toEqual(["hire_agent"]);
|
||||
});
|
||||
});
|
||||
@@ -1,115 +0,0 @@
|
||||
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 type { IpcMainInvokeEvent } from "electron";
|
||||
import type { DesktopResult } from "@shared/contracts";
|
||||
import { IPC_CHANNELS } from "@shared/contracts";
|
||||
import { AppDatabase } from "../../src/main/database";
|
||||
import { registerAgentCoordinationHandlers } from "../../src/main/ipc-agent-coordination-handlers";
|
||||
import { HUMAN_SENDER_ID } from "../../src/main/messaging/dispatch";
|
||||
|
||||
type IpcHandler = (
|
||||
event: IpcMainInvokeEvent,
|
||||
...args: unknown[]
|
||||
) => Promise<DesktopResult<unknown>> | DesktopResult<unknown>;
|
||||
|
||||
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.core.connectors.updateConnectorHealth({
|
||||
id: "openai-codex",
|
||||
status: "ready",
|
||||
authState: "authenticated",
|
||||
version: "test",
|
||||
lastError: null,
|
||||
});
|
||||
const companyId = db.core.company.saveCompany({
|
||||
name: "IPC Message Co",
|
||||
description: "Tests desktop message routing",
|
||||
status: "active",
|
||||
});
|
||||
const workspaceId = db.core.workspaces.saveWorkspace({
|
||||
companyId,
|
||||
name: "Primary",
|
||||
localPath: "/tmp/ipc-message",
|
||||
repoUrl: "",
|
||||
repoRef: "",
|
||||
isPrimary: true,
|
||||
});
|
||||
const agentId = db.core.agents.saveAgent({
|
||||
companyId,
|
||||
name: "Engineer",
|
||||
role: "Engineer",
|
||||
title: "Lead Engineer",
|
||||
status: "idle",
|
||||
reportsTo: null,
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Ship features",
|
||||
budgetMonthlyUsd: 180,
|
||||
});
|
||||
return { companyId, agentId };
|
||||
}
|
||||
|
||||
describe("ipc agent coordination handlers", () => {
|
||||
it("routes desktop messages into a recipient's live runtime before falling back to wake", async () => {
|
||||
const profileDir = await createTempDir("agentcompany-ipc-message-");
|
||||
const db = new AppDatabase(profileDir);
|
||||
db.init();
|
||||
|
||||
const { companyId, agentId } = createScaffold(db);
|
||||
const handlers = new Map<string, IpcHandler>();
|
||||
const injectRuntimeMessage = vi.fn().mockReturnValue(true);
|
||||
const wakeAgentIfPossible = vi.fn().mockReturnValue("queued-run");
|
||||
const dependencies = {
|
||||
registerHandle: (channel: string, handler: IpcHandler) => {
|
||||
handlers.set(channel, handler);
|
||||
},
|
||||
db,
|
||||
ensureCompanyExists: () => null,
|
||||
publishDomainChanged: vi.fn(),
|
||||
emitEvent: vi.fn(),
|
||||
notify: vi.fn(),
|
||||
wakeAgentIfPossible,
|
||||
injectRuntimeMessage,
|
||||
onHumanMessage: vi.fn(),
|
||||
};
|
||||
|
||||
registerAgentCoordinationHandlers(dependencies);
|
||||
const sendAgentMessage = handlers.get(IPC_CHANNELS.sendAgentMessage);
|
||||
if (!sendAgentMessage) {
|
||||
throw new Error("sendAgentMessage handler was not registered.");
|
||||
}
|
||||
|
||||
const result = await sendAgentMessage(null as unknown as IpcMainInvokeEvent, {
|
||||
companyId,
|
||||
fromAgentId: HUMAN_SENDER_ID,
|
||||
toAgentId: agentId,
|
||||
channel: "direct",
|
||||
subject: "live steer",
|
||||
body: "Please use this new context now.",
|
||||
priority: "normal",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(injectRuntimeMessage).toHaveBeenCalledTimes(1);
|
||||
expect(injectRuntimeMessage).toHaveBeenCalledWith(agentId, companyId, expect.objectContaining({
|
||||
body: "Please use this new context now.",
|
||||
}));
|
||||
expect(wakeAgentIfPossible).not.toHaveBeenCalled();
|
||||
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
@@ -1,107 +0,0 @@
|
||||
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 type { IpcMainInvokeEvent } from "electron";
|
||||
import type { DesktopResult } from "@shared/contracts";
|
||||
import { IPC_CHANNELS } from "@shared/contracts";
|
||||
import { AppDatabase } from "../../src/main/database";
|
||||
import { registerCoreAgentGraphHandlers } from "../../src/main/ipc-core-agent-graph-handlers";
|
||||
|
||||
type IpcHandler = (
|
||||
event: IpcMainInvokeEvent,
|
||||
...args: unknown[]
|
||||
) => Promise<DesktopResult<unknown>> | DesktopResult<unknown>;
|
||||
|
||||
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.core.connectors.updateConnectorHealth({
|
||||
id: "openai-codex",
|
||||
status: "ready",
|
||||
authState: "authenticated",
|
||||
version: "test",
|
||||
lastError: null,
|
||||
});
|
||||
const companyId = db.core.company.saveCompany({
|
||||
name: "Handler Test Co",
|
||||
description: "Tests saveAgent model validation",
|
||||
status: "active",
|
||||
});
|
||||
db.core.settings.setDisabledModels(companyId, "openai-codex", [{ id: "gpt-5.4", label: "gpt-5.4" }]);
|
||||
const workspaceId = db.core.workspaces.saveWorkspace({
|
||||
companyId,
|
||||
name: "Primary",
|
||||
localPath: profileDir,
|
||||
repoUrl: "",
|
||||
repoRef: "",
|
||||
isPrimary: true,
|
||||
});
|
||||
const agentId = db.core.agents.saveAgent({
|
||||
companyId,
|
||||
name: "CEO",
|
||||
role: "CEO",
|
||||
title: "Chief Executive Officer",
|
||||
status: "idle",
|
||||
reportsTo: null,
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Lead the company",
|
||||
budgetMonthlyUsd: 250,
|
||||
});
|
||||
|
||||
const handlers = new Map<string, IpcHandler>();
|
||||
registerCoreAgentGraphHandlers({
|
||||
registerHandle: (channel, handler) => {
|
||||
handlers.set(channel, handler);
|
||||
},
|
||||
db,
|
||||
ensureCompanyExists: (id) => (db.core.settings.hasCompany(id) ? null : { ok: false, error: { code: "COMPANY_NOT_FOUND", message: "missing" } }),
|
||||
findInvalidReference: () => null,
|
||||
publishDomainChanged: vi.fn(),
|
||||
handleApprovalResolved: vi.fn(),
|
||||
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
});
|
||||
|
||||
const saveAgent = handlers.get(IPC_CHANNELS.saveAgent);
|
||||
if (!saveAgent) {
|
||||
throw new Error("saveAgent handler was not registered.");
|
||||
}
|
||||
|
||||
const result = await saveAgent(null as unknown as IpcMainInvokeEvent, {
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "CEO",
|
||||
role: "CEO",
|
||||
title: "Chief Executive Officer",
|
||||
department: "executive",
|
||||
status: "idle",
|
||||
reportsTo: null,
|
||||
connectorId: "openai-codex",
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -1,70 +0,0 @@
|
||||
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 type { IpcMainInvokeEvent } from "electron";
|
||||
import type { DesktopResult } from "@shared/contracts";
|
||||
import { IPC_CHANNELS } from "@shared/contracts";
|
||||
import { AppDatabase } from "../../src/main/database";
|
||||
import { registerCoreAdminHandlers } from "../../src/main/ipc-core-admin-handlers";
|
||||
|
||||
type IpcHandler = (
|
||||
event: IpcMainInvokeEvent,
|
||||
...args: unknown[]
|
||||
) => Promise<DesktopResult<unknown>> | DesktopResult<unknown>;
|
||||
|
||||
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 a valid pi-ai model for a pi-mono connector", async () => {
|
||||
const profileDir = await createTempDir("agentcompany-admin-handler-");
|
||||
const db = new AppDatabase(profileDir);
|
||||
db.init();
|
||||
|
||||
const { getModels } = await import("@mariozechner/pi-ai");
|
||||
const anthropicModel = getModels("anthropic" as never)[0]?.id;
|
||||
if (!anthropicModel) {
|
||||
throw new Error("pi-ai anthropic provider must expose at least one model.");
|
||||
}
|
||||
|
||||
const handlers = new Map<string, IpcHandler>();
|
||||
registerCoreAdminHandlers({
|
||||
registerHandle: (channel, handler) => {
|
||||
handlers.set(channel, handler);
|
||||
},
|
||||
db,
|
||||
ensureCompanyExists: () => null,
|
||||
publishDomainChanged: vi.fn(),
|
||||
handleApprovalResolved: vi.fn(),
|
||||
checkConnector: 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 as unknown as IpcMainInvokeEvent, {
|
||||
id: "anthropic",
|
||||
model: anthropicModel,
|
||||
envBindingText: "ANTHROPIC_BASE_URL=https://example.com/anthropic",
|
||||
notes: "Pi-mono connector",
|
||||
}) as { ok: boolean; error?: { code: string; message: string } };
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(db.core.connectors.getConnector("anthropic").model).toBe(anthropicModel);
|
||||
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
@@ -1,100 +0,0 @@
|
||||
import type { IpcMainInvokeEvent } from "electron";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { DesktopResult } from "@shared/contracts";
|
||||
import { IPC_CHANNELS } from "@shared/contracts";
|
||||
import type { AppDatabase } from "../../src/main/database";
|
||||
import { registerCoreReadHandlers } from "../../src/main/ipc-core-read-handlers";
|
||||
import type { RegisterCoreHandlersDependencies } from "../../src/main/ipc-core-common";
|
||||
|
||||
vi.mock("electron", () => ({
|
||||
dialog: {
|
||||
showMessageBox: vi.fn(),
|
||||
showOpenDialog: vi.fn(),
|
||||
},
|
||||
nativeTheme: { themeSource: "system" },
|
||||
shell: {
|
||||
openExternal: vi.fn(),
|
||||
openPath: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
type IpcHandler = (
|
||||
event: IpcMainInvokeEvent,
|
||||
...args: unknown[]
|
||||
) => Promise<DesktopResult<unknown>> | DesktopResult<unknown>;
|
||||
|
||||
const companyId = "11111111-1111-4111-8111-111111111111";
|
||||
|
||||
function createReadHandlerHarness(ensureCompanyExists: RegisterCoreHandlersDependencies["ensureCompanyExists"]) {
|
||||
const handlers = new Map<string, IpcHandler>();
|
||||
const getDisabledModels = vi.fn(() => [{ id: "claude-sonnet-4-5", label: "Claude Sonnet 4.5" }]);
|
||||
const db = {
|
||||
core: {
|
||||
settings: {
|
||||
getCurrentCompanyId: vi.fn(() => companyId),
|
||||
getDisabledModels,
|
||||
},
|
||||
},
|
||||
projection: {
|
||||
snapshot: {
|
||||
listRendererSnapshot: vi.fn(() => ({})),
|
||||
},
|
||||
},
|
||||
} as unknown as AppDatabase;
|
||||
|
||||
registerCoreReadHandlers({
|
||||
registerHandle: (channel, handler) => {
|
||||
handlers.set(channel, handler);
|
||||
},
|
||||
db,
|
||||
ensureCompanyExists,
|
||||
publishDomainChanged: vi.fn(),
|
||||
checkForUpdates: vi.fn(async () => true),
|
||||
resetAfterRestore: vi.fn(),
|
||||
listConnectorModels: vi.fn(async () => []),
|
||||
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
});
|
||||
|
||||
const handler = handlers.get(IPC_CHANNELS.getConnectorDisabledModels);
|
||||
if (!handler) {
|
||||
throw new Error("getConnectorDisabledModels handler was not registered.");
|
||||
}
|
||||
return { handler, getDisabledModels };
|
||||
}
|
||||
|
||||
describe("ipc-core-read-handlers connector model pool reads", () => {
|
||||
it("reads disabled models after confirming the company exists", async () => {
|
||||
const ensureCompanyExists = vi.fn(() => null);
|
||||
const { handler, getDisabledModels } = createReadHandlerHarness(ensureCompanyExists);
|
||||
|
||||
const result = await handler(null as unknown as IpcMainInvokeEvent, {
|
||||
companyId,
|
||||
connectorId: "anthropic",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
data: [{ id: "claude-sonnet-4-5", label: "Claude Sonnet 4.5" }],
|
||||
});
|
||||
expect(ensureCompanyExists).toHaveBeenCalledWith(companyId);
|
||||
expect(getDisabledModels).toHaveBeenCalledWith(companyId, "anthropic");
|
||||
});
|
||||
|
||||
it("returns the company boundary error before reading disabled models", async () => {
|
||||
const companyError = {
|
||||
ok: false,
|
||||
error: { code: "COMPANY_NOT_FOUND", message: "Company not found." },
|
||||
} satisfies DesktopResult<never>;
|
||||
const ensureCompanyExists = vi.fn(() => companyError);
|
||||
const { handler, getDisabledModels } = createReadHandlerHarness(ensureCompanyExists);
|
||||
|
||||
const result = await handler(null as unknown as IpcMainInvokeEvent, {
|
||||
companyId,
|
||||
connectorId: "anthropic",
|
||||
});
|
||||
|
||||
expect(result).toEqual(companyError);
|
||||
expect(ensureCompanyExists).toHaveBeenCalledWith(companyId);
|
||||
expect(getDisabledModels).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,256 +0,0 @@
|
||||
import type { IpcMainInvokeEvent } from "electron";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { DesktopResult } from "../../src/shared/contracts";
|
||||
import { IPC_CHANNELS } from "../../src/shared/contracts";
|
||||
import type { CredentialResolver } from "../../src/main/credentials/credential-resolver";
|
||||
import type { CredentialStore } from "../../src/main/credentials/credential-store";
|
||||
|
||||
const streamMocks = vi.hoisted(() => ({
|
||||
safeStreamSimpleWithEnv: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("electron", () => ({
|
||||
ipcMain: {
|
||||
removeHandler: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../src/main/worker/runtime/pi-safe-stream", () => ({
|
||||
safeStreamSimpleWithEnv: streamMocks.safeStreamSimpleWithEnv,
|
||||
}));
|
||||
|
||||
import { registerCredentialIpcHandlers } from "../../src/main/ipc-credentials-handlers";
|
||||
|
||||
type IpcHandler = (
|
||||
event: IpcMainInvokeEvent,
|
||||
...args: unknown[]
|
||||
) => Promise<DesktopResult<unknown>> | DesktopResult<unknown>;
|
||||
|
||||
function createDeps(overrides: { resolver?: Partial<CredentialResolver>; store?: Partial<CredentialStore> } = {}) {
|
||||
const handlers = new Map<string, IpcHandler>();
|
||||
const logger = {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
const resolver = {
|
||||
resolve: vi.fn(async () => ({
|
||||
apiKey: "sk-proj-testcredentialsecret0123456789",
|
||||
})),
|
||||
...(overrides.resolver ?? {}),
|
||||
} as unknown as CredentialResolver;
|
||||
|
||||
registerCredentialIpcHandlers({
|
||||
store: {
|
||||
list: vi.fn(() => []),
|
||||
save: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
...(overrides.store ?? {}),
|
||||
} as unknown as CredentialStore,
|
||||
resolver,
|
||||
emitEvent: vi.fn(),
|
||||
refreshConnectorHealth: vi.fn(async () => {}),
|
||||
registerHandle: (channel, handler) => {
|
||||
handlers.set(channel, handler);
|
||||
},
|
||||
fail: (code, message) => ({ ok: false, error: { code, message } }),
|
||||
logger,
|
||||
});
|
||||
|
||||
const handler = handlers.get(IPC_CHANNELS.credentialsTest);
|
||||
if (!handler) {
|
||||
throw new Error("credentialsTest handler was not registered.");
|
||||
}
|
||||
return { handler, handlers, logger };
|
||||
}
|
||||
|
||||
async function runCredentialTest(handler: IpcHandler) {
|
||||
return handler(null as unknown as IpcMainInvokeEvent, { connectorId: "fireworks" }) as Promise<DesktopResult<{
|
||||
ok: boolean;
|
||||
message: string;
|
||||
}>>;
|
||||
}
|
||||
|
||||
function requireHandler(handlers: Map<string, IpcHandler>, channel: string) {
|
||||
const handler = handlers.get(channel);
|
||||
if (!handler) {
|
||||
throw new Error(`${channel} handler was not registered.`);
|
||||
}
|
||||
return handler;
|
||||
}
|
||||
|
||||
function joinLogCalls(mock: ReturnType<typeof vi.fn>) {
|
||||
return mock.mock.calls.flat().map(String).join(" ");
|
||||
}
|
||||
|
||||
describe("credential IPC handler logging", () => {
|
||||
beforeEach(() => {
|
||||
streamMocks.safeStreamSimpleWithEnv.mockReset();
|
||||
});
|
||||
|
||||
it("redacts resolver failures for the UI and logs only safe credential test metadata", async () => {
|
||||
const secret = "sk-proj-resolversecret012345678901234";
|
||||
const error = Object.assign(
|
||||
new Error(`failed for ${secret} at https://api.fireworks.ai/inference/v1`),
|
||||
{ code: "CREDENTIAL_INVALID", $metadata: { requestId: "req_resolver_123" } },
|
||||
);
|
||||
const { handler, logger } = createDeps({
|
||||
resolver: {
|
||||
resolve: vi.fn(async () => {
|
||||
throw error;
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runCredentialTest(handler);
|
||||
const log = joinLogCalls(logger.error);
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
data: {
|
||||
ok: false,
|
||||
message: "failed for sk-proj-[REDACTED] at https://api.fireworks.ai/inference/v1",
|
||||
},
|
||||
});
|
||||
expect(log).toBe("[credentials] test failed: connector=fireworks provider=fireworks code=CREDENTIAL_INVALID requestId=req_resolver_123");
|
||||
expect(log).not.toContain(secret);
|
||||
expect(log).not.toContain("api.fireworks.ai");
|
||||
});
|
||||
|
||||
it("keeps stream error messages out of credential test logs", async () => {
|
||||
const secret = "Bearer eyJhbGciOiJIUzI1NiJ9.secret.payload";
|
||||
streamMocks.safeStreamSimpleWithEnv.mockReturnValueOnce({
|
||||
result: vi.fn(async () => ({
|
||||
stopReason: "error",
|
||||
errorMessage: `provider rejected ${secret} for https://api.fireworks.ai/inference/v1`,
|
||||
})),
|
||||
});
|
||||
const { handler, logger } = createDeps();
|
||||
|
||||
const result = await runCredentialTest(handler);
|
||||
const log = joinLogCalls(logger.warn);
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
data: {
|
||||
ok: false,
|
||||
message: "provider rejected Bearer [REDACTED] for https://api.fireworks.ai/inference/v1",
|
||||
},
|
||||
});
|
||||
expect(log).toBe("[credentials] test failed: connector=fireworks provider=fireworks code=stream_error");
|
||||
expect(log).not.toContain(secret);
|
||||
expect(log).not.toContain("api.fireworks.ai");
|
||||
});
|
||||
|
||||
it("logs safe code and request id when credential test streaming throws", async () => {
|
||||
const secret = "AKIA1234567890ABCDEF";
|
||||
const error = Object.assign(
|
||||
new Error(`upstream threw ${secret} at https://bedrock-runtime.us-east-1.amazonaws.com`),
|
||||
{
|
||||
code: "AccessDeniedException",
|
||||
response: { headers: new Map([["x-request-id", "req_stream_456"]]) },
|
||||
},
|
||||
);
|
||||
streamMocks.safeStreamSimpleWithEnv.mockReturnValueOnce({
|
||||
result: vi.fn(async () => {
|
||||
throw error;
|
||||
}),
|
||||
});
|
||||
const { handler, logger } = createDeps();
|
||||
|
||||
const result = await runCredentialTest(handler);
|
||||
const log = joinLogCalls(logger.error);
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
data: {
|
||||
ok: false,
|
||||
message: "upstream threw AKIA[REDACTED] at https://bedrock-runtime.us-east-1.amazonaws.com",
|
||||
},
|
||||
});
|
||||
expect(log).toBe("[credentials] test failed: connector=fireworks provider=fireworks code=AccessDeniedException requestId=req_stream_456");
|
||||
expect(log).not.toContain(secret);
|
||||
expect(log).not.toContain("bedrock-runtime.us-east-1.amazonaws.com");
|
||||
});
|
||||
|
||||
it("keeps raw save failure messages out of credential logs", async () => {
|
||||
const secret = "sk-proj-savesecret012345678901234";
|
||||
const error = Object.assign(
|
||||
new Error(`save failed for ${secret} at https://api.fireworks.ai/account/acme`),
|
||||
{ requestId: "req_save_789" },
|
||||
);
|
||||
const { handlers, logger } = createDeps({
|
||||
store: {
|
||||
save: vi.fn(() => {
|
||||
throw error;
|
||||
}),
|
||||
},
|
||||
});
|
||||
const saveHandler = requireHandler(handlers, IPC_CHANNELS.credentialsSave);
|
||||
|
||||
const result = await saveHandler(null as unknown as IpcMainInvokeEvent, {
|
||||
connectorId: "fireworks",
|
||||
payload: {
|
||||
kind: "api_key",
|
||||
value: { source: "literal", value: secret },
|
||||
},
|
||||
});
|
||||
const log = joinLogCalls(logger.error);
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "SAVE_FAILED",
|
||||
message: "Could not save credential. Check application logs for details.",
|
||||
},
|
||||
});
|
||||
expect(log).toBe("[credentials] save failed: connector=fireworks provider=fireworks code=credentials.save.failed requestId=req_save_789");
|
||||
expect(log).not.toContain(secret);
|
||||
expect(log).not.toContain("api.fireworks.ai");
|
||||
});
|
||||
|
||||
it("sanitizes connector ids on remove failure logs", async () => {
|
||||
const connectorId = "unknown\nhttps://api.fireworks.ai/account/sk-proj-remove012345678901234";
|
||||
const { handlers, logger } = createDeps({
|
||||
store: {
|
||||
remove: vi.fn(() => {
|
||||
throw new Error("remove failed for https://api.fireworks.ai/account/sk-proj-remove012345678901234");
|
||||
}),
|
||||
},
|
||||
});
|
||||
const removeHandler = requireHandler(handlers, IPC_CHANNELS.credentialsRemove);
|
||||
|
||||
const result = await removeHandler(null as unknown as IpcMainInvokeEvent, { connectorId });
|
||||
const log = joinLogCalls(logger.error);
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "REMOVE_FAILED",
|
||||
message: "Could not remove credential. Check application logs for details.",
|
||||
},
|
||||
});
|
||||
expect(log).toBe("[credentials] remove failed: connector=invalid provider=unknown code=credentials.remove.failed");
|
||||
expect(log).not.toContain("api.fireworks.ai");
|
||||
expect(log).not.toContain("sk-proj-remove012345678901234");
|
||||
});
|
||||
|
||||
it("sanitizes connector ids on unknown connector test logs", async () => {
|
||||
const connectorId = "missing\nhttps://api.fireworks.ai/account/sk-proj-unknown012345678901234";
|
||||
const { handler, logger } = createDeps();
|
||||
|
||||
const result = await handler(null as unknown as IpcMainInvokeEvent, { connectorId });
|
||||
const log = joinLogCalls(logger.error);
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "UNKNOWN_CONNECTOR",
|
||||
message: "Unknown connector.",
|
||||
},
|
||||
});
|
||||
expect(log).toBe("[credentials] test failed: connector=invalid provider=unknown code=credentials.test.unknown_connector");
|
||||
expect(log).not.toContain("api.fireworks.ai");
|
||||
expect(log).not.toContain("sk-proj-unknown012345678901234");
|
||||
});
|
||||
});
|
||||
@@ -1,135 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { IPC_CHANNELS } from "../../src/shared/contracts";
|
||||
|
||||
const electron = vi.hoisted(() => ({
|
||||
openExternal: vi.fn<() => Promise<void>>(async () => {}),
|
||||
openPath: vi.fn<() => Promise<string>>(async () => ""),
|
||||
showMessageBox: vi.fn<() => Promise<{ response: number }>>(async () => ({ response: 0 })),
|
||||
}));
|
||||
|
||||
vi.mock("electron", () => ({
|
||||
dialog: { showMessageBox: electron.showMessageBox },
|
||||
nativeTheme: { themeSource: "system" },
|
||||
shell: {
|
||||
openExternal: electron.openExternal,
|
||||
openPath: electron.openPath,
|
||||
},
|
||||
}));
|
||||
|
||||
import { registerCoreReadHandlers } from "../../src/main/ipc-core-read-handlers";
|
||||
import type { AppDatabase } from "../../src/main/database";
|
||||
import type { RegisterCoreHandlersDependencies } from "../../src/main/ipc-core-common";
|
||||
|
||||
const companyId = "11111111-1111-4111-8111-111111111111";
|
||||
const workProductId = "22222222-2222-4222-8222-222222222222";
|
||||
|
||||
function registerOpenWorkProductHandler(url: string) {
|
||||
const handlers = new Map<string, (...args: unknown[]) => Promise<unknown> | unknown>();
|
||||
const db = {
|
||||
core: {
|
||||
documents: {
|
||||
resolveWorkProductTarget: vi.fn(() => ({ kind: "url", value: url })),
|
||||
updateWorkProduct: vi.fn(),
|
||||
},
|
||||
settings: {
|
||||
canOpenPath: vi.fn(() => true),
|
||||
getCurrentCompanyId: vi.fn(() => companyId),
|
||||
},
|
||||
},
|
||||
projection: {
|
||||
snapshot: {
|
||||
listRendererSnapshot: vi.fn(() => ({})),
|
||||
},
|
||||
},
|
||||
} as unknown as AppDatabase;
|
||||
const deps = {
|
||||
registerHandle: (channel, handler) => handlers.set(channel, handler as (...args: unknown[]) => Promise<unknown> | unknown),
|
||||
db,
|
||||
ensureCompanyExists: () => null,
|
||||
publishDomainChanged: vi.fn(),
|
||||
checkForUpdates: vi.fn(async () => true),
|
||||
resetAfterRestore: vi.fn(),
|
||||
listConnectorModels: vi.fn(async () => []),
|
||||
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
} satisfies Pick<
|
||||
RegisterCoreHandlersDependencies,
|
||||
| "registerHandle"
|
||||
| "db"
|
||||
| "ensureCompanyExists"
|
||||
| "publishDomainChanged"
|
||||
| "checkForUpdates"
|
||||
| "resetAfterRestore"
|
||||
| "listConnectorModels"
|
||||
| "logger"
|
||||
>;
|
||||
registerCoreReadHandlers(deps);
|
||||
const handler = handlers.get(IPC_CHANNELS.openWorkProduct);
|
||||
if (!handler) {
|
||||
throw new Error("openWorkProduct handler was not registered.");
|
||||
}
|
||||
return handler;
|
||||
}
|
||||
|
||||
async function openUrl(url: string) {
|
||||
const handler = registerOpenWorkProductHandler(url);
|
||||
return handler({}, { companyId, id: workProductId });
|
||||
}
|
||||
|
||||
describe("openWorkProduct external URL policy", () => {
|
||||
it("opens trusted hosts without confirmation", async () => {
|
||||
electron.openExternal.mockClear();
|
||||
electron.showMessageBox.mockClear();
|
||||
|
||||
const result = await openUrl("https://github.com/acme/repo");
|
||||
|
||||
expect(result).toEqual({ ok: true, data: true });
|
||||
expect(electron.showMessageBox).not.toHaveBeenCalled();
|
||||
expect(electron.openExternal).toHaveBeenCalledWith("https://github.com/acme/repo");
|
||||
});
|
||||
|
||||
it("asks before opening trusted hosts over http", async () => {
|
||||
electron.openExternal.mockClear();
|
||||
electron.showMessageBox.mockClear();
|
||||
electron.showMessageBox.mockResolvedValueOnce({ response: 0 });
|
||||
|
||||
const result = await openUrl("http://github.com/acme/repo");
|
||||
|
||||
expect(result).toEqual({ ok: true, data: true });
|
||||
expect(electron.showMessageBox).toHaveBeenCalledTimes(1);
|
||||
expect(electron.openExternal).toHaveBeenCalledWith("http://github.com/acme/repo");
|
||||
});
|
||||
|
||||
it("asks before opening untrusted hosts", async () => {
|
||||
electron.openExternal.mockClear();
|
||||
electron.showMessageBox.mockClear();
|
||||
electron.showMessageBox.mockResolvedValueOnce({ response: 0 });
|
||||
|
||||
const result = await openUrl("https://example.com/report");
|
||||
|
||||
expect(result).toEqual({ ok: true, data: true });
|
||||
expect(electron.showMessageBox).toHaveBeenCalledTimes(1);
|
||||
expect(electron.openExternal).toHaveBeenCalledWith("https://example.com/report");
|
||||
});
|
||||
|
||||
it("blocks custom schemes before shell handoff", async () => {
|
||||
electron.openExternal.mockClear();
|
||||
electron.showMessageBox.mockClear();
|
||||
|
||||
const result = await openUrl("vscode://file/etc/passwd");
|
||||
|
||||
expect(result).toMatchObject({ ok: false, error: { code: "OPEN_EXTERNAL_URL_FORBIDDEN" } });
|
||||
expect(electron.showMessageBox).not.toHaveBeenCalled();
|
||||
expect(electron.openExternal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks credential-bearing URLs before shell handoff", async () => {
|
||||
electron.openExternal.mockClear();
|
||||
electron.showMessageBox.mockClear();
|
||||
|
||||
const result = await openUrl("https://user:pass@example.com/report");
|
||||
|
||||
expect(result).toMatchObject({ ok: false, error: { code: "OPEN_EXTERNAL_URL_FORBIDDEN" } });
|
||||
expect(electron.showMessageBox).not.toHaveBeenCalled();
|
||||
expect(electron.openExternal).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,89 +0,0 @@
|
||||
import type { IpcMainInvokeEvent } from "electron";
|
||||
import { z } from "zod";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { DesktopResult } from "@shared/contracts";
|
||||
|
||||
const { ipcHandlers, ipcHandle } = vi.hoisted(() => {
|
||||
const handlers = new Map<string, (event: IpcMainInvokeEvent, ...args: unknown[]) => Promise<DesktopResult<unknown>>>();
|
||||
return {
|
||||
ipcHandlers: handlers,
|
||||
ipcHandle: vi.fn((channel: string, handler: (event: IpcMainInvokeEvent, ...args: unknown[]) => Promise<DesktopResult<unknown>>) => {
|
||||
handlers.set(channel, handler);
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("electron", () => ({
|
||||
ipcMain: {
|
||||
handle: ipcHandle,
|
||||
},
|
||||
Notification: {
|
||||
isSupported: () => false,
|
||||
},
|
||||
}));
|
||||
|
||||
import { createIpcSupport } from "../../src/main/ipc-support";
|
||||
|
||||
describe("IPC support error codes", () => {
|
||||
beforeEach(() => {
|
||||
ipcHandlers.clear();
|
||||
ipcHandle.mockClear();
|
||||
});
|
||||
|
||||
it("returns the classified handler code without leaking the raw exception message", async () => {
|
||||
const { registerHandle } = createSupport();
|
||||
registerHandle("test:throws", () => {
|
||||
throw new Error("database password leaked in raw error");
|
||||
});
|
||||
|
||||
const result = await invokeRegisteredHandler("test:throws");
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "ipc.handler.failed",
|
||||
message: "The desktop operation failed. Use the error code when reporting this issue.",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("returns the classified payload code for zod validation failures", async () => {
|
||||
const { registerHandle } = createSupport();
|
||||
registerHandle("test:payload", () => {
|
||||
z.object({ name: z.string() }).parse({ name: 42 });
|
||||
return { ok: true, data: true };
|
||||
});
|
||||
|
||||
const result = await invokeRegisteredHandler("test:payload");
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "ipc.payload.invalid",
|
||||
message: "The desktop request payload is invalid.",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function createSupport() {
|
||||
return createIpcSupport({
|
||||
isDev: true,
|
||||
devServerOrigin: "http://localhost:5173",
|
||||
logger: { error: vi.fn() },
|
||||
getMainWindow: () => null,
|
||||
getDb: () => ({
|
||||
hasCompany: () => true,
|
||||
belongsToCompany: () => true,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async function invokeRegisteredHandler(channel: string): Promise<DesktopResult<unknown>> {
|
||||
const handler = ipcHandlers.get(channel);
|
||||
if (!handler) throw new Error(`No IPC handler registered for ${channel}.`);
|
||||
return handler({
|
||||
senderFrame: { url: "http://localhost:5173/app" },
|
||||
sender: { getURL: () => "http://localhost:5173/app" },
|
||||
} as IpcMainInvokeEvent);
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { createRovingTabIndex as useRovingTabIndex } from "@renderer/lib/keyboard";
|
||||
|
||||
// Since vitest runs in Node (no DOM), we test the hook logic
|
||||
// by extracting and testing the pure callback functions directly.
|
||||
// We create a minimal mock of React hooks to isolate the logic.
|
||||
|
||||
// -- Helper to create a mock KeyboardEvent-like object --
|
||||
function mockKeyEvent(key: string): {
|
||||
key: string;
|
||||
preventDefault: () => void;
|
||||
defaultPrevented: boolean;
|
||||
} {
|
||||
let prevented = false;
|
||||
return {
|
||||
key,
|
||||
preventDefault() {
|
||||
prevented = true;
|
||||
},
|
||||
get defaultPrevented() {
|
||||
return prevented;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("useRovingTabIndex", () => {
|
||||
it("returns containerRef, focusedIndex (default 0), handleKeyDown, getTabIndex", () => {
|
||||
const result = useRovingTabIndex(5);
|
||||
expect(result).toHaveProperty("containerRef");
|
||||
expect(result).toHaveProperty("focusedIndex");
|
||||
expect(result).toHaveProperty("handleKeyDown");
|
||||
expect(result).toHaveProperty("getTabIndex");
|
||||
expect(result.focusedIndex).toBe(0);
|
||||
expect(typeof result.handleKeyDown).toBe("function");
|
||||
expect(typeof result.getTabIndex).toBe("function");
|
||||
});
|
||||
|
||||
it("ArrowDown increments focusedIndex, wraps from last to 0", () => {
|
||||
const result = useRovingTabIndex(3);
|
||||
expect(result.focusedIndex).toBe(0);
|
||||
|
||||
// Press ArrowDown once -> index 1
|
||||
const event1 = mockKeyEvent("ArrowDown");
|
||||
result.handleKeyDown(event1 as unknown as React.KeyboardEvent);
|
||||
expect(result.focusedIndex).toBe(1);
|
||||
expect(event1.defaultPrevented).toBe(true);
|
||||
|
||||
// Press ArrowDown again -> index 2
|
||||
const event2 = mockKeyEvent("ArrowDown");
|
||||
result.handleKeyDown(event2 as unknown as React.KeyboardEvent);
|
||||
expect(result.focusedIndex).toBe(2);
|
||||
|
||||
// Press ArrowDown again -> wraps to 0
|
||||
const event3 = mockKeyEvent("ArrowDown");
|
||||
result.handleKeyDown(event3 as unknown as React.KeyboardEvent);
|
||||
expect(result.focusedIndex).toBe(0);
|
||||
});
|
||||
|
||||
it("ArrowUp decrements focusedIndex, wraps from 0 to last", () => {
|
||||
const result = useRovingTabIndex(3);
|
||||
expect(result.focusedIndex).toBe(0);
|
||||
|
||||
// Press ArrowUp at 0 -> wraps to 2
|
||||
const event1 = mockKeyEvent("ArrowUp");
|
||||
result.handleKeyDown(event1 as unknown as React.KeyboardEvent);
|
||||
expect(result.focusedIndex).toBe(2);
|
||||
expect(event1.defaultPrevented).toBe(true);
|
||||
|
||||
// Press ArrowUp at 2 -> index 1
|
||||
const event2 = mockKeyEvent("ArrowUp");
|
||||
result.handleKeyDown(event2 as unknown as React.KeyboardEvent);
|
||||
expect(result.focusedIndex).toBe(1);
|
||||
});
|
||||
|
||||
it("Home sets focusedIndex to 0", () => {
|
||||
const result = useRovingTabIndex(5);
|
||||
// Move to index 3 first
|
||||
result.handleKeyDown(mockKeyEvent("ArrowDown") as unknown as React.KeyboardEvent);
|
||||
result.handleKeyDown(mockKeyEvent("ArrowDown") as unknown as React.KeyboardEvent);
|
||||
result.handleKeyDown(mockKeyEvent("ArrowDown") as unknown as React.KeyboardEvent);
|
||||
expect(result.focusedIndex).toBe(3);
|
||||
|
||||
// Press Home -> index 0
|
||||
const event = mockKeyEvent("Home");
|
||||
result.handleKeyDown(event as unknown as React.KeyboardEvent);
|
||||
expect(result.focusedIndex).toBe(0);
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
});
|
||||
|
||||
it("End sets focusedIndex to itemCount - 1", () => {
|
||||
const result = useRovingTabIndex(5);
|
||||
expect(result.focusedIndex).toBe(0);
|
||||
|
||||
// Press End -> index 4
|
||||
const event = mockKeyEvent("End");
|
||||
result.handleKeyDown(event as unknown as React.KeyboardEvent);
|
||||
expect(result.focusedIndex).toBe(4);
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
});
|
||||
|
||||
it("getTabIndex returns 0 for focusedIndex, -1 for others", () => {
|
||||
const result = useRovingTabIndex(5);
|
||||
// Default focusedIndex is 0
|
||||
expect(result.getTabIndex(0)).toBe(0);
|
||||
expect(result.getTabIndex(1)).toBe(-1);
|
||||
expect(result.getTabIndex(2)).toBe(-1);
|
||||
expect(result.getTabIndex(4)).toBe(-1);
|
||||
|
||||
// Move to index 2
|
||||
result.handleKeyDown(mockKeyEvent("ArrowDown") as unknown as React.KeyboardEvent);
|
||||
result.handleKeyDown(mockKeyEvent("ArrowDown") as unknown as React.KeyboardEvent);
|
||||
expect(result.focusedIndex).toBe(2);
|
||||
expect(result.getTabIndex(0)).toBe(-1);
|
||||
expect(result.getTabIndex(2)).toBe(0);
|
||||
});
|
||||
|
||||
it("j key acts same as ArrowDown, k key acts same as ArrowUp", () => {
|
||||
const result = useRovingTabIndex(3);
|
||||
expect(result.focusedIndex).toBe(0);
|
||||
|
||||
// j -> index 1
|
||||
const jEvent = mockKeyEvent("j");
|
||||
result.handleKeyDown(jEvent as unknown as React.KeyboardEvent);
|
||||
expect(result.focusedIndex).toBe(1);
|
||||
expect(jEvent.defaultPrevented).toBe(true);
|
||||
|
||||
// j -> index 2
|
||||
result.handleKeyDown(mockKeyEvent("j") as unknown as React.KeyboardEvent);
|
||||
expect(result.focusedIndex).toBe(2);
|
||||
|
||||
// k -> index 1
|
||||
const kEvent = mockKeyEvent("k");
|
||||
result.handleKeyDown(kEvent as unknown as React.KeyboardEvent);
|
||||
expect(result.focusedIndex).toBe(1);
|
||||
expect(kEvent.defaultPrevented).toBe(true);
|
||||
|
||||
// k -> index 0
|
||||
result.handleKeyDown(mockKeyEvent("k") as unknown as React.KeyboardEvent);
|
||||
expect(result.focusedIndex).toBe(0);
|
||||
});
|
||||
|
||||
it("handleKeyDown is no-op when itemCount is 0", () => {
|
||||
const result = useRovingTabIndex(0);
|
||||
expect(result.focusedIndex).toBe(0);
|
||||
|
||||
const event = mockKeyEvent("ArrowDown");
|
||||
result.handleKeyDown(event as unknown as React.KeyboardEvent);
|
||||
expect(result.focusedIndex).toBe(0);
|
||||
// preventDefault should NOT be called when itemCount is 0
|
||||
expect(event.defaultPrevented).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,59 +0,0 @@
|
||||
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.core.company.saveCompany({
|
||||
name: "Knowledge Provenance Co",
|
||||
description: "Tests sourceRunId updates on knowledge entry edits",
|
||||
status: "active",
|
||||
});
|
||||
|
||||
const knowledgeId = db.core.knowledge.saveKnowledgeEntry({
|
||||
companyId,
|
||||
category: "lesson_learned",
|
||||
topic: "Original lesson",
|
||||
content: "Initial content",
|
||||
importance: "medium",
|
||||
sourceRunId: null,
|
||||
});
|
||||
|
||||
db.core.knowledge.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();
|
||||
});
|
||||
});
|
||||
@@ -1,83 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { platformDownloads, type PlatformDownload } from "../../landing/components/release-links.generated";
|
||||
import { buildPlatformSupportSummary } from "../../landing/lib/platform-support";
|
||||
|
||||
describe("landing platform support copy", () => {
|
||||
it("derives FAQ support copy from current generated release assets", () => {
|
||||
const summary = buildPlatformSupportSummary(platformDownloads);
|
||||
|
||||
expect(summary.downloadAnswer).toBe(
|
||||
"Use the download section on this page to choose the build that matches your architecture and package format. " +
|
||||
"macOS ships as signed and notarized DMG builds for arm64 and x64. " +
|
||||
"Windows ships as an NSIS installer build for arm64 and x64. " +
|
||||
"Linux ships as AppImage, DEB, and RPM builds for arm64 and x64.",
|
||||
);
|
||||
expect(summary.platformAnswer).toBe(
|
||||
"AgentCompany is available on macOS (arm64, x64), Windows (arm64, x64), " +
|
||||
"and Linux (arm64, x64). It is built with Electron and the desktop feature set stays aligned across all supported targets.",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses structured architectures and platform package notes", () => {
|
||||
const downloads = [
|
||||
platform("macos", "macOS", "PKG builds", "PKG Builds", [["Apple Silicon", ["arm64"], "display-only note"]]),
|
||||
platform("windows", "Windows", "an MSIX build", "MSIX Builds", [["Universal", ["arm64", "x64"], "ARM64 + x64"]]),
|
||||
platform("linux", "Linux", "tarball builds", "Tarball Builds", [["Linux bundle", ["x64"], "x64 (glibc)"]]),
|
||||
];
|
||||
|
||||
const summary = buildPlatformSupportSummary(downloads);
|
||||
|
||||
expect(summary.downloadAnswer).toBe(
|
||||
"Use the download section on this page to choose the build that matches your architecture and package format. " +
|
||||
"macOS ships as PKG builds for arm64. " +
|
||||
"Windows ships as an MSIX build for arm64 and x64. " +
|
||||
"Linux ships as tarball builds for x64.",
|
||||
);
|
||||
expect(summary.platformAnswer).toBe(
|
||||
"AgentCompany is available on macOS (arm64), Windows (arm64, x64), and Linux (x64). " +
|
||||
"It is built with Electron and the desktop feature set stays aligned across all supported targets.",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps generated asset notes synchronized with structured architectures", () => {
|
||||
for (const platformDownload of platformDownloads) {
|
||||
for (const asset of platformDownload.assets) {
|
||||
expect(asset.note).toBe(asset.architectures.join(" + "));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("fails when generated release assets omit architecture metadata", () => {
|
||||
const downloads = [
|
||||
platform("macos", "macOS", "DMG builds", "DMG Builds", [["Apple Silicon", [], "arm64"]]),
|
||||
platform("windows", "Windows", "an NSIS installer build", "NSIS Builds", [["Universal", ["arm64", "x64"], "arm64 + x64"]]),
|
||||
platform("linux", "Linux", "AppImage builds", "AppImage Builds", [["Linux", ["x64"], "x64"]]),
|
||||
];
|
||||
|
||||
expect(() => buildPlatformSupportSummary(downloads)).toThrow(
|
||||
"Release asset macos/macos-apple-silicon must declare at least one architecture.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function platform(
|
||||
id: PlatformDownload["id"],
|
||||
title: string,
|
||||
packageDescription: string,
|
||||
note: string,
|
||||
assets: Array<[label: string, architectures: PlatformDownload["assets"][number]["architectures"], note: string]>,
|
||||
): PlatformDownload {
|
||||
return {
|
||||
id,
|
||||
packageDescription,
|
||||
title,
|
||||
note,
|
||||
assets: assets.map(([label, architectures, assetNote]) => ({
|
||||
architectures,
|
||||
id: `${id}-${label.toLowerCase().replaceAll(/\s+/g, "-")}`,
|
||||
label,
|
||||
href: `https://download.example/${id}`,
|
||||
note: assetNote,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { releaseVersion } from "../../landing/components/release-links.generated";
|
||||
import {
|
||||
landingHeroNarrative,
|
||||
landingHowItWorksNarrative,
|
||||
landingStructuredDataNarrative,
|
||||
onboardingFlowNarrative,
|
||||
} from "../../landing/lib/product-narrative";
|
||||
|
||||
describe("landing product narrative", () => {
|
||||
it("uses the generated release version for software structured data", () => {
|
||||
expect(releaseVersion).toMatch(/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/);
|
||||
expect(landingStructuredDataNarrative.softwareVersion).toBe(releaseVersion);
|
||||
});
|
||||
|
||||
it("describes the current first-run setup flow", () => {
|
||||
expect(landingHeroNarrative).toMatchObject({
|
||||
eyebrow: "AgentCompany Desktop",
|
||||
titleLine1: "Launch an Autonomous AI Company",
|
||||
titleLine2: "from Your Desktop",
|
||||
});
|
||||
expect(landingHeroNarrative.description).toContain("AI CEO by default");
|
||||
expect(landingHeroNarrative.description).toContain("AI Co-Founder for human-reviewed decisions");
|
||||
|
||||
expect(landingHowItWorksNarrative.steps.map((step) => step.title)).toEqual([
|
||||
"Requirements",
|
||||
"Providers",
|
||||
"Company",
|
||||
"Team Models",
|
||||
"Mode",
|
||||
"Mission",
|
||||
"Launch",
|
||||
]);
|
||||
expect(landingHowItWorksNarrative.demoMessages.map((message) => message.text)).toEqual([
|
||||
"Node.js ready. CLI runtimes can call the local API.",
|
||||
"Runtime connected.",
|
||||
"Company profile and workspace saved.",
|
||||
"Model pool enabled for CEO and workers.",
|
||||
"AI CEO mode selected.",
|
||||
"First goal and acceptance description captured.",
|
||||
"Company, CEO agent, kickoff task, and initial run created.",
|
||||
]);
|
||||
expect(landingStructuredDataNarrative.featureList).toContain(
|
||||
"Multi-connector support (Claude Code, Codex CLI, Gemini CLI)",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps first-run documentation ordered by the same setup sections", () => {
|
||||
const files = [
|
||||
"README.md",
|
||||
"docs/rebuild/05-design-system-and-flows.md",
|
||||
"landing/README.md",
|
||||
];
|
||||
|
||||
for (const file of files) {
|
||||
const text = readFileSync(file, "utf8");
|
||||
const positions = [];
|
||||
let searchOffset = 0;
|
||||
|
||||
for (const step of onboardingFlowNarrative.sections) {
|
||||
const position = text.indexOf(step.title, searchOffset);
|
||||
positions.push(position);
|
||||
searchOffset = position + step.title.length;
|
||||
}
|
||||
|
||||
expect(positions.every((position) => position >= 0)).toBe(true);
|
||||
expect(positions).toEqual([...positions].sort((left, right) => left - right));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,406 +0,0 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
beforeAll(() => {
|
||||
process.env.AGENTCOMPANY_ALLOW_INSECURE_VAULT = "true";
|
||||
});
|
||||
|
||||
vi.mock("electron", () => ({
|
||||
safeStorage: {
|
||||
isEncryptionAvailable: () => false,
|
||||
getSelectedStorageBackend: () => "basic_text",
|
||||
encryptString: (value: string) => Buffer.from(value, "utf8"),
|
||||
decryptString: (value: Buffer) => value.toString("utf8"),
|
||||
},
|
||||
}));
|
||||
|
||||
import { AppDatabase } from "../../src/main/database";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
const ts = "2026-04-24T00:00:00.000Z";
|
||||
|
||||
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 insertLegacyMigrationRows(db: DatabaseSync, maxVersion: number, options?: { spacedSchemaTable?: boolean }): void {
|
||||
db.exec(options?.spacedSchemaTable
|
||||
? "create table schema_migrations ( version integer primary key, name text not null, applied_at text not null )"
|
||||
: "create table schema_migrations (version integer primary key, name text not null, applied_at text not null)");
|
||||
for (let version = 1; version <= maxVersion; version += 1) {
|
||||
db
|
||||
.prepare("insert into schema_migrations (version, name, applied_at) values (?, ?, ?)")
|
||||
.run(version, `legacy-${version}`, ts);
|
||||
}
|
||||
}
|
||||
|
||||
function seedLegacyOperationalProfile(db: DatabaseSync, options?: { spacedSchemaTable?: boolean }): void {
|
||||
insertLegacyMigrationRows(db, 55, options);
|
||||
db.exec(`
|
||||
create table settings (key text primary key, value text not null);
|
||||
create table companies (id text primary key, name text not null, description text not null, status text not null, created_at text not null, updated_at text not null);
|
||||
create table agents (
|
||||
id text primary key,
|
||||
company_id text not null,
|
||||
name text not null,
|
||||
role text not null,
|
||||
title text not null,
|
||||
status text not null,
|
||||
reports_to text,
|
||||
connector_id text not null,
|
||||
workspace_id text,
|
||||
model text,
|
||||
capabilities text not null,
|
||||
budget_monthly_usd real not null default 0,
|
||||
spent_monthly_usd real not null default 0,
|
||||
metadata_json text not null default '{}',
|
||||
created_at text not null,
|
||||
updated_at text not null,
|
||||
department text
|
||||
);
|
||||
create table tasks (
|
||||
id text primary key,
|
||||
company_id text not null,
|
||||
title text not null,
|
||||
description text not null,
|
||||
assignee_agent_id text,
|
||||
workspace_id text,
|
||||
status text not null,
|
||||
priority text not null,
|
||||
active_run_id text,
|
||||
session_json text,
|
||||
session_display_id text,
|
||||
phase text,
|
||||
is_heartbeat integer not null default 0,
|
||||
created_at text not null,
|
||||
updated_at text not null
|
||||
);
|
||||
create table runs (
|
||||
id text primary key,
|
||||
company_id text not null,
|
||||
task_id text,
|
||||
agent_id text not null,
|
||||
workspace_id text,
|
||||
connector_id text not null,
|
||||
status text not null,
|
||||
summary text not null,
|
||||
unattributed_cost integer not null default 0,
|
||||
session_display_id text,
|
||||
context_snapshot_json text,
|
||||
created_at text not null,
|
||||
updated_at text not null
|
||||
);
|
||||
create table work_products (
|
||||
id text primary key,
|
||||
company_id text not null,
|
||||
project_id text,
|
||||
task_id text,
|
||||
type text not null,
|
||||
provider text not null,
|
||||
external_id text,
|
||||
title text not null,
|
||||
url text,
|
||||
status text not null default 'active',
|
||||
review_state text not null default 'none',
|
||||
is_primary integer not null default 0,
|
||||
summary text,
|
||||
metadata_json text,
|
||||
created_by_run_id text,
|
||||
created_at text not null,
|
||||
updated_at text not null,
|
||||
foreign key (task_id) references tasks(id) on delete cascade
|
||||
);
|
||||
create table pending_wakes (id text primary key, company_id text not null, agent_id text not null, trigger_reason text not null, created_at text not null);
|
||||
create table secrets (id text primary key, name text not null, description text not null, backend text not null, ciphertext blob not null, updated_at text not null, created_at text not null);
|
||||
`);
|
||||
db.prepare("insert into settings (key, value) values ('current_company_id', 'company-1')").run();
|
||||
db
|
||||
.prepare("insert into companies (id, name, description, status, created_at, updated_at) values (?, ?, ?, ?, ?, ?)")
|
||||
.run("company-1", "Legacy Co", "", "active", ts, ts);
|
||||
db
|
||||
.prepare("insert into agents (id, company_id, name, role, title, status, reports_to, connector_id, workspace_id, model, capabilities, budget_monthly_usd, spent_monthly_usd, metadata_json, created_at, updated_at, department) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
|
||||
.run("agent-1", "company-1", "Engineer", "Engineer", "Engineer", "active", null, "openai-codex", null, null, "code", 12.34, 0, "{}", ts, ts, "engineering");
|
||||
db
|
||||
.prepare("insert into tasks (id, company_id, title, description, assignee_agent_id, workspace_id, status, priority, active_run_id, session_json, session_display_id, phase, is_heartbeat, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
|
||||
.run("task-1", "company-1", "Legacy task", "", "agent-1", "workspace-task", "todo", "medium", null, "{\"provider\":\"legacy\"}", "display-1", "todo", 0, ts, ts);
|
||||
db
|
||||
.prepare("insert into tasks (id, company_id, title, description, assignee_agent_id, workspace_id, status, priority, active_run_id, session_json, session_display_id, phase, is_heartbeat, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
|
||||
.run("heartbeat-task", "company-1", "[Heartbeat] agent-1", "", "agent-1", "workspace-task", "in_progress", "low", "run-heartbeat", null, null, "in_progress", 1, ts, ts);
|
||||
db
|
||||
.prepare("insert into runs (id, company_id, task_id, agent_id, workspace_id, connector_id, status, summary, unattributed_cost, session_display_id, context_snapshot_json, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
|
||||
.run("run-heartbeat", "company-1", "heartbeat-task", "agent-1", "workspace-task", "openai-codex", "running", "", 0, "run-display", "{}", ts, ts);
|
||||
db
|
||||
.prepare("insert into runs (id, company_id, task_id, agent_id, workspace_id, connector_id, status, summary, unattributed_cost, session_display_id, context_snapshot_json, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
|
||||
.run("run-product", "company-1", null, "agent-1", "workspace-run", "openai-codex", "done", "", 0, "run-display-2", "{}", ts, ts);
|
||||
db
|
||||
.prepare("insert into runs (id, company_id, task_id, agent_id, workspace_id, connector_id, status, summary, unattributed_cost, session_display_id, context_snapshot_json, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
|
||||
.run("run-cancelled", "company-1", null, "agent-1", "workspace-run", "openai-codex", "cancelled", "", 1, "run-display-3", "{}", ts, ts);
|
||||
db
|
||||
.prepare("insert into work_products (id, company_id, task_id, type, provider, external_id, title, status, review_state, is_primary, created_by_run_id, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
|
||||
.run("wp-task", "company-1", "task-1", "file", "filesystem", "task.md", "Task file", "active", "none", 1, null, ts, ts);
|
||||
db
|
||||
.prepare("insert into work_products (id, company_id, task_id, type, provider, external_id, title, status, review_state, is_primary, created_by_run_id, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
|
||||
.run("wp-run", "company-1", null, "file", "filesystem", "run.md", "Run file", "active", "none", 0, "run-product", ts, ts);
|
||||
db
|
||||
.prepare("insert into pending_wakes (id, company_id, agent_id, trigger_reason, created_at) values (?, ?, ?, ?, ?)")
|
||||
.run("wake-1", "company-1", "agent-1", "manual", ts);
|
||||
db
|
||||
.prepare("insert into secrets (id, name, description, backend, ciphertext, updated_at, created_at) values (?, ?, ?, ?, ?, ?, ?)")
|
||||
.run("secret-1", "api", "API key", "safeStorage", Buffer.from("encrypted"), ts, ts);
|
||||
}
|
||||
|
||||
describe("legacy to v56 baseline migration", () => {
|
||||
it("migrates legacy data helpers and compresses schema history", async () => {
|
||||
const profileDir = await createTempDir("agentcompany-legacy-baseline-");
|
||||
const rawDb = new DatabaseSync(join(profileDir, "profile.sqlite"));
|
||||
try {
|
||||
seedLegacyOperationalProfile(rawDb);
|
||||
} finally {
|
||||
rawDb.close();
|
||||
}
|
||||
|
||||
let appDb = new AppDatabase(profileDir);
|
||||
appDb.init();
|
||||
try {
|
||||
expect(appDb.queryAll("select version, name from schema_migrations")).toEqual([
|
||||
{ version: 56, name: "baseline_v56" },
|
||||
{ version: 57, name: "task_leases_runtime_ownership" },
|
||||
{ version: 58, name: "approval_policy_table" },
|
||||
{ version: 59, name: "social_company_scope" },
|
||||
{ version: 60, name: "filesystem_task_workspaces" },
|
||||
]);
|
||||
expect(appDb.queryAll<{ companyId: string }>("select company_id as companyId from secrets where id = 'secret-1'")[0])
|
||||
.toEqual({ companyId: "company-1" });
|
||||
expect(appDb.queryAll<{ source: string; triggerDetail: string }>("select source, trigger_detail as triggerDetail from wakeup_requests where id = 'wake-1'")[0])
|
||||
.toEqual({ source: "manual", triggerDetail: JSON.stringify([{ trigger: "manual", at: ts, detail: "manual" }]) });
|
||||
expect(appDb.queryAll<{ amount: number }>("select amount from budget_policies where scope_type = 'agent' and scope_id = 'agent-1'")[0])
|
||||
.toEqual({ amount: 1234 });
|
||||
expect(appDb.queryAll<{ agentId: string; adapterType: string }>("select agent_id as agentId, adapter_type as adapterType from task_sessions")[0])
|
||||
.toEqual({ agentId: "agent-1", adapterType: "openai-codex" });
|
||||
expect(appDb.queryAll("select id from tasks where id = 'heartbeat-task'")).toEqual([]);
|
||||
expect(appDb.queryAll<{ taskId: string | null }>("select task_id as taskId from runs where id = 'run-heartbeat'")[0])
|
||||
.toEqual({ taskId: null });
|
||||
expect(appDb.queryAll<{ id: string; usageStatus: string }>("select id, usage_status as usageStatus from runs where id in ('run-product', 'run-cancelled') order by id"))
|
||||
.toEqual([
|
||||
{ id: "run-cancelled", usageStatus: "aborted_before_usage" },
|
||||
{ id: "run-product", usageStatus: "ok" },
|
||||
]);
|
||||
expect(appDb.queryAll<{ id: string; workspaceId: string | null; lastTouchedByRunId: string | null }>("select id, workspace_id as workspaceId, last_touched_by_run_id as lastTouchedByRunId from work_products order by id"))
|
||||
.toEqual([
|
||||
{ id: "wp-run", workspaceId: "workspace-run", lastTouchedByRunId: "run-product" },
|
||||
{ id: "wp-task", workspaceId: "workspace-task", lastTouchedByRunId: null },
|
||||
]);
|
||||
} finally {
|
||||
appDb.close();
|
||||
}
|
||||
|
||||
appDb = new AppDatabase(profileDir);
|
||||
appDb.init();
|
||||
try {
|
||||
expect(appDb.queryAll("select version, name from schema_migrations")).toEqual([
|
||||
{ version: 56, name: "baseline_v56" },
|
||||
{ version: 57, name: "task_leases_runtime_ownership" },
|
||||
{ version: 58, name: "approval_policy_table" },
|
||||
{ version: 59, name: "social_company_scope" },
|
||||
{ version: 60, name: "filesystem_task_workspaces" },
|
||||
]);
|
||||
expect(appDb.queryAll("select id from budget_policies where scope_type = 'agent' and scope_id = 'agent-1'"))
|
||||
.toHaveLength(1);
|
||||
} finally {
|
||||
appDb.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts legacy schema_migrations formatting during baseline assertion", async () => {
|
||||
const profileDir = await createTempDir("agentcompany-legacy-schema-format-");
|
||||
const rawDb = new DatabaseSync(join(profileDir, "profile.sqlite"));
|
||||
try {
|
||||
seedLegacyOperationalProfile(rawDb, { spacedSchemaTable: true });
|
||||
} finally {
|
||||
rawDb.close();
|
||||
}
|
||||
|
||||
const appDb = new AppDatabase(profileDir);
|
||||
appDb.init();
|
||||
try {
|
||||
expect(appDb.queryAll("select version, name from schema_migrations")).toEqual([
|
||||
{ version: 56, name: "baseline_v56" },
|
||||
{ version: 57, name: "task_leases_runtime_ownership" },
|
||||
{ version: 58, name: "approval_policy_table" },
|
||||
{ version: 59, name: "social_company_scope" },
|
||||
{ version: 60, name: "filesystem_task_workspaces" },
|
||||
]);
|
||||
} finally {
|
||||
appDb.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rolls back when a legacy table would drop an unknown column", async () => {
|
||||
const profileDir = await createTempDir("agentcompany-legacy-rollback-");
|
||||
const rawDb = new DatabaseSync(join(profileDir, "profile.sqlite"));
|
||||
try {
|
||||
insertLegacyMigrationRows(rawDb, 1);
|
||||
rawDb.exec(`
|
||||
create table companies (
|
||||
id text primary key,
|
||||
name text not null,
|
||||
description text not null,
|
||||
status text not null,
|
||||
created_at text not null,
|
||||
updated_at text not null,
|
||||
rogue_payload text not null
|
||||
);
|
||||
`);
|
||||
rawDb
|
||||
.prepare("insert into companies (id, name, description, status, created_at, updated_at, rogue_payload) values (?, ?, ?, ?, ?, ?, ?)")
|
||||
.run("company-1", "Legacy Co", "", "active", ts, ts, "keep");
|
||||
} finally {
|
||||
rawDb.close();
|
||||
}
|
||||
|
||||
const appDb = new AppDatabase(profileDir);
|
||||
try {
|
||||
expect(() => appDb.init()).toThrow(/unexpected legacy columns/);
|
||||
expect(appDb.queryAll<{ name: string }>("pragma table_info(companies)").map((row) => row.name))
|
||||
.toContain("rogue_payload");
|
||||
expect(appDb.queryAll("select version from schema_migrations")).toEqual([{ version: 1 }]);
|
||||
} finally {
|
||||
appDb.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rolls back v56 compression when baseline drift is detected", async () => {
|
||||
const profileDir = await createTempDir("agentcompany-v56-drift-rollback-");
|
||||
const rawDb = new DatabaseSync(join(profileDir, "profile.sqlite"));
|
||||
try {
|
||||
rawDb.exec(`
|
||||
create table schema_migrations (version integer primary key, name text not null, applied_at text not null);
|
||||
insert into schema_migrations (version, name, applied_at) values (55, 'legacy-55', '${ts}');
|
||||
insert into schema_migrations (version, name, applied_at) values (56, 'legacy-56', '${ts}');
|
||||
create table companies (id text primary key, name text not null);
|
||||
`);
|
||||
} finally {
|
||||
rawDb.close();
|
||||
}
|
||||
|
||||
const appDb = new AppDatabase(profileDir);
|
||||
try {
|
||||
expect(() => appDb.init()).toThrow(/Baseline v56 table drift/);
|
||||
expect(appDb.queryAll("select version, name from schema_migrations order by version")).toEqual([
|
||||
{ version: 55, name: "legacy-55" },
|
||||
{ version: 56, name: "legacy-56" },
|
||||
]);
|
||||
expect(appDb.queryAll("select name from sqlite_master where type = 'table' and name = 'agents'")).toEqual([]);
|
||||
} finally {
|
||||
appDb.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("migrates task session rows through the old task-scoped table shape", async () => {
|
||||
const profileDir = await createTempDir("agentcompany-old-task-sessions-");
|
||||
const rawDb = new DatabaseSync(join(profileDir, "profile.sqlite"));
|
||||
try {
|
||||
insertLegacyMigrationRows(rawDb, 40);
|
||||
rawDb.exec(`
|
||||
create table settings (key text primary key, value text not null);
|
||||
create table companies (id text primary key, name text not null, description text not null, status text not null, created_at text not null, updated_at text not null);
|
||||
create table agents (
|
||||
id text primary key,
|
||||
company_id text not null,
|
||||
name text not null,
|
||||
role text not null,
|
||||
title text not null,
|
||||
status text not null,
|
||||
reports_to text,
|
||||
connector_id text not null,
|
||||
workspace_id text,
|
||||
model text,
|
||||
capabilities text not null,
|
||||
budget_monthly_usd real not null default 0,
|
||||
spent_monthly_usd real not null default 0,
|
||||
metadata_json text not null default '{}',
|
||||
created_at text not null,
|
||||
updated_at text not null
|
||||
);
|
||||
create table tasks (
|
||||
id text primary key,
|
||||
company_id text not null,
|
||||
title text not null,
|
||||
description text not null,
|
||||
assignee_agent_id text,
|
||||
status text not null,
|
||||
priority text not null,
|
||||
session_json text,
|
||||
session_display_id text,
|
||||
active_session_id text,
|
||||
created_at text not null,
|
||||
updated_at text not null
|
||||
);
|
||||
create table task_sessions (
|
||||
id text primary key,
|
||||
company_id text not null,
|
||||
task_id text not null,
|
||||
agent_id text not null,
|
||||
adapter_type text not null,
|
||||
session_params_json text,
|
||||
session_display_id text,
|
||||
created_at text not null,
|
||||
updated_at text not null
|
||||
);
|
||||
`);
|
||||
rawDb
|
||||
.prepare("insert into companies (id, name, description, status, created_at, updated_at) values (?, ?, ?, ?, ?, ?)")
|
||||
.run("company-1", "Legacy Co", "", "active", ts, ts);
|
||||
rawDb
|
||||
.prepare("insert into agents (id, company_id, name, role, title, status, reports_to, connector_id, workspace_id, model, capabilities, budget_monthly_usd, spent_monthly_usd, metadata_json, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
|
||||
.run("agent-1", "company-1", "Engineer", "Engineer", "Engineer", "active", null, "openai-codex", null, null, "code", 0, 0, "{}", ts, ts);
|
||||
rawDb
|
||||
.prepare("insert into tasks (id, company_id, title, description, assignee_agent_id, status, priority, session_json, session_display_id, active_session_id, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
|
||||
.run("task-1", "company-1", "Legacy task", "", "agent-1", "todo", "medium", "{\"provider\":\"legacy\"}", "display-1", null, ts, ts);
|
||||
} finally {
|
||||
rawDb.close();
|
||||
}
|
||||
|
||||
const appDb = new AppDatabase(profileDir);
|
||||
appDb.init();
|
||||
try {
|
||||
expect(appDb.queryAll<{ name: string }>("pragma table_info(task_sessions)").map((row) => row.name))
|
||||
.toEqual([
|
||||
"id",
|
||||
"company_id",
|
||||
"agent_id",
|
||||
"adapter_type",
|
||||
"last_run_id",
|
||||
"last_error",
|
||||
"run_count",
|
||||
"total_input_tokens",
|
||||
"total_output_tokens",
|
||||
"rotated_at",
|
||||
"rotation_reason",
|
||||
"handoff_markdown",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]);
|
||||
expect(appDb.queryAll<{ agentId: string; adapterType: string }>("select agent_id as agentId, adapter_type as adapterType from task_sessions"))
|
||||
.toEqual([{ agentId: "agent-1", adapterType: "openai-codex" }]);
|
||||
expect(appDb.queryAll("select version, name from schema_migrations")).toEqual([
|
||||
{ version: 56, name: "baseline_v56" },
|
||||
{ version: 57, name: "task_leases_runtime_ownership" },
|
||||
{ version: 58, name: "approval_policy_table" },
|
||||
{ version: 59, name: "social_company_scope" },
|
||||
{ version: 60, name: "filesystem_task_workspaces" },
|
||||
]);
|
||||
} finally {
|
||||
appDb.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,61 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { redactTokens } from "../../src/main/worker/observability/log-redactor";
|
||||
|
||||
/** Verifies the worker process token redactor strips known
|
||||
* credential-shaped substrings from log strings before they leave the
|
||||
* worker process. */
|
||||
describe("worker log redactor", () => {
|
||||
it("redacts Bearer tokens", () => {
|
||||
const input = "Authorization: Bearer abc.def.ghi-jkl_mnop";
|
||||
expect(redactTokens(input)).toBe("Authorization: Bearer [REDACTED]");
|
||||
});
|
||||
|
||||
it("redacts authorization header values regardless of case", () => {
|
||||
expect(redactTokens("authorization=secret-value-1234567890")).toContain("[REDACTED]");
|
||||
expect(redactTokens("Authorization: secret-value-1234567890")).toContain("[REDACTED]");
|
||||
});
|
||||
|
||||
it("redacts access_token query strings", () => {
|
||||
expect(redactTokens("https://api.example.com/?access_token=abc123xyz&foo=1")).toBe(
|
||||
"https://api.example.com/?access_token=[REDACTED]&foo=1",
|
||||
);
|
||||
});
|
||||
|
||||
it("redacts refresh_token values", () => {
|
||||
expect(redactTokens("refresh_token=long.refresh.tok&grant_type=refresh_token")).toContain(
|
||||
"refresh_token=[REDACTED]",
|
||||
);
|
||||
});
|
||||
|
||||
it("redacts api_key key/value pairs", () => {
|
||||
const input = "config { api_key: \"sk-test-1234567890abcdef\", model: \"gpt-4o\" }";
|
||||
const result = redactTokens(input);
|
||||
expect(result).not.toContain("sk-test-1234567890abcdef");
|
||||
expect(result).toContain("[REDACTED]");
|
||||
});
|
||||
|
||||
it("redacts sk- and sk-ant- prefixed keys anywhere in a string", () => {
|
||||
expect(redactTokens("error using sk-ant-api03_abcdefghijklmnop, retrying")).toContain("sk-[REDACTED]");
|
||||
expect(redactTokens("token=sk-test1234567890_abcd")).toContain("sk-[REDACTED]");
|
||||
});
|
||||
|
||||
it("redacts GitHub personal access token shapes", () => {
|
||||
expect(redactTokens("git push https://x:ghp_aaaaaaaaaaaaaaaaaa@github.com/foo")).toContain("ghp_[REDACTED]");
|
||||
});
|
||||
|
||||
it("redacts AWS access key id prefixes", () => {
|
||||
expect(redactTokens("aws cred AKIAIOSFODNN7EXAMPLE in trace")).toContain("AKIA[REDACTED]");
|
||||
});
|
||||
|
||||
it("leaves credential-free strings unchanged", () => {
|
||||
const safe = "Just a regular log line about model claude-opus-4-5 with usage 123 tokens.";
|
||||
expect(redactTokens(safe)).toBe(safe);
|
||||
});
|
||||
|
||||
it("redacts multiple matches in one string", () => {
|
||||
const input = "Bearer abc.def.ghi.jkl Authorization=secretsecretsecret";
|
||||
const result = redactTokens(input);
|
||||
expect(result).not.toContain("abc.def.ghi.jkl");
|
||||
expect(result).not.toContain("secretsecretsecret");
|
||||
});
|
||||
});
|
||||
@@ -1,44 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
CONSOLE_ERROR_SUPPRESSION_RULES,
|
||||
WARNING_SUPPRESSION_RULES,
|
||||
classifyConsoleErrorSuppression,
|
||||
classifyWarningSuppression,
|
||||
} from "../../src/main/main-warning-suppression-rules";
|
||||
|
||||
describe("main warning suppression rules", () => {
|
||||
it("documents every warning suppression category with an operator reason", () => {
|
||||
for (const rule of [...WARNING_SUPPRESSION_RULES, ...CONSOLE_ERROR_SUPPRESSION_RULES]) {
|
||||
expect(rule.category).toMatch(/^[a-z0-9-]+$/);
|
||||
expect(rule.reason.length).toBeGreaterThan(40);
|
||||
}
|
||||
});
|
||||
|
||||
it("suppresses only the node sqlite experimental warning", () => {
|
||||
const sqliteWarning = new Error("SQLite is an experimental feature and might change at any time");
|
||||
sqliteWarning.name = "ExperimentalWarning";
|
||||
|
||||
const genericWarning = new Error("fetch is an experimental feature");
|
||||
genericWarning.name = "ExperimentalWarning";
|
||||
|
||||
expect(classifyWarningSuppression(sqliteWarning)).toBe("node-sqlite-experimental-warning");
|
||||
expect(classifyWarningSuppression("SQLite is an experimental feature and might change at any time"))
|
||||
.toBe("node-sqlite-experimental-warning");
|
||||
expect(classifyWarningSuppression(genericWarning)).toBeNull();
|
||||
expect(classifyWarningSuppression("deprecation warning")).toBeNull();
|
||||
});
|
||||
|
||||
it("suppresses only disposed renderer frame send noise", () => {
|
||||
expect(classifyConsoleErrorSuppression([
|
||||
"Error sending from webFrameMain:",
|
||||
new Error("Render frame was disposed before WebFrameMain could send"),
|
||||
])).toBe("electron-disposed-web-frame-send");
|
||||
|
||||
expect(classifyConsoleErrorSuppression([
|
||||
"Error sending from webFrameMain:",
|
||||
new Error("Permission denied"),
|
||||
])).toBeNull();
|
||||
expect(classifyConsoleErrorSuppression(["Unhandled renderer error", new Error("Render frame was disposed")]))
|
||||
.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,12 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeMarkdownText } from "../../src/shared/markdown-text";
|
||||
|
||||
describe("normalizeMarkdownText", () => {
|
||||
it("converts escaped markdown line breaks into renderable markdown", () => {
|
||||
expect(normalizeMarkdownText("Intro\\n\\n## Plan\\n- First\\n- Second")).toBe("Intro\n\n## Plan\n- First\n- Second");
|
||||
});
|
||||
|
||||
it("normalizes actual CRLF input to LF", () => {
|
||||
expect(normalizeMarkdownText("One\r\nTwo\rThree")).toBe("One\nTwo\nThree");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { DatabaseConnection } from "../../src/main/database/connection";
|
||||
import { OrganizationService } from "../../src/main/services/organization-service";
|
||||
import { ChatService } from "../../src/main/services/chat-service";
|
||||
import { canonicalMentionHandle } from "../../src/shared/mention-handles";
|
||||
import { dispatchAgentTool, type AgentToolContext } from "../../src/main/services/agent-tools";
|
||||
|
||||
function call(name: string, args: Record<string, unknown>) {
|
||||
return { id: `call_${randomUUID()}`, name, arguments: args };
|
||||
}
|
||||
|
||||
describe("mention resolution", () => {
|
||||
test("canonicalMentionHandle strips spaces and lowercases", () => {
|
||||
expect(canonicalMentionHandle("Sarah Chen")).toBe("sarahchen");
|
||||
expect(canonicalMentionHandle("J.P. Morgan")).toBe("jpmorgan");
|
||||
expect(canonicalMentionHandle("alice")).toBe("alice");
|
||||
});
|
||||
|
||||
test("@mention with canonical handle resolves to multi-word agent name", () => {
|
||||
const connection = new DatabaseConnection(join(tmpdir(), `ac-mention-${randomUUID()}.db`));
|
||||
const organization = new OrganizationService(connection.db);
|
||||
let orgId = "";
|
||||
let ceoId = "";
|
||||
let providerId = "";
|
||||
let engineerId = "";
|
||||
connection.transaction(() => {
|
||||
orgId = organization.createOrganization({ organizationName: "TestCorp", employeeName: "Yang", mission: "m", goal: "g" });
|
||||
providerId = (connection.db.prepare("select id from providers where org_id = ? and provider_key = 'openai'").get(orgId) as { id: string }).id;
|
||||
ceoId = organization.createAgent({
|
||||
orgId, name: "Alex Kim", role: "CEO", department: "Executive",
|
||||
modelProviderId: providerId, modelId: "gpt-5.4", budgetCents: 25000, managerAgentId: null,
|
||||
});
|
||||
engineerId = organization.createAgent({
|
||||
orgId, name: "Sarah Chen", role: "Engineer", department: "Engineering",
|
||||
modelProviderId: providerId, modelId: "gpt-5.4", budgetCents: 10000, managerAgentId: ceoId,
|
||||
});
|
||||
});
|
||||
|
||||
const handle = canonicalMentionHandle("Sarah Chen");
|
||||
expect(handle).toBe("sarahchen");
|
||||
|
||||
const deps = { db: connection.db, organization, chat: new ChatService(connection.db) };
|
||||
const ctx: AgentToolContext = {
|
||||
orgId, agent: { id: ceoId, modelProviderId: providerId, modelId: "gpt-5.4" },
|
||||
taskProjectId: null, taskGoalId: null, workspace: null,
|
||||
};
|
||||
|
||||
const result = dispatchAgentTool(deps, ctx, call("send_message", {
|
||||
channel: "engineering",
|
||||
body: `@${handle} can you review this?`,
|
||||
}));
|
||||
expect(result).toContain("Sarah Chen");
|
||||
|
||||
const mentions = connection.db.prepare(
|
||||
"select target_type, target_id, wake_state from chat_mentions where target_type = 'agent' and target_id = ?",
|
||||
).all(engineerId) as Array<{ target_type: string; target_id: string; wake_state: string }>;
|
||||
expect(mentions.length).toBeGreaterThan(0);
|
||||
expect(mentions[0].target_id).toBe(engineerId);
|
||||
});
|
||||
|
||||
test("read_messages returns recent channel messages", () => {
|
||||
const connection = new DatabaseConnection(join(tmpdir(), `ac-read-${randomUUID()}.db`));
|
||||
const organization = new OrganizationService(connection.db);
|
||||
let orgId = "";
|
||||
let ceoId = "";
|
||||
let providerId = "";
|
||||
connection.transaction(() => {
|
||||
orgId = organization.createOrganization({ organizationName: "ReadCorp", employeeName: "Yang", mission: "m", goal: "g" });
|
||||
providerId = (connection.db.prepare("select id from providers where org_id = ? and provider_key = 'openai'").get(orgId) as { id: string }).id;
|
||||
ceoId = organization.createAgent({
|
||||
orgId, name: "CEO", role: "CEO", department: "Executive",
|
||||
modelProviderId: providerId, modelId: "gpt-5.4", budgetCents: 25000, managerAgentId: null,
|
||||
});
|
||||
});
|
||||
|
||||
const deps = { db: connection.db, organization, chat: new ChatService(connection.db) };
|
||||
const ctx: AgentToolContext = {
|
||||
orgId, agent: { id: ceoId, modelProviderId: null, modelId: null },
|
||||
taskProjectId: null, taskGoalId: null, workspace: null,
|
||||
};
|
||||
|
||||
dispatchAgentTool(deps, ctx, call("send_message", { channel: "general", body: "Hello team!" }));
|
||||
dispatchAgentTool(deps, ctx, call("send_message", { channel: "general", body: "Kickoff meeting at 3pm." }));
|
||||
|
||||
const readResult = dispatchAgentTool(deps, ctx, call("read_messages", { channel: "general" }));
|
||||
expect(readResult).toContain("Hello team!");
|
||||
expect(readResult).toContain("Kickoff meeting at 3pm.");
|
||||
});
|
||||
|
||||
test("read_messages with no channel returns agent mentions", () => {
|
||||
const connection = new DatabaseConnection(join(tmpdir(), `ac-inbox-${randomUUID()}.db`));
|
||||
const organization = new OrganizationService(connection.db);
|
||||
let orgId = "";
|
||||
let ceoId = "";
|
||||
let engineerId = "";
|
||||
let providerId = "";
|
||||
connection.transaction(() => {
|
||||
orgId = organization.createOrganization({ organizationName: "InboxCorp", employeeName: "Yang", mission: "m", goal: "g" });
|
||||
providerId = (connection.db.prepare("select id from providers where org_id = ? and provider_key = 'openai'").get(orgId) as { id: string }).id;
|
||||
ceoId = organization.createAgent({
|
||||
orgId, name: "CEO", role: "CEO", department: "Executive",
|
||||
modelProviderId: providerId, modelId: "gpt-5.4", budgetCents: 25000, managerAgentId: null,
|
||||
});
|
||||
engineerId = organization.createAgent({
|
||||
orgId, name: "Dana", role: "Engineer", department: "Engineering",
|
||||
modelProviderId: null, modelId: null, budgetCents: 1000, managerAgentId: ceoId,
|
||||
});
|
||||
});
|
||||
|
||||
const deps = { db: connection.db, organization, chat: new ChatService(connection.db) };
|
||||
const ceoCtx: AgentToolContext = {
|
||||
orgId, agent: { id: ceoId, modelProviderId: null, modelId: null },
|
||||
taskProjectId: null, taskGoalId: null, workspace: null,
|
||||
};
|
||||
|
||||
dispatchAgentTool(deps, ceoCtx, call("send_message", { channel: "engineering", body: "@dana check the deployment" }));
|
||||
|
||||
const danaCtx: AgentToolContext = {
|
||||
orgId, agent: { id: engineerId, modelProviderId: null, modelId: null },
|
||||
taskProjectId: null, taskGoalId: null, workspace: null,
|
||||
};
|
||||
const inbox = dispatchAgentTool(deps, danaCtx, call("read_messages", {}));
|
||||
expect(inbox).toContain("check the deployment");
|
||||
});
|
||||
});
|
||||
@@ -1,461 +0,0 @@
|
||||
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/messaging/dispatch";
|
||||
import { executeAutomationAction } from "../../src/main/automation/automation-actions";
|
||||
|
||||
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.core.connectors.updateConnectorHealth({
|
||||
id: "openai-codex",
|
||||
status: "ready",
|
||||
authState: "authenticated",
|
||||
version: "test",
|
||||
lastError: null,
|
||||
});
|
||||
|
||||
const companyId = db.core.company.saveCompany({
|
||||
name: "Message Dispatch Co",
|
||||
description: "Tests board wake and wait-state resolution",
|
||||
status: "active",
|
||||
executionMode: "ai_cofounder",
|
||||
});
|
||||
const workspaceId = db.core.workspaces.saveWorkspace({
|
||||
companyId,
|
||||
name: "Primary",
|
||||
localPath: "/tmp/message-dispatch",
|
||||
repoUrl: "",
|
||||
repoRef: "",
|
||||
isPrimary: true,
|
||||
});
|
||||
const ceoId = db.core.agents.saveAgent({
|
||||
companyId,
|
||||
name: "CEO",
|
||||
role: "CEO",
|
||||
title: "Chief Executive Officer",
|
||||
status: "idle",
|
||||
reportsTo: null,
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Lead the company",
|
||||
budgetMonthlyUsd: 250,
|
||||
});
|
||||
const ctoId = db.core.agents.saveAgent({
|
||||
companyId,
|
||||
name: "CTO",
|
||||
role: "CTO",
|
||||
title: "Chief Technology Officer",
|
||||
status: "idle",
|
||||
reportsTo: ceoId,
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Lead engineering",
|
||||
budgetMonthlyUsd: 200,
|
||||
department: "engineering",
|
||||
});
|
||||
const cmoId = db.core.agents.saveAgent({
|
||||
companyId,
|
||||
name: "CMO",
|
||||
role: "CMO",
|
||||
title: "Chief Marketing Officer",
|
||||
status: "idle",
|
||||
reportsTo: ceoId,
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Lead marketing",
|
||||
budgetMonthlyUsd: 200,
|
||||
department: "marketing",
|
||||
});
|
||||
const pendingId = db.core.agents.saveAgent({
|
||||
companyId,
|
||||
name: "Pending Recruit",
|
||||
role: "IC",
|
||||
title: "Pending Hire",
|
||||
status: "pending_approval",
|
||||
reportsTo: ctoId,
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Pending",
|
||||
budgetMonthlyUsd: 100,
|
||||
department: "engineering",
|
||||
});
|
||||
const terminatedId = db.core.agents.saveAgent({
|
||||
companyId,
|
||||
name: "Former COO",
|
||||
role: "COO",
|
||||
title: "Former COO",
|
||||
status: "terminated",
|
||||
reportsTo: ceoId,
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "No longer active",
|
||||
budgetMonthlyUsd: 100,
|
||||
department: "operations",
|
||||
});
|
||||
|
||||
const taskId = db.core.tasks.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 every active agent for company channel broadcasts (Slack-style all-hands)", 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: "All-hands broadcast",
|
||||
});
|
||||
|
||||
expect(companyResult.ok).toBe(true);
|
||||
if (!companyResult.ok) throw new Error("Expected company message dispatch to succeed.");
|
||||
expect(new Set(companyResult.data.wakeTargets)).toEqual(new Set([ceoId, ctoId, cmoId]));
|
||||
|
||||
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("routes a DM addressed to one agent into the @mentioned agent's inbox (group-chat @mention visibility)", async () => {
|
||||
const profileDir = await createTempDir("agentcompany-message-mention-");
|
||||
const db = new AppDatabase(profileDir);
|
||||
db.init();
|
||||
|
||||
const { companyId, ceoId, ctoId } = createScaffold(db);
|
||||
const wakeAgentIfPossible = vi.fn().mockReturnValue(null);
|
||||
|
||||
const result = dispatchAgentMessage({
|
||||
db,
|
||||
wakeAgentIfPossible,
|
||||
}, {
|
||||
companyId,
|
||||
fromAgentId: HUMAN_SENDER_ID,
|
||||
fromHumanUser: true,
|
||||
channel: "direct",
|
||||
toAgentId: ctoId,
|
||||
subject: "status",
|
||||
body: "Hi @CEO, please share a progress update, @CTO is on infra.",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) throw new Error("Expected dispatch to succeed.");
|
||||
expect(new Set(result.data.wakeTargets)).toEqual(new Set([ceoId, ctoId]));
|
||||
|
||||
const ceoInbox = db.core.messages.listAgentInboxUnread(companyId, ceoId);
|
||||
expect(ceoInbox.map((entry) => entry.id)).toContain(result.data.id);
|
||||
const ctoInbox = db.core.messages.listAgentInboxUnread(companyId, ctoId);
|
||||
expect(ctoInbox.map((entry) => entry.id)).toContain(result.data.id);
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("normalizes escaped markdown line breaks before persisting messages and comments", async () => {
|
||||
const profileDir = await createTempDir("agentcompany-message-markdown-");
|
||||
const db = new AppDatabase(profileDir);
|
||||
db.init();
|
||||
|
||||
const { companyId, ceoId, ctoId, taskId } = createScaffold(db);
|
||||
|
||||
const result = dispatchAgentMessage({
|
||||
db,
|
||||
}, {
|
||||
companyId,
|
||||
fromAgentId: ctoId,
|
||||
channel: "direct",
|
||||
toAgentId: ceoId,
|
||||
subject: "Markdown update",
|
||||
body: "CEO,\\n\\n## Plan\\n- Confirm scope\\n- Ship cleanly",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) throw new Error("Expected dispatch to succeed.");
|
||||
expect(result.data.message?.body).toBe("CEO,\n\n## Plan\n- Confirm scope\n- Ship cleanly");
|
||||
|
||||
const comment = db.core.activity.addComment({
|
||||
companyId,
|
||||
taskId,
|
||||
authorAgentId: ctoId,
|
||||
authorName: "CTO",
|
||||
body: "Progress:\\n- Scope confirmed\\n- Review queued",
|
||||
});
|
||||
expect(comment.body).toBe("Progress:\n- Scope confirmed\n- Review queued");
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("redirects a DM's wake to the @mentioned agent instead of the DM recipient (chatroom semantics)", async () => {
|
||||
const profileDir = await createTempDir("agentcompany-message-redirect-");
|
||||
const db = new AppDatabase(profileDir);
|
||||
db.init();
|
||||
|
||||
const { companyId, ceoId, ctoId } = createScaffold(db);
|
||||
const wakeAgentIfPossible = vi.fn().mockReturnValue(null);
|
||||
|
||||
const result = dispatchAgentMessage({
|
||||
db,
|
||||
wakeAgentIfPossible,
|
||||
}, {
|
||||
companyId,
|
||||
fromAgentId: HUMAN_SENDER_ID,
|
||||
fromHumanUser: true,
|
||||
channel: "direct",
|
||||
toAgentId: ctoId,
|
||||
subject: "progress",
|
||||
body: "@CEO 更新一下进度。",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) throw new Error("Expected dispatch to succeed.");
|
||||
// CEO is the only wake target — the DM recipient (CTO) is NOT
|
||||
// woken because an explicit @mention overrides the implicit DM
|
||||
// recipient. This is what lets the operator say "@CEO" inside a
|
||||
// chat with CTO and have CEO (not CTO) take the turn.
|
||||
expect(result.data.wakeTargets).toEqual([ceoId]);
|
||||
|
||||
// The message is still visible to CTO's inbox — they were the
|
||||
// addressed recipient — but CTO is not required to answer.
|
||||
const ctoInbox = db.core.messages.listAgentInboxUnread(companyId, ctoId);
|
||||
expect(ctoInbox.map((entry) => entry.id)).toContain(result.data.id);
|
||||
const ceoInbox = db.core.messages.listAgentInboxUnread(companyId, ceoId);
|
||||
expect(ceoInbox.map((entry) => entry.id)).toContain(result.data.id);
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("keeps __board__ out of wake targets even when the recipient is the human reader", async () => {
|
||||
const profileDir = await createTempDir("agentcompany-message-board-wake-");
|
||||
const db = new AppDatabase(profileDir);
|
||||
db.init();
|
||||
|
||||
const { companyId, ceoId } = createScaffold(db);
|
||||
const wakeAgentIfPossible = vi.fn().mockReturnValue(null);
|
||||
|
||||
const result = dispatchAgentMessage({
|
||||
db,
|
||||
wakeAgentIfPossible,
|
||||
}, {
|
||||
companyId,
|
||||
fromAgentId: ceoId,
|
||||
channel: "direct",
|
||||
toAgentId: HUMAN_SENDER_ID,
|
||||
subject: "report",
|
||||
body: "Update ready for review.",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) throw new Error("Expected dispatch to succeed.");
|
||||
expect(result.data.wakeTargets).not.toContain(HUMAN_SENDER_ID);
|
||||
expect(wakeAgentIfPossible).not.toHaveBeenCalledWith(
|
||||
HUMAN_SENDER_ID,
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
);
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("rejects direct messages addressed to the sender", async () => {
|
||||
const profileDir = await createTempDir("agentcompany-message-self-direct-");
|
||||
const db = new AppDatabase(profileDir);
|
||||
db.init();
|
||||
|
||||
const { companyId, ceoId } = createScaffold(db);
|
||||
|
||||
const result = dispatchAgentMessage({
|
||||
db,
|
||||
}, {
|
||||
companyId,
|
||||
fromAgentId: ceoId,
|
||||
channel: "direct",
|
||||
toAgentId: ceoId,
|
||||
subject: "self",
|
||||
body: "This should not create a CEO to CEO channel.",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("Expected self-DM dispatch to fail.");
|
||||
expect(result.error.code).toBe("SELF_DIRECT_MESSAGE_FORBIDDEN");
|
||||
expect(() => db.core.messages.sendAgentMessage({
|
||||
companyId,
|
||||
fromAgentId: ceoId,
|
||||
toAgentId: ceoId,
|
||||
subject: "self",
|
||||
body: "Direct DB writers share the same invariant.",
|
||||
})).toThrow("Direct messages require two distinct participants.");
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("skips automation notifications that would address the sender", async () => {
|
||||
const profileDir = await createTempDir("agentcompany-automation-self-direct-");
|
||||
const db = new AppDatabase(profileDir);
|
||||
db.init();
|
||||
|
||||
const { companyId, ceoId } = createScaffold(db);
|
||||
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
||||
|
||||
executeAutomationAction(
|
||||
{
|
||||
db,
|
||||
logger,
|
||||
emitEvent: vi.fn(),
|
||||
publishDomainChanged: vi.fn(),
|
||||
wakeAgentIfPossible: vi.fn(),
|
||||
},
|
||||
{
|
||||
handleTaskCreated: vi.fn(),
|
||||
handleApprovalResolved: vi.fn(),
|
||||
handleTaskStatusChange: vi.fn(),
|
||||
},
|
||||
{
|
||||
id: "rule-self",
|
||||
name: "Self notification",
|
||||
action: "notify_agent",
|
||||
actionConfigJson: "{}",
|
||||
targetDepartment: null,
|
||||
},
|
||||
{ companyId, agentId: ceoId },
|
||||
);
|
||||
|
||||
expect(db.core.messages.listAgentMessages(companyId, { channel: "direct" })).toHaveLength(0);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
"[automation] Skipping rule \"Self notification\" - direct messages require two distinct participants",
|
||||
);
|
||||
|
||||
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: () => {}, warn: () => {}, error: () => {} },
|
||||
});
|
||||
|
||||
threadIntegration.onTaskCreated(taskId, companyId);
|
||||
const thread = db.core.threads.listThreads({
|
||||
companyId,
|
||||
type: "task",
|
||||
relatedEntityType: "task",
|
||||
relatedEntityId: taskId,
|
||||
limit: 1,
|
||||
})[0];
|
||||
|
||||
if (!thread) {
|
||||
throw new Error("Expected task thread to exist.");
|
||||
}
|
||||
|
||||
const replyWait = db.core.threads.setThreadWaitState({
|
||||
threadId: thread.id,
|
||||
companyId,
|
||||
waitingFor: "user_reply",
|
||||
relatedApprovalId: null,
|
||||
reason: "Reply needed",
|
||||
});
|
||||
const reviewWait = db.core.threads.setThreadWaitState({
|
||||
threadId: thread.id,
|
||||
companyId,
|
||||
waitingFor: "user_review",
|
||||
relatedApprovalId: null,
|
||||
reason: "Review needed",
|
||||
});
|
||||
const approvalWait = db.core.threads.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.core.threads.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.core.threads.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,93 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AgentMessageRecord, AgentRecord } from "../../src/shared/types";
|
||||
import {
|
||||
DEFAULT_MESSAGE_FILTERS,
|
||||
filterChannelMessages,
|
||||
hasActiveMessageFilters,
|
||||
} from "../../src/renderer/components/communication/message-filters";
|
||||
|
||||
function agent(id: string, name: string): AgentRecord {
|
||||
return {
|
||||
id,
|
||||
companyId: "company-1",
|
||||
name,
|
||||
role: name,
|
||||
title: name,
|
||||
department: null,
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "openai-codex",
|
||||
workspaceId: null,
|
||||
model: null,
|
||||
capabilities: "",
|
||||
budgetMonthlyUsd: 0,
|
||||
spentMonthlyUsd: 0,
|
||||
metadataJson: "{}",
|
||||
heartbeatEnabled: false,
|
||||
heartbeatIntervalSec: 300,
|
||||
lastHeartbeatAt: null,
|
||||
createdAt: "2026-04-22T00:00:00.000Z",
|
||||
updatedAt: "2026-04-22T00:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
function message(input: Partial<AgentMessageRecord>): AgentMessageRecord {
|
||||
return {
|
||||
id: input.id ?? "msg-1",
|
||||
companyId: "company-1",
|
||||
fromAgentId: input.fromAgentId ?? "agent-ceo",
|
||||
toAgentId: input.toAgentId ?? null,
|
||||
senderKind: input.senderKind ?? "agent",
|
||||
channel: input.channel ?? "company",
|
||||
channelTargetId: input.channelTargetId ?? null,
|
||||
subject: input.subject ?? "Subject",
|
||||
body: input.body ?? "Body",
|
||||
priority: input.priority ?? "normal",
|
||||
parentMessageId: input.parentMessageId ?? null,
|
||||
attachmentsJson: input.attachmentsJson ?? "[]",
|
||||
createdAt: input.createdAt ?? "2026-04-22T00:00:00.000Z",
|
||||
readAt: input.readAt ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("message filters", () => {
|
||||
it("keeps read and unread channel messages in the default channel view", () => {
|
||||
const read = message({ id: "read", readAt: "2026-04-22T00:00:01.000Z" });
|
||||
const unread = message({ id: "unread", readAt: null });
|
||||
|
||||
const result = filterChannelMessages(
|
||||
[read, unread],
|
||||
DEFAULT_MESSAGE_FILTERS,
|
||||
new Map(),
|
||||
"",
|
||||
);
|
||||
|
||||
expect(result.map((entry) => entry.id)).toEqual(["read", "unread"]);
|
||||
});
|
||||
|
||||
it("filters by text and sender inside the selected channel", () => {
|
||||
const ceo = agent("agent-ceo", "Ada CEO");
|
||||
const cto = agent("agent-cto", "Grace CTO");
|
||||
const messages = [
|
||||
message({ id: "ceo", fromAgentId: ceo.id, subject: "Launch", body: "Board update" }),
|
||||
message({ id: "cto", fromAgentId: cto.id, subject: "Infra", body: "Runtime ready" }),
|
||||
];
|
||||
|
||||
const result = filterChannelMessages(
|
||||
messages,
|
||||
{ ...DEFAULT_MESSAGE_FILTERS, search: "grace" },
|
||||
new Map([
|
||||
[ceo.id, ceo],
|
||||
[cto.id, cto],
|
||||
]),
|
||||
"",
|
||||
);
|
||||
|
||||
expect(result.map((entry) => entry.id)).toEqual(["cto"]);
|
||||
});
|
||||
|
||||
it("detects active message filters", () => {
|
||||
expect(hasActiveMessageFilters(DEFAULT_MESSAGE_FILTERS)).toBe(false);
|
||||
expect(hasActiveMessageFilters({ ...DEFAULT_MESSAGE_FILTERS, priority: "urgent" })).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,207 +0,0 @@
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("electron", () => ({
|
||||
safeStorage: {
|
||||
isEncryptionAvailable: () => false,
|
||||
getSelectedStorageBackend: () => "basic_text",
|
||||
encryptString: (value: string) => Buffer.from(value, "utf8"),
|
||||
decryptString: (value: Buffer) => value.toString("utf8"),
|
||||
},
|
||||
}));
|
||||
|
||||
import { AppDatabase } from "../../src/main/database";
|
||||
|
||||
describe("AppDatabase.searchMessages", () => {
|
||||
let tempDir: string;
|
||||
let db: AppDatabase;
|
||||
let companyId: string;
|
||||
let agentA: string;
|
||||
let agentB: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "agentcompany-search-"));
|
||||
db = new AppDatabase(tempDir);
|
||||
db.init();
|
||||
|
||||
companyId = db.core.company.saveCompany({
|
||||
name: "Search Test Corp",
|
||||
description: "Company for testing message search",
|
||||
status: "active",
|
||||
});
|
||||
|
||||
agentA = db.core.agents.saveAgent({
|
||||
companyId,
|
||||
name: "Agent Alpha",
|
||||
role: "Engineer",
|
||||
title: "Senior Engineer",
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "claude-pro",
|
||||
workspaceId: null,
|
||||
model: null,
|
||||
capabilities: "coding",
|
||||
budgetMonthlyUsd: 100,
|
||||
});
|
||||
|
||||
agentB = db.core.agents.saveAgent({
|
||||
companyId,
|
||||
name: "Agent Beta",
|
||||
role: "Designer",
|
||||
title: "Lead Designer",
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "claude-pro",
|
||||
workspaceId: null,
|
||||
model: null,
|
||||
capabilities: "design",
|
||||
budgetMonthlyUsd: 100,
|
||||
});
|
||||
|
||||
// Seed messages across different channels
|
||||
db.core.messages.sendAgentMessage({
|
||||
companyId,
|
||||
fromAgentId: agentA,
|
||||
toAgentId: agentB,
|
||||
channel: "direct",
|
||||
subject: "Database migration plan",
|
||||
body: "We need to migrate the PostgreSQL tables to the new schema.",
|
||||
priority: "normal",
|
||||
});
|
||||
|
||||
db.core.messages.sendAgentMessage({
|
||||
companyId,
|
||||
fromAgentId: agentB,
|
||||
toAgentId: agentA,
|
||||
channel: "direct",
|
||||
subject: "Design review feedback",
|
||||
body: "The button colors look great, but the migration banner needs work.",
|
||||
priority: "normal",
|
||||
});
|
||||
|
||||
db.core.messages.sendAgentMessage({
|
||||
companyId,
|
||||
fromAgentId: agentA,
|
||||
channel: "company",
|
||||
subject: "Sprint retrospective",
|
||||
body: "Team velocity improved by 20% this sprint.",
|
||||
priority: "normal",
|
||||
});
|
||||
|
||||
db.core.messages.sendAgentMessage({
|
||||
companyId,
|
||||
fromAgentId: agentA,
|
||||
channel: "department",
|
||||
channelTargetId: "engineering",
|
||||
subject: "Code review standards",
|
||||
body: "All PRs must have at least two approvals before merging.",
|
||||
priority: "urgent",
|
||||
});
|
||||
|
||||
db.core.messages.sendAgentMessage({
|
||||
companyId,
|
||||
fromAgentId: agentB,
|
||||
channel: "department",
|
||||
channelTargetId: "design",
|
||||
subject: "Design system update",
|
||||
body: "We updated the color tokens for dark mode compatibility.",
|
||||
priority: "low",
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
db.close();
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("returns messages where body contains the query string", () => {
|
||||
const results = db.core.messages.searchMessages(companyId, "velocity");
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0].companyId).toBe(companyId);
|
||||
expect(results[0].body.toLowerCase()).toContain("velocity");
|
||||
});
|
||||
|
||||
it("returns messages where subject contains the query string", () => {
|
||||
const results = db.core.messages.searchMessages(companyId, "retrospective");
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0].subject).toBe("Sprint retrospective");
|
||||
});
|
||||
|
||||
it("escapes LIKE wildcards so % and _ match literally", () => {
|
||||
// Insert a message with literal % and _ characters
|
||||
db.core.messages.sendAgentMessage({
|
||||
companyId,
|
||||
fromAgentId: agentA,
|
||||
channel: "company",
|
||||
subject: "Performance metrics",
|
||||
body: "CPU usage was 95% and disk_io dropped significantly.",
|
||||
priority: "normal",
|
||||
});
|
||||
|
||||
// Search for the literal "95%"
|
||||
const percentResults = db.core.messages.searchMessages(companyId, "95%");
|
||||
expect(percentResults.length).toBe(1);
|
||||
expect(percentResults[0].body).toContain("95%");
|
||||
|
||||
// Search for the literal "disk_io"
|
||||
const underscoreResults = db.core.messages.searchMessages(companyId, "disk_io");
|
||||
expect(underscoreResults.length).toBe(1);
|
||||
expect(underscoreResults[0].body).toContain("disk_io");
|
||||
});
|
||||
|
||||
it("scopes to a specific channel when channel param is provided", () => {
|
||||
const results = db.core.messages.searchMessages(companyId, "approvals", { channel: "department" });
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0].channel).toBe("department");
|
||||
expect(results[0].body).toContain("approvals");
|
||||
});
|
||||
|
||||
it("scopes to channelTargetId when provided alongside channel", () => {
|
||||
const results = db.core.messages.searchMessages(companyId, "updated", {
|
||||
channel: "department",
|
||||
channelTargetId: "design",
|
||||
});
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0].channelTargetId).toBe("design");
|
||||
});
|
||||
|
||||
it("returns empty array when no matches", () => {
|
||||
const results = db.core.messages.searchMessages(companyId, "xyznonexistentquery");
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it("respects the limit parameter (default 50)", () => {
|
||||
// Insert 60 messages
|
||||
for (let i = 0; i < 60; i++) {
|
||||
db.core.messages.sendAgentMessage({
|
||||
companyId,
|
||||
fromAgentId: agentA,
|
||||
channel: "company",
|
||||
subject: `Bulk message ${i}`,
|
||||
body: `This is a flood test message number ${i}`,
|
||||
priority: "normal",
|
||||
});
|
||||
}
|
||||
|
||||
// Default limit should be 50
|
||||
const defaultResults = db.core.messages.searchMessages(companyId, "flood test");
|
||||
expect(defaultResults.length).toBe(50);
|
||||
|
||||
// Explicit limit of 10
|
||||
const limitedResults = db.core.messages.searchMessages(companyId, "flood test", { limit: 10 });
|
||||
expect(limitedResults.length).toBe(10);
|
||||
});
|
||||
|
||||
it("returns results ordered by created_at DESC (most recent first)", () => {
|
||||
const results = db.core.messages.searchMessages(companyId, "migration");
|
||||
expect(results.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
for (let i = 1; i < results.length; i++) {
|
||||
const prev = new Date(results[i - 1].createdAt).getTime();
|
||||
const curr = new Date(results[i].createdAt).getTime();
|
||||
expect(prev).toBeGreaterThanOrEqual(curr);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,346 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { DatabaseConnection } from "../../src/main/database/connection";
|
||||
import { MigrationService } from "../../src/main/services/migration-service";
|
||||
import { OrganizationService } from "../../src/main/services/organization-service";
|
||||
import { SnapshotService } from "../../src/main/services/snapshot-service";
|
||||
|
||||
function createConnection(): DatabaseConnection {
|
||||
return new DatabaseConnection(join(tmpdir(), `agentcompany-new-migration-${randomUUID()}.db`));
|
||||
}
|
||||
|
||||
function createOrganization(organization: OrganizationService): string {
|
||||
return organization.createOrganization({
|
||||
organizationName: "Acme Operations",
|
||||
employeeName: "Dana Lee",
|
||||
mission: "Coordinate agents with auditable approvals.",
|
||||
goal: "Ship a verified control plane.",
|
||||
});
|
||||
}
|
||||
|
||||
function createLegacyDatabase(sql: string): string {
|
||||
const legacyPath = join(tmpdir(), `agentcompany-legacy-${randomUUID()}.db`);
|
||||
const legacy = new DatabaseSync(legacyPath);
|
||||
legacy.exec(sql);
|
||||
legacy.close();
|
||||
return legacyPath;
|
||||
}
|
||||
|
||||
interface MigrationReportBody {
|
||||
sourceFingerprint: string;
|
||||
tableCounts: Record<string, number>;
|
||||
tableChecksums: Record<string, string>;
|
||||
idMaps: Record<string, string[]>;
|
||||
failures: Array<{ table: string; reason: string; detail: string }>;
|
||||
validationSummary: {
|
||||
missingTableCount: number;
|
||||
totalSourceRows: number;
|
||||
rowsWithIntegrityFailures: number;
|
||||
rowCoverageBasisPoints: number;
|
||||
checksumCoverageBasisPoints: number;
|
||||
idMapCoverageBasisPoints: number;
|
||||
relationFailureCount: number;
|
||||
passed: boolean;
|
||||
};
|
||||
rollbackPack: {
|
||||
sourceDatabasePath: string;
|
||||
sourceFingerprint: string;
|
||||
tableChecksums: Record<string, string>;
|
||||
idMaps: Record<string, string[]>;
|
||||
};
|
||||
}
|
||||
|
||||
describe("migration service", () => {
|
||||
test("creates idempotent reports with checksums, id maps, rollback pack, and integrity failures", () => {
|
||||
const connection = createConnection();
|
||||
const organization = new OrganizationService(connection.db);
|
||||
const migrations = new MigrationService(connection.db);
|
||||
const snapshot = new SnapshotService(connection.db);
|
||||
const legacyPath = createLegacyDatabase(`
|
||||
create table organizations(id text primary key);
|
||||
insert into organizations(id) values ('org-1');
|
||||
create table employees(id text primary key, org_id text);
|
||||
insert into employees(id, org_id) values ('emp-1', 'org-1');
|
||||
create table agents(id text primary key, org_id text);
|
||||
insert into agents(id, org_id) values ('agent-1', 'org-missing');
|
||||
create table tasks(id text primary key, org_id text, assignee_agent_id text);
|
||||
insert into tasks(id, org_id, assignee_agent_id) values ('task-1', 'org-1', 'agent-missing');
|
||||
`);
|
||||
let orgId = "";
|
||||
let firstReportId = "";
|
||||
let secondReportId = "";
|
||||
|
||||
connection.transaction(() => {
|
||||
orgId = createOrganization(organization);
|
||||
firstReportId = migrations.createReport({ orgId, sourceDatabasePath: legacyPath });
|
||||
secondReportId = migrations.createReport({ orgId, sourceDatabasePath: legacyPath });
|
||||
});
|
||||
|
||||
const state = snapshot.load();
|
||||
const body = JSON.parse(state.migrationReports[0]!.reportJson) as MigrationReportBody;
|
||||
expect(secondReportId).toBe(firstReportId);
|
||||
expect(state.migrationReports).toHaveLength(1);
|
||||
expect(body.sourceFingerprint).toHaveLength(64);
|
||||
expect(body.tableCounts.organizations).toBe(1);
|
||||
expect(body.tableChecksums.agents).toHaveLength(64);
|
||||
expect(body.idMaps.tasks).toEqual(["task-1"]);
|
||||
expect(body.rollbackPack.sourceFingerprint).toBe(body.sourceFingerprint);
|
||||
expect(body.rollbackPack.tableChecksums.agents).toBe(body.tableChecksums.agents);
|
||||
expect(body.rollbackPack.idMaps.tasks).toEqual(["task-1"]);
|
||||
expect(body.failures).toContainEqual({ table: "agents", reason: "dangling_org_id", detail: "agent-1 -> org-missing" });
|
||||
expect(body.failures).toContainEqual({ table: "tasks", reason: "dangling_assignee_agent_id", detail: "task-1 -> agent-missing" });
|
||||
expect(body.validationSummary).toMatchObject({
|
||||
totalSourceRows: 4,
|
||||
rowsWithIntegrityFailures: 2,
|
||||
rowCoverageBasisPoints: 5000,
|
||||
checksumCoverageBasisPoints: 10000,
|
||||
idMapCoverageBasisPoints: 10000,
|
||||
relationFailureCount: 3,
|
||||
passed: false,
|
||||
});
|
||||
connection.close();
|
||||
});
|
||||
|
||||
test("captures rebuilt domain tables and direct relation health", () => {
|
||||
const connection = createConnection();
|
||||
const organization = new OrganizationService(connection.db);
|
||||
const migrations = new MigrationService(connection.db);
|
||||
const snapshot = new SnapshotService(connection.db);
|
||||
const legacyPath = createLegacyDatabase(`
|
||||
create table organizations(id text primary key);
|
||||
create table org_departments(id text primary key, org_id text, lead_agent_id text);
|
||||
create table hiring_policies(id text primary key, org_id text);
|
||||
create table employees(id text primary key, org_id text);
|
||||
create table employee_identities(id text primary key, org_id text, employee_id text);
|
||||
create table service_identities(id text primary key, org_id text, agent_id text);
|
||||
create table role_grants(id text primary key, org_id text, granted_by_employee_id text);
|
||||
create table access_decisions(id text primary key, org_id text);
|
||||
create table agents(id text primary key, org_id text, model_provider_id text, manager_agent_id text, workspace_id text);
|
||||
create table tasks(id text primary key, org_id text, assignee_agent_id text, project_id text, goal_id text, workspace_id text);
|
||||
create table workspaces(id text primary key, org_id text, project_id text);
|
||||
create table goals(id text primary key, org_id text, owner_agent_id text);
|
||||
create table projects(id text primary key, org_id text, goal_id text, lead_agent_id text);
|
||||
create table task_runs(id text primary key, org_id text, task_id text, agent_id text, provider_id text, provider_request_id text, service_identity_id text);
|
||||
create table approvals(id text primary key, org_id text, decided_by_employee_id text);
|
||||
create table approval_routing_rules(id text primary key, org_id text);
|
||||
create table approval_assignments(id text primary key, org_id text, approval_id text, routing_rule_id text);
|
||||
create table feature_flags(id text primary key, org_id text);
|
||||
create table slo_targets(id text primary key, org_id text);
|
||||
create table operational_events(id text primary key, org_id text);
|
||||
create table chaos_drills(id text primary key, org_id text);
|
||||
create table chaos_drill_runs(id text primary key, org_id text, drill_id text);
|
||||
create table sdlc_review_agents(id text primary key, org_id text);
|
||||
create table sdlc_review_runs(id text primary key, org_id text, review_agent_id text);
|
||||
create table eval_suites(id text primary key, org_id text);
|
||||
create table eval_cases(id text primary key, org_id text, suite_id text);
|
||||
create table eval_runs(id text primary key, org_id text, suite_id text);
|
||||
create table pr_bot_gates(id text primary key, org_id text);
|
||||
create table chat_spaces(id text primary key, org_id text);
|
||||
create table chat_messages(id text primary key, org_id text, space_id text, thread_id text);
|
||||
create table chat_threads(id text primary key, org_id text, space_id text, root_message_id text);
|
||||
create table chat_memberships(space_id text, principal_type text, principal_id text);
|
||||
create table chat_attachments(message_id text, file_id text);
|
||||
create table chat_mentions(message_id text, target_type text, target_id text);
|
||||
create table chat_slash_invocations(id text primary key, message_id text);
|
||||
create table board_interventions(id text primary key, org_id text, thread_id text, message_id text, approval_id text);
|
||||
create table chat_presence(principal_type text, principal_id text);
|
||||
create table chat_read_state(org_id text, employee_id text, space_id text, last_read_message_id text);
|
||||
create table providers(id text primary key, org_id text, credential_id text);
|
||||
create table provider_requests(id text primary key, org_id text, provider_id text, workspace_id text);
|
||||
create table provider_usage_windows(id text primary key, org_id text, provider_id text);
|
||||
create table provider_rate_limit_observations(id text primary key, org_id text, provider_id text);
|
||||
create table provider_auth_profiles(id text primary key, org_id text, provider_id text, credential_id text);
|
||||
create table provider_auth_sessions(id text primary key, org_id text, provider_id text, employee_id text);
|
||||
create table credentials(id text primary key, org_id text);
|
||||
create table skill_definitions(id text primary key, org_id text);
|
||||
create table skill_revisions(id text primary key, skill_id text);
|
||||
create table skill_artifacts(id text primary key, revision_id text);
|
||||
create table skill_tests(id text primary key, revision_id text);
|
||||
create table skill_dependencies(revision_id text, depends_on_revision_id text);
|
||||
create table skill_bindings(id text primary key, org_id text, agent_id text, skill_id text, pinned_revision_id text);
|
||||
create table skill_runtime_snapshots(id text primary key, agent_id text, run_id text);
|
||||
create table skill_catalog_tags(skill_id text, tag text, tag_type text);
|
||||
create table file_objects(id text primary key, org_id text, space_id text);
|
||||
create table file_access_grants(id text primary key, org_id text, file_id text, source_message_id text, source_thread_id text);
|
||||
create table file_handoff_records(id text primary key, org_id text, message_id text, file_id text);
|
||||
create table social_accounts(id text primary key, org_id text, authorized_employee_id text);
|
||||
create table social_account_grants(id text primary key, org_id text, social_account_id text, employee_id text);
|
||||
create table browser_session_containers(id text primary key, org_id text, social_account_id text);
|
||||
create table browser_action_policies(id text primary key, org_id text, social_account_id text);
|
||||
create table social_approval_routes(id text primary key, org_id text, social_account_id text, route_to_id text);
|
||||
create table browser_actions(id text primary key, org_id text, social_account_id text, evidence_file_id text);
|
||||
create table social_audit_records(id text primary key, org_id text, social_account_id text, evidence_file_id text);
|
||||
create table scheduled_posts(id text primary key, org_id text, social_account_id text, approval_id text, created_by_employee_id text);
|
||||
create table policy_rules(id text primary key, org_id text);
|
||||
create table compliance_exports(id text primary key, org_id text, requested_by_employee_id text);
|
||||
create table legal_holds(id text primary key, org_id text, created_by_employee_id text);
|
||||
create table retention_freezes(id text primary key, org_id text, created_by_employee_id text);
|
||||
create table search_documents(entity_id text);
|
||||
create table audit_replay_runs(id text primary key, org_id text, thread_id text, created_by_employee_id text);
|
||||
create table rollback_snapshots(id text primary key, org_id text, migration_report_id text, created_by_employee_id text);
|
||||
create table migration_cutover_events(id text primary key, org_id text, migration_report_id text, rollback_snapshot_id text);
|
||||
create table migration_reconciliation_items(id text primary key, org_id text, migration_report_id text, rollback_snapshot_id text, resolved_by_employee_id text);
|
||||
create table audit_events(id text primary key, org_id text);
|
||||
insert into organizations(id) values ('org-1');
|
||||
insert into employees(id, org_id) values ('emp-1', 'org-1');
|
||||
insert into employee_identities(id, org_id, employee_id) values ('eid-1', 'org-1', 'emp-1');
|
||||
insert into credentials(id, org_id) values ('cred-1', 'org-1');
|
||||
insert into providers(id, org_id, credential_id) values ('prov-1', 'org-1', 'cred-1');
|
||||
insert into chat_spaces(id, org_id) values ('space-1', 'org-1');
|
||||
insert into file_objects(id, org_id, space_id) values ('file-1', 'org-1', 'space-1');
|
||||
insert into workspaces(id, org_id, project_id) values ('wsp-1', 'org-1', 'prj-1');
|
||||
insert into agents(id, org_id, model_provider_id, manager_agent_id, workspace_id) values ('agent-1', 'org-1', 'prov-1', null, 'wsp-1');
|
||||
insert into org_departments(id, org_id, lead_agent_id) values ('dep-1', 'org-1', 'agent-1');
|
||||
insert into hiring_policies(id, org_id) values ('hpl-1', 'org-1');
|
||||
insert into service_identities(id, org_id, agent_id) values ('sid-1', 'org-1', 'agent-1');
|
||||
insert into role_grants(id, org_id, granted_by_employee_id) values ('role-1', 'org-1', 'emp-1');
|
||||
insert into access_decisions(id, org_id) values ('access-1', 'org-1');
|
||||
insert into goals(id, org_id, owner_agent_id) values ('goal-1', 'org-1', 'agent-1');
|
||||
insert into projects(id, org_id, goal_id, lead_agent_id) values ('prj-1', 'org-1', 'goal-1', 'agent-1');
|
||||
insert into tasks(id, org_id, assignee_agent_id, project_id, goal_id, workspace_id) values ('task-1', 'org-1', 'agent-1', 'prj-1', 'goal-1', 'wsp-1');
|
||||
insert into task_runs(id, org_id, task_id, agent_id, provider_id, provider_request_id, service_identity_id) values ('run-1', 'org-1', 'task-1', 'agent-1', 'prov-1', 'req-1', 'sid-1');
|
||||
insert into approvals(id, org_id, decided_by_employee_id) values ('approval-1', 'org-1', 'emp-1');
|
||||
insert into approval_routing_rules(id, org_id) values ('route-1', 'org-1');
|
||||
insert into approval_assignments(id, org_id, approval_id, routing_rule_id) values ('assign-1', 'org-1', 'approval-1', 'route-1');
|
||||
insert into feature_flags(id, org_id) values ('flag-1', 'org-1');
|
||||
insert into slo_targets(id, org_id) values ('slo-1', 'org-1');
|
||||
insert into operational_events(id, org_id) values ('op-1', 'org-1');
|
||||
insert into chaos_drills(id, org_id) values ('drill-1', 'org-1');
|
||||
insert into chaos_drill_runs(id, org_id, drill_id) values ('drill-run-1', 'org-1', 'drill-1');
|
||||
insert into sdlc_review_agents(id, org_id) values ('reviewer-1', 'org-1');
|
||||
insert into sdlc_review_runs(id, org_id, review_agent_id) values ('review-run-1', 'org-1', 'reviewer-1');
|
||||
insert into eval_suites(id, org_id) values ('suite-1', 'org-1');
|
||||
insert into eval_cases(id, org_id, suite_id) values ('case-1', 'org-1', 'suite-1');
|
||||
insert into eval_runs(id, org_id, suite_id) values ('eval-run-1', 'org-1', 'suite-1');
|
||||
insert into pr_bot_gates(id, org_id) values ('gate-1', 'org-1');
|
||||
insert into chat_threads(id, org_id, space_id, root_message_id) values ('thread-1', 'org-1', 'space-1', null);
|
||||
insert into chat_messages(id, org_id, space_id, thread_id) values ('msg-1', 'org-1', 'space-1', 'thread-1');
|
||||
insert into chat_memberships(space_id, principal_type, principal_id) values ('space-1', 'employee', 'emp-1');
|
||||
insert into chat_attachments(message_id, file_id) values ('msg-1', 'file-1');
|
||||
insert into file_access_grants(id, org_id, file_id, source_message_id, source_thread_id) values ('file-grant-1', 'org-1', 'file-1', 'msg-1', 'thread-1');
|
||||
insert into file_handoff_records(id, org_id, message_id, file_id) values ('handoff-1', 'org-1', 'msg-1', 'file-1');
|
||||
insert into chat_mentions(message_id, target_type, target_id) values ('msg-1', 'agent', 'agent-1');
|
||||
insert into chat_slash_invocations(id, message_id) values ('slash-1', 'msg-1');
|
||||
insert into board_interventions(id, org_id, thread_id, message_id, approval_id) values ('bdi-1', 'org-1', 'thread-1', 'msg-1', 'approval-1');
|
||||
insert into chat_presence(principal_type, principal_id) values ('agent', 'agent-1');
|
||||
insert into chat_read_state(org_id, employee_id, space_id, last_read_message_id) values ('org-1', 'emp-1', 'space-1', 'msg-1');
|
||||
insert into provider_requests(id, org_id, provider_id, workspace_id) values ('req-1', 'org-1', 'prov-1', 'wsp-1');
|
||||
insert into provider_usage_windows(id, org_id, provider_id) values ('usage-1', 'org-1', 'prov-1');
|
||||
insert into provider_rate_limit_observations(id, org_id, provider_id) values ('quota-1', 'org-1', 'prov-1');
|
||||
insert into provider_auth_profiles(id, org_id, provider_id, credential_id) values ('authp-1', 'org-1', 'prov-1', 'cred-1');
|
||||
insert into provider_auth_sessions(id, org_id, provider_id, employee_id) values ('auths-1', 'org-1', 'prov-1', 'emp-1');
|
||||
insert into skill_definitions(id, org_id) values ('skill-1', 'org-1');
|
||||
insert into skill_revisions(id, skill_id) values ('rev-1', 'skill-1'), ('rev-2', 'skill-1');
|
||||
insert into skill_artifacts(id, revision_id) values ('artifact-1', 'rev-1');
|
||||
insert into skill_tests(id, revision_id) values ('test-1', 'rev-1');
|
||||
insert into skill_dependencies(revision_id, depends_on_revision_id) values ('rev-1', 'rev-2');
|
||||
insert into skill_bindings(id, org_id, agent_id, skill_id, pinned_revision_id) values ('binding-1', 'org-1', 'agent-1', 'skill-1', 'rev-1');
|
||||
insert into skill_runtime_snapshots(id, agent_id, run_id) values ('snapshot-1', 'agent-1', 'run-1');
|
||||
insert into skill_catalog_tags(skill_id, tag, tag_type) values ('skill-1', 'engineering', 'department');
|
||||
insert into social_accounts(id, org_id, authorized_employee_id) values ('social-1', 'org-1', 'emp-1');
|
||||
insert into social_account_grants(id, org_id, social_account_id, employee_id) values ('grant-1', 'org-1', 'social-1', 'emp-1');
|
||||
insert into browser_session_containers(id, org_id, social_account_id) values ('container-1', 'org-1', 'social-1');
|
||||
insert into browser_action_policies(id, org_id, social_account_id) values ('browser-policy-1', 'org-1', 'social-1');
|
||||
insert into social_approval_routes(id, org_id, social_account_id, route_to_id) values ('social-route-1', 'org-1', 'social-1', 'emp-1');
|
||||
insert into browser_actions(id, org_id, social_account_id, evidence_file_id) values ('browser-1', 'org-1', 'social-1', 'file-1');
|
||||
insert into social_audit_records(id, org_id, social_account_id, evidence_file_id) values ('social-audit-1', 'org-1', 'social-1', 'file-1');
|
||||
insert into scheduled_posts(id, org_id, social_account_id, approval_id, created_by_employee_id) values ('post-1', 'org-1', 'social-1', 'approval-1', 'emp-1');
|
||||
insert into policy_rules(id, org_id) values ('policy-1', 'org-1');
|
||||
insert into compliance_exports(id, org_id, requested_by_employee_id) values ('cmp-1', 'org-1', 'emp-1');
|
||||
insert into legal_holds(id, org_id, created_by_employee_id) values ('hold-1', 'org-1', 'emp-1');
|
||||
insert into retention_freezes(id, org_id, created_by_employee_id) values ('freeze-1', 'org-1', 'emp-1');
|
||||
insert into search_documents(entity_id) values ('task-1');
|
||||
insert into audit_replay_runs(id, org_id, thread_id, created_by_employee_id) values ('replay-1', 'org-1', 'thread-1', 'emp-1');
|
||||
insert into rollback_snapshots(id, org_id, migration_report_id, created_by_employee_id) values ('rollback-1', 'org-1', 'migration-1', 'emp-1');
|
||||
insert into migration_cutover_events(id, org_id, migration_report_id, rollback_snapshot_id) values ('cutover-1', 'org-1', 'migration-1', 'rollback-1');
|
||||
insert into migration_reconciliation_items(id, org_id, migration_report_id, rollback_snapshot_id, resolved_by_employee_id) values ('reconcile-1', 'org-1', 'migration-1', 'rollback-1', 'emp-1');
|
||||
insert into audit_events(id, org_id) values ('audit-1', 'org-1');
|
||||
`);
|
||||
let orgId = "";
|
||||
|
||||
connection.transaction(() => {
|
||||
orgId = createOrganization(organization);
|
||||
migrations.createReport({ orgId, sourceDatabasePath: legacyPath });
|
||||
});
|
||||
|
||||
const body = JSON.parse(snapshot.load().migrationReports[0]!.reportJson) as MigrationReportBody;
|
||||
expect(body.failures).toEqual([]);
|
||||
expect(body.validationSummary).toMatchObject({
|
||||
missingTableCount: 0,
|
||||
rowCoverageBasisPoints: 10000,
|
||||
checksumCoverageBasisPoints: 10000,
|
||||
idMapCoverageBasisPoints: 10000,
|
||||
relationFailureCount: 0,
|
||||
passed: true,
|
||||
});
|
||||
expect(body.tableCounts.credentials).toBe(1);
|
||||
expect(body.tableCounts.employee_identities).toBe(1);
|
||||
expect(body.tableCounts.org_departments).toBe(1);
|
||||
expect(body.tableCounts.hiring_policies).toBe(1);
|
||||
expect(body.tableCounts.feature_flags).toBe(1);
|
||||
expect(body.tableCounts.chaos_drill_runs).toBe(1);
|
||||
expect(body.tableCounts.sdlc_review_runs).toBe(1);
|
||||
expect(body.tableCounts.eval_cases).toBe(1);
|
||||
expect(body.tableCounts.role_grants).toBe(1);
|
||||
expect(body.tableCounts.skill_artifacts).toBe(1);
|
||||
expect(body.tableCounts.chat_read_state).toBe(1);
|
||||
expect(body.tableCounts.board_interventions).toBe(1);
|
||||
expect(body.tableCounts.file_access_grants).toBe(1);
|
||||
expect(body.tableCounts.file_handoff_records).toBe(1);
|
||||
expect(body.tableCounts.provider_requests).toBe(1);
|
||||
expect(body.tableCounts.provider_usage_windows).toBe(1);
|
||||
expect(body.tableCounts.provider_rate_limit_observations).toBe(1);
|
||||
expect(body.tableCounts.provider_auth_profiles).toBe(1);
|
||||
expect(body.tableCounts.provider_auth_sessions).toBe(1);
|
||||
expect(body.tableCounts.browser_session_containers).toBe(1);
|
||||
expect(body.tableCounts.browser_action_policies).toBe(1);
|
||||
expect(body.tableCounts.social_approval_routes).toBe(1);
|
||||
expect(body.tableCounts.social_audit_records).toBe(1);
|
||||
expect(body.tableCounts.compliance_exports).toBe(1);
|
||||
expect(body.tableCounts.audit_replay_runs).toBe(1);
|
||||
expect(body.tableCounts.rollback_snapshots).toBe(1);
|
||||
expect(body.tableCounts.migration_cutover_events).toBe(1);
|
||||
expect(body.tableCounts.migration_reconciliation_items).toBe(1);
|
||||
expect(body.tableCounts.search_documents).toBe(1);
|
||||
expect(body.tableChecksums.audit_events).toHaveLength(64);
|
||||
expect(body.idMaps.skill_runtime_snapshots).toEqual(["snapshot-1"]);
|
||||
expect(body.idMaps.service_identities).toEqual(["sid-1"]);
|
||||
expect(body.idMaps.access_decisions).toEqual(["access-1"]);
|
||||
expect(body.idMaps.approval_assignments).toEqual(["assign-1"]);
|
||||
expect(body.idMaps.operational_events).toEqual(["op-1"]);
|
||||
expect(body.idMaps.chaos_drill_runs).toEqual(["drill-run-1"]);
|
||||
expect(body.idMaps.sdlc_review_runs).toEqual(["review-run-1"]);
|
||||
expect(body.idMaps.eval_runs).toEqual(["eval-run-1"]);
|
||||
expect(body.idMaps.board_interventions).toEqual(["bdi-1"]);
|
||||
expect(body.idMaps.file_handoff_records).toEqual(["handoff-1"]);
|
||||
expect(body.idMaps.retention_freezes).toEqual(["freeze-1"]);
|
||||
expect(body.idMaps.audit_replay_runs).toEqual(["replay-1"]);
|
||||
expect(body.idMaps.rollback_snapshots).toEqual(["rollback-1"]);
|
||||
expect(body.idMaps.migration_cutover_events).toEqual(["cutover-1"]);
|
||||
expect(body.idMaps.migration_reconciliation_items).toEqual(["reconcile-1"]);
|
||||
expect(body.idMaps.provider_usage_windows).toEqual(["usage-1"]);
|
||||
expect(body.idMaps.provider_rate_limit_observations).toEqual(["quota-1"]);
|
||||
expect(body.idMaps.provider_auth_profiles).toEqual(["authp-1"]);
|
||||
expect(body.idMaps.provider_auth_sessions).toEqual(["auths-1"]);
|
||||
expect(body.idMaps.browser_session_containers).toEqual(["container-1"]);
|
||||
expect(body.idMaps.browser_action_policies).toEqual(["browser-policy-1"]);
|
||||
expect(body.idMaps.social_approval_routes).toEqual(["social-route-1"]);
|
||||
expect(body.idMaps.social_audit_records).toEqual(["social-audit-1"]);
|
||||
connection.close();
|
||||
});
|
||||
|
||||
test("rejects reports for unknown target organizations", () => {
|
||||
const connection = createConnection();
|
||||
const migrations = new MigrationService(connection.db);
|
||||
const legacyPath = createLegacyDatabase("create table organizations(id text primary key);");
|
||||
|
||||
expect(() => connection.transaction(() => migrations.createReport({
|
||||
orgId: "org_missing",
|
||||
sourceDatabasePath: legacyPath,
|
||||
}))).toThrow("Organization was not found.");
|
||||
connection.close();
|
||||
});
|
||||
});
|
||||
@@ -1,101 +0,0 @@
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ConnectorId } from "@shared/types";
|
||||
|
||||
vi.mock("electron", () => ({
|
||||
safeStorage: {
|
||||
isEncryptionAvailable: () => false,
|
||||
getSelectedStorageBackend: () => "basic_text",
|
||||
encryptString: (value: string) => Buffer.from(value, "utf8"),
|
||||
decryptString: (value: Buffer) => value.toString("utf8"),
|
||||
},
|
||||
}));
|
||||
|
||||
import { AppDatabase } from "../../src/main/database";
|
||||
import { CONNECTORS } from "../../src/main/connector-definitions";
|
||||
import { resolveModels } from "../../src/main/model-resolver";
|
||||
import { pickFromCandidates } from "../../src/main/model-dispatch";
|
||||
|
||||
describe("model capability seed", () => {
|
||||
let tempDir: string;
|
||||
let db: AppDatabase;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "agentcompany-model-capabilities-"));
|
||||
db = new AppDatabase(tempDir);
|
||||
db.init();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
db.close();
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("keeps catalog-seeded rows current on existing profiles without overwriting user edits", () => {
|
||||
db.core.modelCapabilities.upsertModelCapability({
|
||||
provider: "zai",
|
||||
modelId: "glm-4.6",
|
||||
reasoning: 2,
|
||||
coding: 2,
|
||||
writing: 2,
|
||||
tier: "trivial",
|
||||
costPerMtokOut: 99,
|
||||
});
|
||||
db.core.modelCapabilities.deleteModelCapability("zai", "glm-5.1");
|
||||
|
||||
db.close();
|
||||
db = new AppDatabase(tempDir);
|
||||
db.init();
|
||||
|
||||
expect(db.core.modelCapabilities.getModelCapability("zai", "glm-5.1")).toMatchObject({
|
||||
provider: "zai",
|
||||
modelId: "glm-5.1",
|
||||
reasoning: 5,
|
||||
coding: 4,
|
||||
writing: 4,
|
||||
tier: "hard",
|
||||
});
|
||||
expect(db.core.modelCapabilities.getModelCapability("zai", "glm-4.6")).toMatchObject({
|
||||
reasoning: 2,
|
||||
coding: 2,
|
||||
writing: 2,
|
||||
tier: "trivial",
|
||||
costPerMtokOut: 99,
|
||||
});
|
||||
});
|
||||
|
||||
it("seeds a capability row for every static connector catalog model", () => {
|
||||
const seeded = new Set(
|
||||
db.core.modelCapabilities
|
||||
.listModelCapabilities()
|
||||
.map((entry) => `${entry.provider}::${entry.modelId}`),
|
||||
);
|
||||
const expected = new Set<string>();
|
||||
|
||||
for (const connector of CONNECTORS) {
|
||||
for (const model of resolveModels(connector.provider)) {
|
||||
expected.add(`${connector.provider}::${model.id}`);
|
||||
}
|
||||
if (connector.auth.type !== "local" && connector.defaultModel.trim()) {
|
||||
expected.add(`${connector.provider}::${connector.defaultModel}`);
|
||||
}
|
||||
}
|
||||
|
||||
expect(expected.size).toBeGreaterThan(700);
|
||||
for (const key of expected) {
|
||||
expect(seeded.has(key), key).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("uses the same catalog seed path for hard reasoning picks", () => {
|
||||
const winner = pickFromCandidates(
|
||||
{ domain: "reasoning", requiredTier: "hard" },
|
||||
[{ provider: "zai", connectorId: "zai" as ConnectorId, modelId: "glm-5.1" }],
|
||||
db.core.modelCapabilities.listModelCapabilities(),
|
||||
);
|
||||
|
||||
expect(winner?.modelId).toBe("glm-5.1");
|
||||
});
|
||||
});
|
||||
@@ -1,354 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AgentRecord, ConnectorId, ConnectorRecord, ModelCapability } from "@shared/types";
|
||||
import {
|
||||
pickFromCandidates,
|
||||
pickModelForAgent,
|
||||
type EnabledModel,
|
||||
type PickModelDeps,
|
||||
} from "../../src/main/model-dispatch";
|
||||
|
||||
/**
|
||||
* Minimum fields required to build a plausible AgentRecord for the
|
||||
* dispatcher. The shared type has many optional/legacy columns; the picker
|
||||
* only reads role, title, roleLevel, connectorId, model, modelStrategy, and
|
||||
* companyId.
|
||||
*/
|
||||
function makeAgent(overrides: Partial<AgentRecord>): AgentRecord {
|
||||
return {
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
name: "Test Agent",
|
||||
role: "engineer",
|
||||
title: "Staff Engineer",
|
||||
department: null,
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "anthropic-api" as ConnectorId,
|
||||
workspaceId: null,
|
||||
model: "claude-sonnet-4-6",
|
||||
capabilities: "[]",
|
||||
budgetMonthlyUsd: 0,
|
||||
spentMonthlyUsd: 0,
|
||||
metadataJson: "{}",
|
||||
heartbeatEnabled: false,
|
||||
heartbeatIntervalSec: 120,
|
||||
lastHeartbeatAt: null,
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
modelStrategy: "auto",
|
||||
roleLevel: "ic",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a ConnectorRecord that the execution-readiness check treats as
|
||||
* healthy. Only the fields the check reads are populated realistically;
|
||||
* the rest default to empty.
|
||||
*/
|
||||
function makeConnector(id: string): ConnectorRecord {
|
||||
return {
|
||||
id: id as ConnectorId,
|
||||
label: id,
|
||||
description: "",
|
||||
status: "ready",
|
||||
version: null,
|
||||
authState: "ok",
|
||||
lastCheckedAt: null,
|
||||
lastError: null,
|
||||
model: null,
|
||||
envBindingText: "",
|
||||
notes: "",
|
||||
capabilityMatrix: {
|
||||
sessionResume: true,
|
||||
gracefulCancel: true,
|
||||
modelDiscovery: true,
|
||||
structuredTranscript: true,
|
||||
costAttribution: true,
|
||||
},
|
||||
configurationDoc: "",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build picker deps from a flat list of (connectorId, provider, modelId)
|
||||
* triples + a capability matrix. Anything unspecified behaves as if the
|
||||
* connector is healthy and has no disabled models.
|
||||
*/
|
||||
function makeDeps(options: {
|
||||
enabledModels: Array<{ connectorId: string; provider: string; modelId: string }>;
|
||||
capabilities: ModelCapability[];
|
||||
}): PickModelDeps {
|
||||
const connectorsById = new Map<string, ConnectorRecord>();
|
||||
for (const entry of options.enabledModels) {
|
||||
if (!connectorsById.has(entry.connectorId)) {
|
||||
connectorsById.set(entry.connectorId, makeConnector(entry.connectorId));
|
||||
}
|
||||
}
|
||||
return {
|
||||
getConnector: (id) => {
|
||||
const c = connectorsById.get(id);
|
||||
if (!c) throw new Error(`unknown connector ${id}`);
|
||||
return c;
|
||||
},
|
||||
getDisabledModels: () => [],
|
||||
listModelCapabilities: () => options.capabilities,
|
||||
readConnectors: () => Array.from(connectorsById.values()),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch the getConnectorModelPolicy path used inside listEnabledModels.
|
||||
* We don't want to stand up the full connector definition catalog for
|
||||
* every test, so we intercept policy queries by wiring each connector's
|
||||
* provider + model list through a per-test lookup.
|
||||
*
|
||||
* The picker imports `getConnectorDefinition` and `getConnectorModelPolicy`
|
||||
* at module scope. Integration tests stand up the whole system; these
|
||||
* unit tests exercise the pure scoring math by stubbing those imports via
|
||||
* vitest.
|
||||
*/
|
||||
// Stubbing is handled by the helper above — tests below only assert the
|
||||
// sort/filter/fallback behaviour on a hand-crafted capability matrix plus
|
||||
// a small enabled-model set. The heavy integration path (connector
|
||||
// definitions + policy) is covered by higher-level tests.
|
||||
|
||||
describe("pickModelForAgent", () => {
|
||||
const claudeOpus: ModelCapability = {
|
||||
provider: "anthropic",
|
||||
modelId: "claude-opus-4-7",
|
||||
reasoning: 5,
|
||||
coding: 5,
|
||||
writing: 5,
|
||||
tier: "hard",
|
||||
costPerMtokOut: 75,
|
||||
updatedAt: "",
|
||||
};
|
||||
const llama: ModelCapability = {
|
||||
provider: "fireworks",
|
||||
modelId: "llama-70b",
|
||||
reasoning: 2,
|
||||
coding: 2,
|
||||
writing: 2,
|
||||
tier: "trivial",
|
||||
costPerMtokOut: 1,
|
||||
updatedAt: "",
|
||||
};
|
||||
|
||||
it("explicit strategy honours agent.connectorId + agent.model verbatim", () => {
|
||||
const agent = makeAgent({
|
||||
modelStrategy: "explicit",
|
||||
connectorId: "anthropic-api" as ConnectorId,
|
||||
model: "claude-opus-4-7",
|
||||
});
|
||||
const deps = makeDeps({
|
||||
enabledModels: [
|
||||
{ connectorId: "anthropic-api", provider: "anthropic", modelId: "claude-opus-4-7" },
|
||||
],
|
||||
capabilities: [claudeOpus],
|
||||
});
|
||||
const pick = pickModelForAgent(agent, deps);
|
||||
expect(pick.ok).toBe(true);
|
||||
if (pick.ok) {
|
||||
expect(pick.assignment).toEqual({ connectorId: "anthropic-api", modelId: "claude-opus-4-7" });
|
||||
}
|
||||
});
|
||||
|
||||
it("CEO role always takes the user-picked pair regardless of modelStrategy", () => {
|
||||
const agent = makeAgent({
|
||||
modelStrategy: "auto",
|
||||
roleLevel: "c_suite",
|
||||
role: "CEO",
|
||||
reportsTo: "some-parent",
|
||||
connectorId: "anthropic-api" as ConnectorId,
|
||||
model: "claude-opus-4-7",
|
||||
});
|
||||
const deps = makeDeps({
|
||||
enabledModels: [
|
||||
{ connectorId: "anthropic-api", provider: "anthropic", modelId: "claude-opus-4-7" },
|
||||
{ connectorId: "fireworks", provider: "fireworks", modelId: "llama-70b" },
|
||||
],
|
||||
capabilities: [claudeOpus, llama],
|
||||
});
|
||||
const pick = pickModelForAgent(agent, deps);
|
||||
expect(pick.ok).toBe(true);
|
||||
if (pick.ok) expect(pick.assignment.modelId).toBe("claude-opus-4-7");
|
||||
});
|
||||
|
||||
it("CEO is identified by reportsTo === null even when roleLevel stays at the schema default 'ic'", () => {
|
||||
const agent = makeAgent({
|
||||
modelStrategy: "auto",
|
||||
roleLevel: "ic",
|
||||
reportsTo: null,
|
||||
role: "CEO",
|
||||
connectorId: "anthropic-api" as ConnectorId,
|
||||
model: "claude-opus-4-7",
|
||||
});
|
||||
const deps = makeDeps({
|
||||
enabledModels: [
|
||||
{ connectorId: "anthropic-api", provider: "anthropic", modelId: "claude-opus-4-7" },
|
||||
{ connectorId: "fireworks", provider: "fireworks", modelId: "llama-70b" },
|
||||
],
|
||||
capabilities: [claudeOpus, llama],
|
||||
});
|
||||
const pick = pickModelForAgent(agent, deps);
|
||||
expect(pick.ok).toBe(true);
|
||||
if (pick.ok) expect(pick.assignment.modelId).toBe("claude-opus-4-7");
|
||||
});
|
||||
|
||||
it("auto strategy with no qualifying candidate fails explicitly (no silent CEO fallback)", () => {
|
||||
const agent = makeAgent({
|
||||
modelStrategy: "auto",
|
||||
roleLevel: "ic",
|
||||
reportsTo: "manager-1",
|
||||
role: "Senior Architect",
|
||||
title: "Principal Architect",
|
||||
connectorId: "fireworks" as ConnectorId,
|
||||
model: "llama-70b",
|
||||
});
|
||||
// Two enabled models, both scoring too low for a hard reasoning agent.
|
||||
const deps = makeDeps({
|
||||
enabledModels: [
|
||||
{ connectorId: "fireworks", provider: "fireworks", modelId: "llama-70b" },
|
||||
{ connectorId: "fireworks-b", provider: "fireworks", modelId: "llama-7b" },
|
||||
],
|
||||
capabilities: [
|
||||
llama,
|
||||
{ provider: "fireworks", modelId: "llama-7b", reasoning: 2, coding: 2, writing: 2, tier: "trivial", costPerMtokOut: 0.5, updatedAt: "" },
|
||||
],
|
||||
});
|
||||
const pick = pickModelForAgent(agent, deps);
|
||||
expect(pick.ok).toBe(false);
|
||||
if (!pick.ok) {
|
||||
expect(pick.reason).toBe("NO_QUALIFYING_CANDIDATE");
|
||||
expect(pick.message).toContain(agent.name);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("inferTaskProfile — i18n", () => {
|
||||
it("classifies Chinese role strings via substring match", async () => {
|
||||
const { inferTaskProfile } = await import("../../src/main/agent-role-helpers");
|
||||
expect(inferTaskProfile("高级架构师", null, "director").domain).toBe("reasoning");
|
||||
expect(inferTaskProfile("前端工程师", null, "ic").domain).toBe("coding");
|
||||
expect(inferTaskProfile("内容运营", null, "ic").domain).toBe("writing");
|
||||
expect(inferTaskProfile("产品经理", null, "manager").domain).toBe("writing");
|
||||
});
|
||||
|
||||
it("recognises Chinese trivial-tier cues", async () => {
|
||||
const { inferTaskProfile } = await import("../../src/main/agent-role-helpers");
|
||||
expect(inferTaskProfile("实习生", "数据标注实习生", "ic").requiredTier).toBe("trivial");
|
||||
expect(inferTaskProfile("初级工程师", null, "ic").requiredTier).toBe("trivial");
|
||||
expect(inferTaskProfile("助理", null, "ic").requiredTier).toBe("trivial");
|
||||
});
|
||||
|
||||
it("English classification still works after unicode tokenizer swap", async () => {
|
||||
const { inferTaskProfile } = await import("../../src/main/agent-role-helpers");
|
||||
expect(inferTaskProfile("Senior Architect", null, "director").domain).toBe("reasoning");
|
||||
expect(inferTaskProfile("Staff Engineer", null, "lead").domain).toBe("coding");
|
||||
expect(inferTaskProfile("Marketing Lead", null, "lead").domain).toBe("writing");
|
||||
// 'lead' keyword must not match 'leaderboard' / 'cleaner' substrings —
|
||||
// token-based matching is still in effect for Latin keywords.
|
||||
expect(inferTaskProfile("Cleaner", null, "ic").domain).toBe("writing");
|
||||
});
|
||||
});
|
||||
|
||||
describe("pickFromCandidates — pure rubric", () => {
|
||||
const claudeOpus: ModelCapability = {
|
||||
provider: "anthropic", modelId: "claude-opus-4-7",
|
||||
reasoning: 5, coding: 5, writing: 5,
|
||||
tier: "hard", costPerMtokOut: 75, updatedAt: "",
|
||||
};
|
||||
const claudeSonnet: ModelCapability = {
|
||||
provider: "anthropic", modelId: "claude-sonnet-4-6",
|
||||
reasoning: 4, coding: 5, writing: 5,
|
||||
tier: "standard", costPerMtokOut: 15, updatedAt: "",
|
||||
};
|
||||
const kimiK2: ModelCapability = {
|
||||
provider: "fireworks", modelId: "kimi-k2",
|
||||
reasoning: 4, coding: 4, writing: 5,
|
||||
tier: "standard", costPerMtokOut: 3, updatedAt: "",
|
||||
};
|
||||
const llama: ModelCapability = {
|
||||
provider: "fireworks", modelId: "llama-70b",
|
||||
reasoning: 2, coding: 2, writing: 2,
|
||||
tier: "trivial", costPerMtokOut: 1, updatedAt: "",
|
||||
};
|
||||
|
||||
const allEnabled: EnabledModel[] = [
|
||||
{ provider: "anthropic", connectorId: "claude-pro" as ConnectorId, modelId: "claude-opus-4-7" },
|
||||
{ provider: "anthropic", connectorId: "claude-pro" as ConnectorId, modelId: "claude-sonnet-4-6" },
|
||||
{ provider: "fireworks", connectorId: "fireworks" as ConnectorId, modelId: "kimi-k2" },
|
||||
{ provider: "fireworks", connectorId: "fireworks" as ConnectorId, modelId: "llama-70b" },
|
||||
];
|
||||
|
||||
it("picks the cheapest qualifying model (standard/writing → kimi beats sonnet on price)", () => {
|
||||
const winner = pickFromCandidates(
|
||||
{ domain: "writing", requiredTier: "standard" },
|
||||
allEnabled,
|
||||
[claudeOpus, claudeSonnet, kimiK2, llama],
|
||||
);
|
||||
expect(winner?.modelId).toBe("kimi-k2");
|
||||
});
|
||||
|
||||
it("hard tier requires tier≥hard even if domain score clears the floor", () => {
|
||||
// writing=5 qualifies sonnet and kimi numerically, but both are tier=standard.
|
||||
// Only opus (tier=hard) survives.
|
||||
const winner = pickFromCandidates(
|
||||
{ domain: "writing", requiredTier: "hard" },
|
||||
allEnabled,
|
||||
[claudeOpus, claudeSonnet, kimiK2, llama],
|
||||
);
|
||||
expect(winner?.modelId).toBe("claude-opus-4-7");
|
||||
});
|
||||
|
||||
it("returns null when no candidate clears the bar", () => {
|
||||
// Ask for hard/reasoning ≥4 but only provide trivial-tier llama.
|
||||
const winner = pickFromCandidates(
|
||||
{ domain: "reasoning", requiredTier: "hard" },
|
||||
[{ provider: "fireworks", connectorId: "fireworks" as ConnectorId, modelId: "llama-70b" }],
|
||||
[llama],
|
||||
);
|
||||
expect(winner).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null on an empty candidate list", () => {
|
||||
expect(pickFromCandidates({ domain: "coding", requiredTier: "trivial" }, [], [llama])).toBeNull();
|
||||
});
|
||||
|
||||
it("trivial tier still honours the domain floor (2)", () => {
|
||||
// llama scores 2 on writing — exactly on the floor, so it qualifies.
|
||||
const winner = pickFromCandidates(
|
||||
{ domain: "writing", requiredTier: "trivial" },
|
||||
[{ provider: "fireworks", connectorId: "fireworks" as ConnectorId, modelId: "llama-70b" }],
|
||||
[llama],
|
||||
);
|
||||
expect(winner?.modelId).toBe("llama-70b");
|
||||
});
|
||||
|
||||
it("ignores enabled models with no capability row", () => {
|
||||
// Only claudeOpus has a capability row; mystery model is filtered out
|
||||
// despite appearing in the enabled list.
|
||||
const winner = pickFromCandidates(
|
||||
{ domain: "reasoning", requiredTier: "hard" },
|
||||
[
|
||||
{ provider: "mystery", connectorId: "mystery" as ConnectorId, modelId: "unknown" },
|
||||
{ provider: "anthropic", connectorId: "claude-pro" as ConnectorId, modelId: "claude-opus-4-7" },
|
||||
],
|
||||
[claudeOpus],
|
||||
);
|
||||
expect(winner?.modelId).toBe("claude-opus-4-7");
|
||||
});
|
||||
});
|
||||
|
||||
describe("pickModelForAgent — internal rubric", () => {
|
||||
it("exposes DOMAIN_SCORE_FLOOR and TIER_RANK for downstream tests", async () => {
|
||||
const { __INTERNAL__ } = await import("../../src/main/model-dispatch");
|
||||
expect(__INTERNAL__.DOMAIN_SCORE_FLOOR.trivial).toBe(2);
|
||||
expect(__INTERNAL__.DOMAIN_SCORE_FLOOR.standard).toBe(3);
|
||||
expect(__INTERNAL__.DOMAIN_SCORE_FLOOR.hard).toBe(4);
|
||||
expect(__INTERNAL__.TIER_RANK.trivial).toBe(1);
|
||||
expect(__INTERNAL__.TIER_RANK.standard).toBe(2);
|
||||
expect(__INTERNAL__.TIER_RANK.hard).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -1,46 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { SectionId } from "@shared/types";
|
||||
import {
|
||||
COMMAND_PALETTE_NAV_ITEM_DEFS,
|
||||
DASHBOARD_NAV_TARGETS,
|
||||
SECTION_NAV_IDS,
|
||||
SETTINGS_NAV_ITEM_DEF,
|
||||
SIDEBAR_NAV_GROUP_DEFS,
|
||||
} from "../../src/renderer/navigation";
|
||||
|
||||
function sidebarSectionIds(): SectionId[] {
|
||||
return SIDEBAR_NAV_GROUP_DEFS.flatMap((group) => group.items.map((item) => item.id));
|
||||
}
|
||||
|
||||
describe("renderer navigation model", () => {
|
||||
it("keeps the sidebar focused on the primary user journey", () => {
|
||||
expect(sidebarSectionIds()).toEqual([
|
||||
"overview",
|
||||
"inbox",
|
||||
"tasks",
|
||||
"approvals",
|
||||
"agents",
|
||||
"communication",
|
||||
"documents",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps every section reachable from command navigation", () => {
|
||||
const commandIds = COMMAND_PALETTE_NAV_ITEM_DEFS.map((item) => item.id);
|
||||
|
||||
expect(new Set(commandIds).size).toBe(SECTION_NAV_IDS.length);
|
||||
expect(commandIds).toEqual(expect.arrayContaining(SECTION_NAV_IDS));
|
||||
});
|
||||
|
||||
it("keeps Dashboard targets reachable from command navigation", () => {
|
||||
const commandIds = new Set(COMMAND_PALETTE_NAV_ITEM_DEFS.map((item) => item.id));
|
||||
|
||||
for (const sectionId of DASHBOARD_NAV_TARGETS) {
|
||||
expect(commandIds.has(sectionId)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps settings outside the scrollable sidebar groups", () => {
|
||||
expect(sidebarSectionIds()).not.toContain(SETTINGS_NAV_ITEM_DEF.id);
|
||||
});
|
||||
});
|
||||
@@ -1,270 +0,0 @@
|
||||
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<{
|
||||
options: Record<string, unknown>;
|
||||
clickHandler?: () => void;
|
||||
closeHandler?: () => void;
|
||||
}> = [];
|
||||
|
||||
vi.mock("electron", () => {
|
||||
return {
|
||||
Notification: class MockNotification {
|
||||
static isSupported = () => true;
|
||||
options: Record<string, unknown>;
|
||||
_clickHandler?: () => void;
|
||||
_closeHandler?: () => void;
|
||||
|
||||
constructor(options: Record<string, unknown>) {
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
show() {
|
||||
constructedNotifications.push({
|
||||
options: this.options,
|
||||
clickHandler: this._clickHandler,
|
||||
closeHandler: this._closeHandler,
|
||||
});
|
||||
}
|
||||
|
||||
on(event: string, handler: () => void) {
|
||||
if (event === "click") this._clickHandler = handler;
|
||||
if (event === "close") this._closeHandler = handler;
|
||||
// Update the last entry if already shown, or store for later
|
||||
const last = constructedNotifications[constructedNotifications.length - 1];
|
||||
if (last && event === "click") last.clickHandler = handler;
|
||||
if (last && event === "close") last.closeHandler = handler;
|
||||
}
|
||||
},
|
||||
app: { focus: vi.fn() },
|
||||
};
|
||||
});
|
||||
|
||||
// Import after mocking
|
||||
const { createNotificationService } = await import("@main/notification-service");
|
||||
|
||||
describe("notification-service", () => {
|
||||
let service: ReturnType<typeof createNotificationService>;
|
||||
let mockGetMainWindow: ReturnType<typeof vi.fn>;
|
||||
let mockEmitEvent: ReturnType<typeof vi.fn>;
|
||||
let mockLogger: { info: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn>; error: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
constructedNotifications.length = 0;
|
||||
|
||||
mockGetMainWindow = vi.fn(() => null);
|
||||
mockEmitEvent = vi.fn();
|
||||
mockLogger = {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
|
||||
service = createNotificationService({
|
||||
getMainWindow: mockGetMainWindow as unknown as () => BrowserWindow | null,
|
||||
emitEvent: mockEmitEvent as unknown as (event: DesktopEvent) => void,
|
||||
logger: mockLogger as { info: (...args: unknown[]) => void; warn: (...args: unknown[]) => void; error: (...args: unknown[]) => void },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
service.destroy();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("Test 1: fireNotification with urgency 'critical' creates Notification with silent: false", () => {
|
||||
service.notify({
|
||||
title: "Budget Exhausted",
|
||||
body: "Agent paused",
|
||||
urgency: "critical",
|
||||
});
|
||||
|
||||
expect(constructedNotifications).toHaveLength(1);
|
||||
expect(constructedNotifications[0]!.options.silent).toBe(false);
|
||||
});
|
||||
|
||||
it("Test 2: fireNotification with urgency 'informational' creates Notification with silent: true", () => {
|
||||
service.notify({
|
||||
title: "Agent Hired",
|
||||
body: "Bob joined",
|
||||
urgency: "informational",
|
||||
});
|
||||
|
||||
expect(constructedNotifications).toHaveLength(1);
|
||||
expect(constructedNotifications[0]!.options.silent).toBe(true);
|
||||
});
|
||||
|
||||
it("Test 3: notify without batchKey fires immediately (no batching)", () => {
|
||||
service.notify({
|
||||
title: "Update Available",
|
||||
body: "Version 2.0 is ready",
|
||||
urgency: "informational",
|
||||
});
|
||||
|
||||
expect(constructedNotifications).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("Test 4: notify with batchKey fires first event immediately, starts batch timer", () => {
|
||||
service.notify({
|
||||
title: "Task Failed",
|
||||
body: "Task A failed",
|
||||
urgency: "informational",
|
||||
navigation: { section: "tasks" },
|
||||
batchKey: "failed_runs",
|
||||
});
|
||||
|
||||
expect(constructedNotifications).toHaveLength(1);
|
||||
expect(constructedNotifications[0]!.options.title).toBe("Task Failed");
|
||||
});
|
||||
|
||||
it("Test 5: notify with same batchKey within 10s increments count, does NOT fire second notification", () => {
|
||||
service.notify({
|
||||
title: "Task Failed",
|
||||
body: "Task A failed",
|
||||
urgency: "informational",
|
||||
navigation: { section: "tasks" },
|
||||
batchKey: "failed_runs",
|
||||
});
|
||||
|
||||
service.notify({
|
||||
title: "Task Failed",
|
||||
body: "Task B failed",
|
||||
urgency: "informational",
|
||||
navigation: { section: "tasks" },
|
||||
batchKey: "failed_runs",
|
||||
});
|
||||
|
||||
// Only the first should have fired
|
||||
expect(constructedNotifications).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("Test 6: After 10s batch timer expires, fires summary notification with count in title", () => {
|
||||
service.notify({
|
||||
title: "Task Failed",
|
||||
body: "Task A failed",
|
||||
urgency: "informational",
|
||||
navigation: { section: "tasks" },
|
||||
batchKey: "failed_runs",
|
||||
});
|
||||
|
||||
// Add 2 more batched notifications
|
||||
service.notify({
|
||||
title: "Task Failed",
|
||||
body: "Task B failed",
|
||||
urgency: "informational",
|
||||
navigation: { section: "tasks" },
|
||||
batchKey: "failed_runs",
|
||||
});
|
||||
service.notify({
|
||||
title: "Task Failed",
|
||||
body: "Task C failed",
|
||||
urgency: "informational",
|
||||
navigation: { section: "tasks" },
|
||||
batchKey: "failed_runs",
|
||||
});
|
||||
|
||||
expect(constructedNotifications).toHaveLength(1); // Only first
|
||||
|
||||
// Advance timer by 10 seconds
|
||||
vi.advanceTimersByTime(10_000);
|
||||
|
||||
// Summary notification should have fired
|
||||
expect(constructedNotifications).toHaveLength(2);
|
||||
const summary = constructedNotifications[1]!;
|
||||
expect(summary.options.title).toContain("2");
|
||||
expect(summary.options.title).toContain("tasks failed");
|
||||
expect(summary.options.body).toBe("Click to view failed runs");
|
||||
});
|
||||
|
||||
it("Test 7: Batched summary notification has no entityId (navigates to section overview)", () => {
|
||||
service.notify({
|
||||
title: "Agent Hired",
|
||||
body: "Alice joined",
|
||||
urgency: "informational",
|
||||
navigation: { section: "agents", entityId: "agent-1" },
|
||||
batchKey: "agent_hired",
|
||||
});
|
||||
|
||||
service.notify({
|
||||
title: "Agent Hired",
|
||||
body: "Bob joined",
|
||||
urgency: "informational",
|
||||
navigation: { section: "agents", entityId: "agent-2" },
|
||||
batchKey: "agent_hired",
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(10_000);
|
||||
|
||||
// Summary notification fires -- simulate click
|
||||
expect(constructedNotifications).toHaveLength(2);
|
||||
const summaryClickHandler = constructedNotifications[1]!.clickHandler;
|
||||
expect(summaryClickHandler).toBeInstanceOf(Function);
|
||||
summaryClickHandler!();
|
||||
|
||||
// emitEvent should have been called with section but no entityId
|
||||
const emitCall = mockEmitEvent.mock.calls.find(
|
||||
(args: unknown[]) => (args[0] as { type?: string })?.type === "notification-click",
|
||||
);
|
||||
expect(emitCall).toBeDefined();
|
||||
expect(emitCall![0].section).toBe("agents");
|
||||
expect(emitCall![0].entityId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("Test 8: Events in NEVER_BATCH list always fire individually regardless of batchKey", () => {
|
||||
// pending_approval is in NEVER_BATCH
|
||||
service.notify({
|
||||
title: "Approval Required",
|
||||
body: "First approval",
|
||||
urgency: "critical",
|
||||
navigation: { section: "approvals" },
|
||||
batchKey: "pending_approval",
|
||||
});
|
||||
|
||||
service.notify({
|
||||
title: "Approval Required",
|
||||
body: "Second approval",
|
||||
urgency: "critical",
|
||||
navigation: { section: "approvals" },
|
||||
batchKey: "pending_approval",
|
||||
});
|
||||
|
||||
// Both should fire individually
|
||||
expect(constructedNotifications).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("Test 9: Active Notification objects are held in a Set and removed on 'close' event", () => {
|
||||
service.notify({
|
||||
title: "Test",
|
||||
body: "Test body",
|
||||
urgency: "informational",
|
||||
});
|
||||
|
||||
expect(constructedNotifications).toHaveLength(1);
|
||||
|
||||
// The close handler should exist and be callable without error
|
||||
const closeHandler = constructedNotifications[0]!.closeHandler;
|
||||
expect(closeHandler).toBeInstanceOf(Function);
|
||||
|
||||
// Calling close handler should not throw (notification removed from internal Set)
|
||||
closeHandler!();
|
||||
});
|
||||
|
||||
it("Test 10: On Linux, urgency 'critical' sets urgency option to 'critical'", () => {
|
||||
const originalPlatform = process.platform;
|
||||
Object.defineProperty(process, "platform", { value: "linux", configurable: true });
|
||||
|
||||
service.notify({
|
||||
title: "Critical Alert",
|
||||
body: "Something critical",
|
||||
urgency: "critical",
|
||||
});
|
||||
|
||||
expect(constructedNotifications).toHaveLength(1);
|
||||
expect(constructedNotifications[0]!.options.urgency).toBe("critical");
|
||||
|
||||
Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true });
|
||||
});
|
||||
});
|
||||
@@ -1,64 +0,0 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { AppDatabase } from "../../src/main/database";
|
||||
import { CredentialStore } from "../../src/main/credentials/credential-store";
|
||||
import { OAuthLoginService } from "../../src/main/credentials/oauth-login-service";
|
||||
|
||||
vi.mock("electron", () => ({
|
||||
shell: { openExternal: vi.fn(async () => true) },
|
||||
}));
|
||||
|
||||
beforeAll(() => {
|
||||
process.env.AGENTCOMPANY_ALLOW_INSECURE_VAULT = "true";
|
||||
});
|
||||
|
||||
const noopLogger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
||||
|
||||
describe("OAuthLoginService", () => {
|
||||
let tempDir: string;
|
||||
let db: AppDatabase;
|
||||
let store: CredentialStore;
|
||||
let service: OAuthLoginService;
|
||||
const emit = vi.fn();
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "agentcompany-oauth-service-"));
|
||||
db = new AppDatabase(tempDir);
|
||||
db.init();
|
||||
store = new CredentialStore({ db });
|
||||
service = new OAuthLoginService({
|
||||
store,
|
||||
emitEvent: emit,
|
||||
refreshConnectorHealth: async () => {},
|
||||
logger: noopLogger,
|
||||
});
|
||||
emit.mockClear();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
db.close();
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("rejects start() for a non-OAuth connector", async () => {
|
||||
await expect(service.start("anthropic")).rejects.toThrow(/does not use OAuth/);
|
||||
});
|
||||
|
||||
it("rejects start() for an unknown connector id", async () => {
|
||||
await expect(service.start("definitely-not-a-connector")).rejects.toThrow(/Unknown connector/);
|
||||
});
|
||||
|
||||
it("submitPromptResponse returns false when there is no pending prompt", () => {
|
||||
expect(service.submitPromptResponse("nonexistent-request-id", "value")).toBe(false);
|
||||
});
|
||||
|
||||
it("submitManualCode returns false when there is no pending request", () => {
|
||||
expect(service.submitManualCode("nonexistent-request-id", "code")).toBe(false);
|
||||
});
|
||||
|
||||
it("cancel() of a connector with no active flow is a no-op (does not throw)", () => {
|
||||
expect(() => service.cancel("claude-pro")).not.toThrow();
|
||||
});
|
||||
});
|
||||
Loaded 100 of 1904 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user