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.
333 lines
12 KiB
TypeScript
333 lines
12 KiB
TypeScript
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);
|
|
});
|
|
});
|