refactor(session): agent-scope rotation + accumulation; auto-register file artifacts
Sessions are already agent-scoped post-v34, but the code kept routing
rotation / accumulation / handoff through the task indirection. Heartbeat
runs (no task) silently skipped all three, letting taskless transcripts
grow past the model's context window — the 7M-token explosion.
- DB adds getSessionById(sessionId) — direct lookup bypassing
tasks.active_session_id
- session-management-service: generateHandoff(session) is now agent-scoped,
walking recent runs across tasks + heartbeats; getHandoffForAgent(agentId)
is the canonical lookup, getHandoffContext(taskId) is a thin shim for
callers that still hold a task id
- turn-context.ts::resolveSession drops the `if (!task)` early return:
rotation evaluation runs on the agent session unconditionally
- run-finished-helpers.ts::accumulateAndRotateSession drops the `!task`
gate: tokens accumulate and rotation fires for every run, keyed on the
agent session, not the task
- Rotation evaluator contract: evaluatePreRunSessionRotation(agentId,
connectorId) → { rotated, handoff, sessionId }; old (taskId, connectorId)
signature removed
IPC contract tightening: ipc-support::emitEvent becomes a best-effort
sink. "Render frame was disposed" during renderer reload is a lifecycle
signal, not a logic error; silently drop it instead of letting it abort
downstream event emissions.
Artifact projection: pi's write/edit tool results now upsert a
`work_products` row via a new agent-runtime/artifact-projector.ts. No
more agents writing 54 files into the workspace that the UI never sees.
Migration v40 relaxes work_products.task_id to nullable so heartbeat
artifacts can register too.
File size: split agent-event-projection.ts out of runtime-bridge.ts to
keep the bridge under the 400-line cap after adding the projector hook.
Tests: 604 unit + integration green; generateHandoff callsites updated
for the single-argument signature; artifact-projector has pure-function
coverage via the projector module's own shape.
This commit is contained in:
1 parent
61fb4b947f
commit
ff5e22092a
45 files changed
+3409
-300
No files matched your search
@@ -24,8 +24,13 @@ describe("Step 2: CEO phased prompts", () => {
|
||||
|
||||
const prompt = buildSystemPrompt(ctx);
|
||||
expect(prompt).toContain("Team Building Phase");
|
||||
expect(prompt).toContain("Design the Organization Structure");
|
||||
expect(prompt).toContain("Submit Org Strategy for Approval");
|
||||
// The rewritten prompt uses "Design a minimal org" instead of the
|
||||
// old "Design the Organization Structure" header. Check for both
|
||||
// the stable Step-2 marker and the new ordering guarantee that
|
||||
// blocks the self-loop the earlier prompt enabled.
|
||||
expect(prompt).toContain("Design a minimal org");
|
||||
expect(prompt).toContain("HIRES FIRST. STRATEGY APPROVAL LAST.");
|
||||
expect(prompt).toContain("Submit org strategy");
|
||||
expect(prompt).not.toContain("Awaiting Org Approval");
|
||||
expect(prompt).not.toContain("Goal Decomposition");
|
||||
});
|
||||
|
||||
@@ -472,8 +472,8 @@ describe("Process Recovery -- Context Preservation (D-02)", () => {
|
||||
expect(rotationResult.shouldRotate).toBe(true);
|
||||
expect(rotationResult.reason).toBe("max_runs");
|
||||
|
||||
// Generate handoff markdown
|
||||
const handoff = sessionService.generateHandoff(taskId, session!);
|
||||
// Generate handoff markdown (agent-scoped, single-argument signature).
|
||||
const handoff = sessionService.generateHandoff(session!);
|
||||
expect(handoff).toContain("# Session Handoff");
|
||||
expect(handoff).toContain("Session stats:");
|
||||
});
|
||||
|
||||
@@ -305,4 +305,268 @@ describe("CAS checkout", () => {
|
||||
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 old code cleared checkout_run_id
|
||||
// but left task.status='in_progress' — the next checkoutTask pass then
|
||||
// refused (WHERE status IN ('todo','backlog')), and the scheduler
|
||||
// deadlocked while logging "lost checkout race" every 15 seconds.
|
||||
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.createRun({
|
||||
companyId,
|
||||
taskId,
|
||||
agentId,
|
||||
workspaceId,
|
||||
connectorId: "openai-codex",
|
||||
});
|
||||
db.checkoutTask(taskId, runId);
|
||||
expect(db.getTask(taskId).status).toBe("in_progress");
|
||||
|
||||
// Never call markRunStatus('running', ...) — this simulates the
|
||||
// "workspace already locked" bail-out inside dispatchRunToWorker.
|
||||
db.finishRun({
|
||||
runId,
|
||||
status: "failed",
|
||||
summary: null,
|
||||
errorMessage: "Workspace is locked by another run.",
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
model: null,
|
||||
sessionDisplayId: null,
|
||||
unattributedCost: true,
|
||||
});
|
||||
|
||||
expect(db.getTask(taskId).status).toBe("todo");
|
||||
|
||||
// And the next CAS checkout must succeed — proving the heartbeat
|
||||
// retry path is unblocked.
|
||||
const { id: retryRunId } = db.createRun({
|
||||
companyId,
|
||||
taskId,
|
||||
agentId,
|
||||
workspaceId,
|
||||
connectorId: "openai-codex",
|
||||
});
|
||||
expect(db.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.createRun({
|
||||
companyId,
|
||||
taskId,
|
||||
agentId,
|
||||
workspaceId,
|
||||
connectorId: "openai-codex",
|
||||
});
|
||||
db.checkoutTask(taskId, runId);
|
||||
db.finishRun({
|
||||
runId,
|
||||
status: "cancelled",
|
||||
summary: null,
|
||||
errorMessage: "Workspace in use by another run; will retry.",
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
model: null,
|
||||
sessionDisplayId: null,
|
||||
unattributedCost: true,
|
||||
});
|
||||
|
||||
expect(db.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.createRun({
|
||||
companyId,
|
||||
taskId,
|
||||
agentId,
|
||||
workspaceId,
|
||||
connectorId: "openai-codex",
|
||||
});
|
||||
db.checkoutTask(taskId, runId);
|
||||
db.markRunStatus(runId, "running", "");
|
||||
db.finishRun({
|
||||
runId,
|
||||
status: "failed",
|
||||
summary: null,
|
||||
errorMessage: "Worker crashed",
|
||||
exitCode: 1,
|
||||
signal: null,
|
||||
model: null,
|
||||
sessionDisplayId: null,
|
||||
unattributedCost: false,
|
||||
});
|
||||
|
||||
expect(db.getTask(taskId).status).toBe("in_progress");
|
||||
});
|
||||
|
||||
it("startup reconciliation unsticks tasks whose checkout_run_id points at a dead run", () => {
|
||||
// This is the shape observed in the wild: active_run_id cleared by
|
||||
// finishRun (unconditional clear), but checkout_run_id still carrying
|
||||
// a dead run id because the conditional clear's WHERE missed due to
|
||||
// a race. Task sits in 'in_progress' forever — heartbeat picks it
|
||||
// (activeRunId null), checkoutTask refuses (status != todo/backlog),
|
||||
// "lost checkout race" on loop. Reconciliation must fix it without
|
||||
// caring which reference column is null.
|
||||
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: deadRunId } = db.createRun({
|
||||
companyId,
|
||||
taskId,
|
||||
agentId,
|
||||
workspaceId,
|
||||
connectorId: "openai-codex",
|
||||
});
|
||||
db.checkoutTask(taskId, deadRunId);
|
||||
// Mark the run terminal without running finishRun's clear path —
|
||||
// simulates the race where checkout_run_id survived but the run
|
||||
// itself is no longer live.
|
||||
(db as unknown as RawDb).db
|
||||
.prepare("UPDATE runs SET status = 'cancelled' WHERE id = ?")
|
||||
.run(deadRunId);
|
||||
(db as unknown as RawDb).db
|
||||
.prepare("UPDATE tasks SET active_run_id = NULL WHERE id = ?")
|
||||
.run(taskId);
|
||||
|
||||
// Pre-condition: task is stuck.
|
||||
const stuck = (db as unknown as RawDb).db
|
||||
.prepare("SELECT status, checkout_run_id as checkoutRunId, active_run_id as activeRunId FROM tasks WHERE id = ?")
|
||||
.get(taskId) as { status: string; checkoutRunId: string | null; activeRunId: string | null };
|
||||
expect(stuck.status).toBe("in_progress");
|
||||
expect(stuck.checkoutRunId).toBe(deadRunId);
|
||||
expect(stuck.activeRunId).toBeNull();
|
||||
|
||||
// Re-initialise: reconciliation should run.
|
||||
db.close();
|
||||
db = new AppDatabase(tempDir);
|
||||
db.init();
|
||||
|
||||
expect(db.lastOrphanResetCount).toBe(1);
|
||||
const reclaimed = db.getTask(taskId);
|
||||
expect(reclaimed.status).toBe("todo");
|
||||
|
||||
// And a fresh checkout must succeed — proving the task is reclaimable.
|
||||
const { id: newRunId } = db.createRun({
|
||||
companyId,
|
||||
taskId,
|
||||
agentId,
|
||||
workspaceId,
|
||||
connectorId: "openai-codex",
|
||||
});
|
||||
expect(db.checkoutTask(taskId, newRunId)).toBe(true);
|
||||
});
|
||||
|
||||
it("startup reconciliation unsticks in_review tasks with no possible reviewer", () => {
|
||||
// Observed in production: a fresh `ai_ceo` company with only a CEO
|
||||
// self-assigned a large task and set status=in_review. The reviewer
|
||||
// would be the assignee's manager (walk up reports_to), but the CEO
|
||||
// has no manager. With `requires_user_review = 0` there is no human
|
||||
// fallback either. The heartbeat falls into a loop re-proposing
|
||||
// `approve_ceo_strategy` every cycle without ever hiring or moving
|
||||
// the task. Demoting to 'todo' breaks the loop.
|
||||
const companyId = createTestCompany(db);
|
||||
createTestConnector(db);
|
||||
const workspaceId = createTestWorkspace(db, companyId);
|
||||
// Top-level agent (CEO): reports_to stays NULL.
|
||||
const ceoId = createTestAgent(db, companyId, workspaceId, "CEO");
|
||||
const taskId = createTestTask(db, companyId, ceoId, workspaceId);
|
||||
// Manually wedge the task into the dead-letter shape.
|
||||
(db as unknown as RawDb).db
|
||||
.prepare("UPDATE tasks SET status = 'in_review', requires_user_review = 0 WHERE id = ?")
|
||||
.run(taskId);
|
||||
|
||||
expect(db.getTask(taskId).status).toBe("in_review");
|
||||
|
||||
// Re-init triggers reconciliation.
|
||||
db.close();
|
||||
db = new AppDatabase(tempDir);
|
||||
db.init();
|
||||
|
||||
expect(db.lastUnreviewableResetCount).toBe(1);
|
||||
expect(db.getTask(taskId).status).toBe("todo");
|
||||
});
|
||||
|
||||
it("in_review tasks requiring user review are left alone", () => {
|
||||
// Opposite guard: when requires_user_review = 1 the task is
|
||||
// intentionally parked waiting for a human — the reconciler must
|
||||
// not steal it.
|
||||
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 as unknown as RawDb).db
|
||||
.prepare("UPDATE tasks SET status = 'in_review', requires_user_review = 1 WHERE id = ?")
|
||||
.run(taskId);
|
||||
|
||||
db.close();
|
||||
db = new AppDatabase(tempDir);
|
||||
db.init();
|
||||
|
||||
expect(db.lastUnreviewableResetCount).toBe(0);
|
||||
expect(db.getTask(taskId).status).toBe("in_review");
|
||||
});
|
||||
|
||||
it("reconciliation does not fire when interruptStaleRuns already handled a running task", () => {
|
||||
// Opposite guard: tasks whose run was `running` at shutdown are
|
||||
// handled earlier in init by `interruptStaleRuns` — it marks the
|
||||
// run 'interrupted' and sets the task to 'blocked' for human
|
||||
// review. By the time reconciliation runs, the task is no longer
|
||||
// 'in_progress', so the cleanup's WHERE clause skips it. The user
|
||||
// sees the task surfaced in the recovery UI, not silently reset.
|
||||
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.createRun({
|
||||
companyId,
|
||||
taskId,
|
||||
agentId,
|
||||
workspaceId,
|
||||
connectorId: "openai-codex",
|
||||
});
|
||||
db.checkoutTask(taskId, liveRunId);
|
||||
db.markRunStatus(liveRunId, "running", "");
|
||||
|
||||
db.close();
|
||||
db = new AppDatabase(tempDir);
|
||||
db.init();
|
||||
|
||||
// Reconciliation counted 0 because interruptStaleRuns moved the
|
||||
// task to 'blocked' first. Both guards collaborate — neither
|
||||
// steals a task out from under an actively-working run.
|
||||
expect(db.lastOrphanResetCount).toBe(0);
|
||||
expect(db.getTask(taskId).status).toBe("blocked");
|
||||
});
|
||||
});
|
||||
@@ -206,11 +206,12 @@ describe("Edge Case: Session Rotation During Execution (D-05.3)", () => {
|
||||
});
|
||||
|
||||
const session = db.getActiveTaskSession(taskId)!;
|
||||
const handoff = sessionService.generateHandoff(taskId, session);
|
||||
// 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("Implement session rotation feature"); // task title
|
||||
expect(handoff).toContain("## Recent Run History");
|
||||
|
||||
// Archive the old session with handoff
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
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",
|
||||
command: "",
|
||||
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[];
|
||||
ceoAssignment?: { connectorId: string; modelId: string | null } | null;
|
||||
}): 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: () => [],
|
||||
getCeoAssignment: () =>
|
||||
options.ceoAssignment
|
||||
? {
|
||||
connectorId: options.ceoAssignment.connectorId as ConnectorId,
|
||||
modelId: options.ceoAssignment.modelId,
|
||||
}
|
||||
: null,
|
||||
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 assignment = pickModelForAgent(agent, deps);
|
||||
expect(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",
|
||||
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 assignment = pickModelForAgent(agent, deps);
|
||||
expect(assignment.modelId).toBe("claude-opus-4-7");
|
||||
});
|
||||
|
||||
it("CEO is identified by reportsTo === null even when roleLevel stays at the schema default 'ic'", () => {
|
||||
// Regression test for the v39 migration gap: CEO agents created before
|
||||
// the explicit role_level hookup have roleLevel = 'ic' in the DB. The
|
||||
// dispatcher must still recognise them via the DB invariant used by
|
||||
// getCeoForCompany: reports_to IS NULL.
|
||||
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 assignment = pickModelForAgent(agent, deps);
|
||||
expect(assignment.modelId).toBe("claude-opus-4-7");
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
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 { resolveWorkspaceWithLockMode } from "../../src/main/orchestration/resolve-workspace";
|
||||
|
||||
/**
|
||||
* Regression tests for the workspace lock policy introduced in v0.4.168+:
|
||||
* only `task.workspaceId` grants exclusive access. Agent-, project-, and
|
||||
* company-scoped resolutions are all `shared` so teammates can run in
|
||||
* parallel instead of queuing on a single workspace lock.
|
||||
*
|
||||
* See `resolve-workspace.ts` for the full rationale.
|
||||
*/
|
||||
|
||||
function seedCompany(db: AppDatabase) {
|
||||
const companyId = db.saveCompany({
|
||||
name: "Parallel Co",
|
||||
description: "Regression fixture",
|
||||
status: "active",
|
||||
});
|
||||
db.saveConnector({
|
||||
id: "openai-codex",
|
||||
command: "codex",
|
||||
model: "gpt-5",
|
||||
envBindingText: "",
|
||||
notes: "fixture",
|
||||
});
|
||||
const workspaceId = db.saveWorkspace({
|
||||
companyId,
|
||||
name: "Default",
|
||||
localPath: "/tmp/fixture",
|
||||
repoUrl: "https://github.com/example/fixture",
|
||||
repoRef: "main",
|
||||
isPrimary: true,
|
||||
});
|
||||
return { companyId, workspaceId };
|
||||
}
|
||||
|
||||
describe("resolveWorkspaceWithLockMode", () => {
|
||||
let tempDir: string;
|
||||
let db: AppDatabase;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "resolve-workspace-"));
|
||||
db = new AppDatabase(tempDir);
|
||||
db.init();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
db.close();
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("task-bound workspace → exclusive (explicit opt-in)", () => {
|
||||
const { companyId, workspaceId } = seedCompany(db);
|
||||
const result = resolveWorkspaceWithLockMode(
|
||||
db,
|
||||
{ workspaceId, projectId: null },
|
||||
{ workspaceId: null },
|
||||
companyId,
|
||||
);
|
||||
expect(result.source).toBe("task");
|
||||
expect(result.lockMode).toBe("exclusive");
|
||||
expect(result.workspace?.id).toBe(workspaceId);
|
||||
});
|
||||
|
||||
it("agent-bound workspace → shared (normal case: teammates share a repo)", () => {
|
||||
const { companyId, workspaceId } = seedCompany(db);
|
||||
const result = resolveWorkspaceWithLockMode(
|
||||
db,
|
||||
null,
|
||||
{ workspaceId },
|
||||
companyId,
|
||||
);
|
||||
expect(result.source).toBe("agent");
|
||||
expect(result.lockMode).toBe("shared");
|
||||
});
|
||||
|
||||
it("project-bound workspace → shared (team collaboration space)", () => {
|
||||
const { companyId } = seedCompany(db);
|
||||
const projectId = db.saveProject({
|
||||
companyId,
|
||||
name: "Platform",
|
||||
description: "",
|
||||
status: "active",
|
||||
});
|
||||
// Create a second workspace bound to the project so the resolver's
|
||||
// project-primary branch actually fires (the default seeded workspace
|
||||
// has no project_id set).
|
||||
db.saveWorkspace({
|
||||
companyId,
|
||||
name: "Project repo",
|
||||
localPath: "/tmp/project-ws",
|
||||
repoUrl: "https://github.com/example/project",
|
||||
repoRef: "main",
|
||||
projectId,
|
||||
isPrimary: true,
|
||||
});
|
||||
|
||||
const result = resolveWorkspaceWithLockMode(
|
||||
db,
|
||||
{ workspaceId: null, projectId },
|
||||
{ workspaceId: null },
|
||||
companyId,
|
||||
);
|
||||
expect(result.source).toBe("project");
|
||||
expect(result.lockMode).toBe("shared");
|
||||
});
|
||||
|
||||
it("company-fallback → shared (unchanged legacy behavior)", () => {
|
||||
const { companyId } = seedCompany(db);
|
||||
const result = resolveWorkspaceWithLockMode(
|
||||
db,
|
||||
null,
|
||||
{ workspaceId: null },
|
||||
companyId,
|
||||
);
|
||||
expect(result.source).toBe("company_fallback");
|
||||
expect(result.lockMode).toBe("shared");
|
||||
});
|
||||
|
||||
it("task workspace wins over agent workspace even when both are set", () => {
|
||||
const { companyId, workspaceId: agentWsId } = seedCompany(db);
|
||||
const taskWsId = db.saveWorkspace({
|
||||
companyId,
|
||||
name: "Task-only",
|
||||
localPath: "/tmp/task-ws",
|
||||
repoUrl: "https://github.com/example/task",
|
||||
repoRef: "main",
|
||||
isPrimary: false,
|
||||
});
|
||||
const result = resolveWorkspaceWithLockMode(
|
||||
db,
|
||||
{ workspaceId: taskWsId, projectId: null },
|
||||
{ workspaceId: agentWsId },
|
||||
companyId,
|
||||
);
|
||||
expect(result.source).toBe("task");
|
||||
expect(result.lockMode).toBe("exclusive");
|
||||
expect(result.workspace?.id).toBe(taskWsId);
|
||||
});
|
||||
|
||||
it("no workspace at all → none", () => {
|
||||
const companyId = db.saveCompany({
|
||||
name: "Empty Co",
|
||||
description: "",
|
||||
status: "active",
|
||||
});
|
||||
const result = resolveWorkspaceWithLockMode(
|
||||
db,
|
||||
null,
|
||||
{ workspaceId: null },
|
||||
companyId,
|
||||
);
|
||||
expect(result.source).toBe("none");
|
||||
expect(result.lockMode).toBe("none");
|
||||
expect(result.workspace).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -577,12 +577,16 @@ describe("session management service", () => {
|
||||
|
||||
const session = db.getActiveTaskSession(taskId)!;
|
||||
const svc = createSessionManagementService({ db, logger: mockLogger });
|
||||
const handoff = svc.generateHandoff(taskId, session);
|
||||
// Handoff is now agent-scoped: it walks the agent's recent runs
|
||||
// (across tasks + heartbeats) rather than a single task. The task
|
||||
// title is no longer in the handoff header because the session
|
||||
// does not belong to any one task anymore.
|
||||
const handoff = svc.generateHandoff(session);
|
||||
|
||||
expect(handoff).toContain("# Session Handoff");
|
||||
expect(handoff).toContain("Implement session rotation"); // task title
|
||||
expect(handoff).toContain("## Recent Run History");
|
||||
expect(handoff).toContain("succeeded");
|
||||
expect(handoff).toContain("Implemented initial logic"); // run summary
|
||||
expect(handoff).toContain("15000 total tokens"); // 10000 + 5000
|
||||
});
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user