feat: implement orchestration and assignment (007) — routing, strategies, history
Phase 7 of the roadmap. On a ticket's automatic transition to HUMAN_ESCALATION, routing resolves 006's hierarchy nodes and capability-eligibility lookup directly (never a second matching algorithm), and a pluggable strategy (ROUND_ROBIN, concurrency-safe via atomic Redis INCR; LEAST_LOADED; SKILL_BASED) selects exactly one eligible agent, persisted as a version-row-per-period Assignment plus an append-only AssignmentHistory event log. MANUAL/DIRECT are never auto-selected — only an explicit admin-supplied agentId reaches them. On success the ticket moves HUMAN_ESCALATION -> IN_PROGRESS through 003's existing state machine. A ticket's "required skill" comes from its most recent AI diagnosis's problemType (005) when one exists, unioned with any matching hierarchy node's skills (006); when neither exists, there's no skill constraint (every active agent eligible), never zero. Found and fixed two real, latent bugs in the shared event-bus infrastructure while building this feature's own tests: (1) EventBus.publish was built on EventEmitter.emit(), which never awaits async listeners, so a caller had no guarantee any subscriber (005's AI-session-ending hook, now also this feature's orchestration hook) had actually finished — rewritten to track subscribers directly and await them via Promise.all, same per-handler error isolation as before. (2) registerDomainEventHandlers() was only called from server.ts's production startup path, never from buildApp() — meaning every integration test in this codebase had zero domain-event subscribers registered at all. Now called (idempotently) from buildApp() itself, since domain-event wiring is synchronous application behavior, not a background-worker concern like the queue. Adds 8 unit tests (each strategy's pure selection/tie-break logic), a dedicated round-robin concurrency test verifying no two concurrent selections collide under real parallel load, and 2 integration test files covering all five user stories. Full regression (every pre-existing 002-006 integration test plus every new 007 test) run together against real Postgres/Redis/MinIO: 124 passed, 9 skipped (005's AI-key-gated tests, unrelated), 0 failed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
d3d57b9954
commit
77928c4878
@@ -0,0 +1,79 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { redisClient } from '@/infrastructure/cache';
|
||||
import {
|
||||
selectViaRoundRobin,
|
||||
roundRobinSelectIndex,
|
||||
} from '@/modules/orchestration/assignments/strategies/round-robin.strategy';
|
||||
|
||||
/**
|
||||
* Constitution Principle VII / FR-006 / SC-002: round robin must be concurrency-safe under
|
||||
* genuinely concurrent selection attempts, not just sequential calls — this is the first
|
||||
* dedicated multi-writer-race test in this codebase since 003-ticketing's optimistic
|
||||
* ticket-status concurrency (research.md).
|
||||
*/
|
||||
describe('ROUND_ROBIN concurrency safety', () => {
|
||||
function fakeAgent(id: string) {
|
||||
return {
|
||||
id,
|
||||
teamId: 't',
|
||||
name: id,
|
||||
active: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
skills: [],
|
||||
};
|
||||
}
|
||||
|
||||
it('never selects the same index twice for concurrent calls against the same key, and the final counter matches the call count', async () => {
|
||||
const nodeId = `concurrency-test-node-${Date.now()}`;
|
||||
const key = `ticketing:round_robin:${nodeId}`;
|
||||
await redisClient.del(key);
|
||||
|
||||
const eligible = [
|
||||
fakeAgent('a'),
|
||||
fakeAgent('b'),
|
||||
fakeAgent('c'),
|
||||
fakeAgent('d'),
|
||||
fakeAgent('e'),
|
||||
];
|
||||
const callCount = 25;
|
||||
|
||||
const results = await Promise.all(
|
||||
Array.from({ length: callCount }, () =>
|
||||
selectViaRoundRobin(eligible, { hierarchyNodeId: nodeId, requiredSkills: [] }),
|
||||
),
|
||||
);
|
||||
|
||||
// Every call must have selected somebody — never null for a non-empty eligible set.
|
||||
expect(results.every((r) => r !== null)).toBe(true);
|
||||
|
||||
// Across every complete cycle through the 5 agents, each agent must be selected exactly
|
||||
// callCount/5 times — if two concurrent INCRs had ever collided (both reading the same
|
||||
// pre-increment value), some agent would be over- or under-selected relative to this exact
|
||||
// count, since callCount (25) is a clean multiple of the eligible set size (5).
|
||||
const counts = new Map<string, number>();
|
||||
for (const r of results) {
|
||||
if (!r) continue;
|
||||
counts.set(r.id, (counts.get(r.id) ?? 0) + 1);
|
||||
}
|
||||
for (const agent of eligible) {
|
||||
expect(counts.get(agent.id)).toBe(callCount / eligible.length);
|
||||
}
|
||||
|
||||
const finalCount = await redisClient.get(key);
|
||||
expect(Number(finalCount)).toBe(callCount);
|
||||
|
||||
await redisClient.del(key);
|
||||
});
|
||||
|
||||
it('the pure index function never repeats an index within one full cycle', () => {
|
||||
const length = 7;
|
||||
const seen = new Set<number>();
|
||||
for (let count = 1; count <= length; count++) {
|
||||
const index = roundRobinSelectIndex(count, length);
|
||||
expect(seen.has(index)).toBe(false);
|
||||
seen.add(index);
|
||||
}
|
||||
expect(seen.size).toBe(length);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,210 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { buildApp } from '@/app';
|
||||
import { prismaClient } from '@/infrastructure/database';
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import {
|
||||
encryptCredential,
|
||||
generateCredentialSecret,
|
||||
issueIntegrationToken,
|
||||
} from '@/modules/catalog/products';
|
||||
|
||||
/**
|
||||
* Covers specs/007-orchestration-assignment/quickstart.md Scenarios 1, 3, 4, 5 against a real
|
||||
* Postgres/Redis — one ticket's lifecycle through escalation, automatic assignment, history,
|
||||
* manual reassignment, and re-escalation.
|
||||
*/
|
||||
describe('Orchestration and assignment — full flow (User Stories 1, 3, 4, 5)', () => {
|
||||
let app: FastifyInstance;
|
||||
const externalProductId = `TEST_ORCH_PROD_${Date.now()}`;
|
||||
const skillTag = `orch_skill_${Date.now()}`;
|
||||
let productId: string;
|
||||
let teamId: string;
|
||||
let agentAId: string;
|
||||
let agentBId: string;
|
||||
let secret: string;
|
||||
let ticketId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildApp();
|
||||
|
||||
const product = await prismaClient.product.create({
|
||||
data: { externalProductId, name: 'Orchestration Test Product', status: 'active' },
|
||||
});
|
||||
productId = product.id;
|
||||
secret = generateCredentialSecret();
|
||||
await prismaClient.productIntegration.create({
|
||||
data: {
|
||||
productId,
|
||||
credentialRef: encryptCredential(secret),
|
||||
authMechanism: 'signed_token',
|
||||
allowedScope: { tenantIds: ['tenant-1'] },
|
||||
status: 'active',
|
||||
rateLimitPerMinute: 1000,
|
||||
rateLimitPerUserPerMinute: 1000,
|
||||
},
|
||||
});
|
||||
|
||||
const team = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin/teams',
|
||||
payload: { name: `Orch Team ${Date.now()}` },
|
||||
});
|
||||
teamId = team.json().data.id;
|
||||
|
||||
const agentA = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/admin/teams/${teamId}/agents`,
|
||||
payload: { name: 'Orch Agent A' },
|
||||
});
|
||||
agentAId = agentA.json().data.id;
|
||||
await app.inject({
|
||||
method: 'PUT',
|
||||
url: `/admin/agents/${agentAId}/skills/${skillTag}`,
|
||||
payload: { level: 3 },
|
||||
});
|
||||
|
||||
const agentB = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/admin/teams/${teamId}/agents`,
|
||||
payload: { name: 'Orch Agent B' },
|
||||
});
|
||||
agentBId = agentB.json().data.id;
|
||||
await app.inject({
|
||||
method: 'PUT',
|
||||
url: `/admin/agents/${agentBId}/skills/${skillTag}`,
|
||||
payload: { level: 3 },
|
||||
});
|
||||
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin/hierarchy-nodes',
|
||||
payload: {
|
||||
name: 'Orch Node',
|
||||
order: 0,
|
||||
productScope: [externalProductId],
|
||||
skills: [skillTag],
|
||||
assignmentStrategy: 'ROUND_ROBIN',
|
||||
},
|
||||
});
|
||||
|
||||
const token = issueIntegrationToken(secret, {
|
||||
externalProductId,
|
||||
tenantId: 'tenant-1',
|
||||
userId: 'user-1',
|
||||
});
|
||||
const created = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/support/requests',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {
|
||||
productId: externalProductId,
|
||||
tenantId: 'tenant-1',
|
||||
userId: 'user-1',
|
||||
source: 'test',
|
||||
problem: 'Needs a human, unrelated to AI diagnosis in this test.',
|
||||
},
|
||||
});
|
||||
ticketId = created.json().data.ticketId;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prismaClient.assignmentHistory.deleteMany({ where: { ticketId } });
|
||||
await prismaClient.assignment.deleteMany({ where: { ticketId } });
|
||||
await prismaClient.hierarchyNode.deleteMany({ where: { name: 'Orch Node' } });
|
||||
await prismaClient.agentSkill.deleteMany({ where: { agentId: { in: [agentAId, agentBId] } } });
|
||||
await prismaClient.agent.deleteMany({ where: { teamId } });
|
||||
await prismaClient.team.deleteMany({ where: { id: teamId } });
|
||||
await prismaClient.ticketMessage.deleteMany({ where: { ticketId } });
|
||||
await prismaClient.ticket.deleteMany({ where: { id: ticketId } });
|
||||
await prismaClient.problem.deleteMany({ where: { productId } });
|
||||
await prismaClient.productIntegration.deleteMany({ where: { productId } });
|
||||
await prismaClient.product.deleteMany({ where: { id: productId } });
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('Scenario 1: escalation triggers automatic resolution and assignment', async () => {
|
||||
const ticket = await prismaClient.ticket.findUniqueOrThrow({ where: { id: ticketId } });
|
||||
const escalate = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: `/tickets/${ticketId}/status`,
|
||||
payload: { status: 'HUMAN_ESCALATION', expectedVersion: ticket.version },
|
||||
});
|
||||
expect(escalate.statusCode).toBe(200);
|
||||
|
||||
// The event handler runs synchronously within the same process (in-memory EventEmitter),
|
||||
// so by the time inject() resolves, publish's listeners have already been invoked — no
|
||||
// polling needed, matching this codebase's existing event-bus behavior (005's own
|
||||
// handleTicketStatusChanged is exercised the same way).
|
||||
const current = await app.inject({ method: 'GET', url: `/tickets/${ticketId}/assignment` });
|
||||
expect(current.statusCode).toBe(200);
|
||||
expect([agentAId, agentBId]).toContain(current.json().data.agentId);
|
||||
expect(current.json().data.strategy).toBe('ROUND_ROBIN');
|
||||
|
||||
const updatedTicket = await prismaClient.ticket.findUniqueOrThrow({ where: { id: ticketId } });
|
||||
expect(updatedTicket.status).toBe('IN_PROGRESS');
|
||||
});
|
||||
|
||||
it('Scenario 4: a manual reassignment overrides the automatic one', async () => {
|
||||
const before = await app.inject({ method: 'GET', url: `/tickets/${ticketId}/assignment` });
|
||||
const originalAgentId = before.json().data.agentId;
|
||||
const otherAgentId = originalAgentId === agentAId ? agentBId : agentAId;
|
||||
|
||||
const manual = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/admin/tickets/${ticketId}/assignment`,
|
||||
payload: { agentId: otherAgentId, reason: 'Manual override for test' },
|
||||
});
|
||||
expect(manual.statusCode).toBe(200);
|
||||
expect(manual.json().data.agentId).toBe(otherAgentId);
|
||||
expect(manual.json().data.strategy).toBe('MANUAL');
|
||||
|
||||
const current = await app.inject({ method: 'GET', url: `/tickets/${ticketId}/assignment` });
|
||||
expect(current.json().data.agentId).toBe(otherAgentId);
|
||||
|
||||
const notFound = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/admin/tickets/${ticketId}/assignment`,
|
||||
payload: { agentId: 'nonexistent-agent-id' },
|
||||
});
|
||||
expect(notFound.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('Scenario 3: assignment history preserves every prior decision', async () => {
|
||||
const history = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/tickets/${ticketId}/assignment-history`,
|
||||
});
|
||||
expect(history.statusCode).toBe(200);
|
||||
const rows: { action: string; strategy: string }[] = history.json().data;
|
||||
expect(rows.length).toBeGreaterThanOrEqual(2); // the automatic assignment + the manual one
|
||||
expect(rows.some((r) => r.action === 'assigned')).toBe(true);
|
||||
expect(rows.some((r) => r.action === 'reassigned')).toBe(true);
|
||||
});
|
||||
|
||||
it('Scenario 5: re-escalation resolves a fresh eligible set and reassigns', async () => {
|
||||
const beforeReEscalate = await prismaClient.assignment.findFirst({
|
||||
where: { ticketId, isCurrent: true },
|
||||
});
|
||||
|
||||
const ticket = await prismaClient.ticket.findUniqueOrThrow({ where: { id: ticketId } });
|
||||
await app.inject({
|
||||
method: 'PATCH',
|
||||
url: `/tickets/${ticketId}/status`,
|
||||
payload: { status: 'HUMAN_ESCALATION', expectedVersion: ticket.version },
|
||||
});
|
||||
|
||||
const afterReEscalate = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/tickets/${ticketId}/assignment`,
|
||||
});
|
||||
expect(afterReEscalate.statusCode).toBe(200);
|
||||
|
||||
const history = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/tickets/${ticketId}/assignment-history`,
|
||||
});
|
||||
const rows: { agentId: string | null }[] = history.json().data;
|
||||
// The pre-re-escalation assignment must still be present in history, whatever the new one is.
|
||||
expect(rows.some((r) => r.agentId === beforeReEscalate?.agentId)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { buildApp } from '@/app';
|
||||
import { prismaClient } from '@/infrastructure/database';
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import {
|
||||
encryptCredential,
|
||||
generateCredentialSecret,
|
||||
issueIntegrationToken,
|
||||
} from '@/modules/catalog/products';
|
||||
|
||||
/** Covers specs/007-orchestration-assignment/quickstart.md Scenario 2 (LEAST_LOADED,
|
||||
* SKILL_BASED, and the empty-eligible-set outcome) against a real Postgres/Redis. */
|
||||
describe('Orchestration and assignment — strategies (User Story 2)', () => {
|
||||
let app: FastifyInstance;
|
||||
let secret: string;
|
||||
let teamId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildApp();
|
||||
const team = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin/teams',
|
||||
payload: { name: `Strategy Team ${Date.now()}` },
|
||||
});
|
||||
teamId = team.json().data.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
async function createProductAndEscalate(problem = 'Needs a human.') {
|
||||
const externalProductId = `TEST_STRAT_PROD_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
||||
const product = await prismaClient.product.create({
|
||||
data: { externalProductId, name: 'Strategy Test Product', status: 'active' },
|
||||
});
|
||||
secret = generateCredentialSecret();
|
||||
await prismaClient.productIntegration.create({
|
||||
data: {
|
||||
productId: product.id,
|
||||
credentialRef: encryptCredential(secret),
|
||||
authMechanism: 'signed_token',
|
||||
allowedScope: { tenantIds: ['tenant-1'] },
|
||||
status: 'active',
|
||||
rateLimitPerMinute: 1000,
|
||||
rateLimitPerUserPerMinute: 1000,
|
||||
},
|
||||
});
|
||||
const token = issueIntegrationToken(secret, {
|
||||
externalProductId,
|
||||
tenantId: 'tenant-1',
|
||||
userId: 'user-1',
|
||||
});
|
||||
const created = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/support/requests',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {
|
||||
productId: externalProductId,
|
||||
tenantId: 'tenant-1',
|
||||
userId: 'user-1',
|
||||
source: 'test',
|
||||
problem,
|
||||
},
|
||||
});
|
||||
return { externalProductId, productId: product.id, ticketId: created.json().data.ticketId };
|
||||
}
|
||||
|
||||
async function escalate(ticketId: string) {
|
||||
const ticket = await prismaClient.ticket.findUniqueOrThrow({ where: { id: ticketId } });
|
||||
return app.inject({
|
||||
method: 'PATCH',
|
||||
url: `/tickets/${ticketId}/status`,
|
||||
payload: { status: 'HUMAN_ESCALATION', expectedVersion: ticket.version },
|
||||
});
|
||||
}
|
||||
|
||||
it('LEAST_LOADED picks the eligible agent with the lowest current workload', async () => {
|
||||
const skillTag = `least_loaded_skill_${Date.now()}`;
|
||||
const agentLow = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/admin/teams/${teamId}/agents`,
|
||||
payload: { name: 'Low Load Agent' },
|
||||
});
|
||||
const agentHigh = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/admin/teams/${teamId}/agents`,
|
||||
payload: { name: 'High Load Agent' },
|
||||
});
|
||||
for (const id of [agentLow.json().data.id, agentHigh.json().data.id]) {
|
||||
await app.inject({
|
||||
method: 'PUT',
|
||||
url: `/admin/agents/${id}/skills/${skillTag}`,
|
||||
payload: { level: 1 },
|
||||
});
|
||||
}
|
||||
await app.inject({
|
||||
method: 'PUT',
|
||||
url: `/admin/agents/${agentLow.json().data.id}/availability`,
|
||||
payload: { status: 'available', workingHours: {}, currentLoad: 1 },
|
||||
});
|
||||
await app.inject({
|
||||
method: 'PUT',
|
||||
url: `/admin/agents/${agentHigh.json().data.id}/availability`,
|
||||
payload: { status: 'available', workingHours: {}, currentLoad: 9 },
|
||||
});
|
||||
|
||||
const { externalProductId, ticketId } = await createProductAndEscalate();
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin/hierarchy-nodes',
|
||||
payload: {
|
||||
name: `LL Node ${Date.now()}`,
|
||||
order: 0,
|
||||
productScope: [externalProductId],
|
||||
skills: [skillTag],
|
||||
assignmentStrategy: 'LEAST_LOADED',
|
||||
},
|
||||
});
|
||||
|
||||
await escalate(ticketId);
|
||||
const current = await app.inject({ method: 'GET', url: `/tickets/${ticketId}/assignment` });
|
||||
expect(current.json().data.agentId).toBe(agentLow.json().data.id);
|
||||
});
|
||||
|
||||
it('SKILL_BASED prefers the eligible agent with the higher proficiency level', async () => {
|
||||
const skillTag = `skill_based_skill_${Date.now()}`;
|
||||
const agentExpert = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/admin/teams/${teamId}/agents`,
|
||||
payload: { name: 'Expert Agent' },
|
||||
});
|
||||
const agentNovice = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/admin/teams/${teamId}/agents`,
|
||||
payload: { name: 'Novice Agent' },
|
||||
});
|
||||
await app.inject({
|
||||
method: 'PUT',
|
||||
url: `/admin/agents/${agentExpert.json().data.id}/skills/${skillTag}`,
|
||||
payload: { level: 9 },
|
||||
});
|
||||
await app.inject({
|
||||
method: 'PUT',
|
||||
url: `/admin/agents/${agentNovice.json().data.id}/skills/${skillTag}`,
|
||||
payload: { level: 1 },
|
||||
});
|
||||
|
||||
const { externalProductId, ticketId } = await createProductAndEscalate();
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin/hierarchy-nodes',
|
||||
payload: {
|
||||
name: `SB Node ${Date.now()}`,
|
||||
order: 0,
|
||||
productScope: [externalProductId],
|
||||
skills: [skillTag],
|
||||
assignmentStrategy: 'SKILL_BASED',
|
||||
},
|
||||
});
|
||||
|
||||
await escalate(ticketId);
|
||||
const current = await app.inject({ method: 'GET', url: `/tickets/${ticketId}/assignment` });
|
||||
expect(current.json().data.agentId).toBe(agentExpert.json().data.id);
|
||||
});
|
||||
|
||||
it('an empty eligible set assigns nobody, leaves the ticket escalated, and records the outcome', async () => {
|
||||
const skillTag = `nobody_has_this_skill_${Date.now()}`;
|
||||
const { externalProductId, ticketId } = await createProductAndEscalate();
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin/hierarchy-nodes',
|
||||
payload: {
|
||||
name: `Empty Node ${Date.now()}`,
|
||||
order: 0,
|
||||
productScope: [externalProductId],
|
||||
skills: [skillTag],
|
||||
assignmentStrategy: 'ROUND_ROBIN',
|
||||
},
|
||||
});
|
||||
|
||||
await escalate(ticketId);
|
||||
|
||||
const current = await app.inject({ method: 'GET', url: `/tickets/${ticketId}/assignment` });
|
||||
expect(current.statusCode).toBe(404);
|
||||
|
||||
const ticket = await prismaClient.ticket.findUniqueOrThrow({ where: { id: ticketId } });
|
||||
expect(ticket.status).toBe('HUMAN_ESCALATION');
|
||||
|
||||
const history = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/tickets/${ticketId}/assignment-history`,
|
||||
});
|
||||
const rows: { agentId: string | null; action: string }[] = history.json().data;
|
||||
expect(rows.some((r) => r.agentId === null && r.action === 'unassigned')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { roundRobinSelectIndex } from '@/modules/orchestration/assignments/strategies/round-robin.strategy';
|
||||
import {
|
||||
lowestLoadCandidates,
|
||||
AgentWithLoad,
|
||||
} from '@/modules/orchestration/assignments/calculators/workload.calculator';
|
||||
import {
|
||||
highestSkillScoreCandidates,
|
||||
withSkillScores,
|
||||
AgentWithSkillScore,
|
||||
} from '@/modules/orchestration/assignments/calculators/skill-match.calculator';
|
||||
|
||||
function fakeAgent(id: string) {
|
||||
return {
|
||||
id,
|
||||
teamId: 't',
|
||||
name: id,
|
||||
active: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
skills: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe('roundRobinSelectIndex', () => {
|
||||
it('cycles through indices 0..length-1 as the count increases', () => {
|
||||
expect(roundRobinSelectIndex(1, 3)).toBe(0);
|
||||
expect(roundRobinSelectIndex(2, 3)).toBe(1);
|
||||
expect(roundRobinSelectIndex(3, 3)).toBe(2);
|
||||
expect(roundRobinSelectIndex(4, 3)).toBe(0); // wraps back to the first agent
|
||||
});
|
||||
|
||||
it('never produces the same index for two different, sequential counter values within one cycle', () => {
|
||||
const seen = new Set<number>();
|
||||
for (let count = 1; count <= 5; count++) {
|
||||
seen.add(roundRobinSelectIndex(count, 5));
|
||||
}
|
||||
expect(seen.size).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('lowestLoadCandidates', () => {
|
||||
it('picks the single lowest-load agent when there is no tie', () => {
|
||||
const withLoads: AgentWithLoad[] = [
|
||||
{ agent: fakeAgent('a'), currentLoad: 5 },
|
||||
{ agent: fakeAgent('b'), currentLoad: 2 },
|
||||
{ agent: fakeAgent('c'), currentLoad: 8 },
|
||||
];
|
||||
expect(lowestLoadCandidates(withLoads).map((a) => a.id)).toEqual(['b']);
|
||||
});
|
||||
|
||||
it('returns every agent tied for the lowest load', () => {
|
||||
const withLoads: AgentWithLoad[] = [
|
||||
{ agent: fakeAgent('a'), currentLoad: 2 },
|
||||
{ agent: fakeAgent('b'), currentLoad: 2 },
|
||||
{ agent: fakeAgent('c'), currentLoad: 8 },
|
||||
];
|
||||
expect(
|
||||
lowestLoadCandidates(withLoads)
|
||||
.map((a) => a.id)
|
||||
.sort(),
|
||||
).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('returns an empty array for an empty input', () => {
|
||||
expect(lowestLoadCandidates([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('withSkillScores', () => {
|
||||
it('sums proficiency level only across the required skills, ignoring extras', () => {
|
||||
const eligible = [
|
||||
{
|
||||
...fakeAgent('a'),
|
||||
skills: [
|
||||
{ id: '1', agentId: 'a', skillTag: 'x', level: 3 },
|
||||
{ id: '2', agentId: 'a', skillTag: 'y', level: 10 }, // not required — ignored
|
||||
],
|
||||
},
|
||||
{
|
||||
...fakeAgent('b'),
|
||||
skills: [
|
||||
{ id: '3', agentId: 'b', skillTag: 'x', level: 1 },
|
||||
{ id: '4', agentId: 'b', skillTag: 'z', level: 1 },
|
||||
],
|
||||
},
|
||||
];
|
||||
const scored = withSkillScores(eligible, ['x', 'z']);
|
||||
expect(scored.find((s) => s.agent.id === 'a')?.totalLevel).toBe(3); // only x counts
|
||||
expect(scored.find((s) => s.agent.id === 'b')?.totalLevel).toBe(2); // x + z
|
||||
});
|
||||
});
|
||||
|
||||
describe('highestSkillScoreCandidates', () => {
|
||||
it('picks the single highest-scoring agent when there is no tie', () => {
|
||||
const scored: AgentWithSkillScore[] = [
|
||||
{ agent: fakeAgent('a'), totalLevel: 3 },
|
||||
{ agent: fakeAgent('b'), totalLevel: 7 },
|
||||
];
|
||||
expect(highestSkillScoreCandidates(scored).map((a) => a.id)).toEqual(['b']);
|
||||
});
|
||||
|
||||
it('returns every agent tied for the highest score', () => {
|
||||
const scored: AgentWithSkillScore[] = [
|
||||
{ agent: fakeAgent('a'), totalLevel: 5 },
|
||||
{ agent: fakeAgent('b'), totalLevel: 5 },
|
||||
{ agent: fakeAgent('c'), totalLevel: 1 },
|
||||
];
|
||||
expect(
|
||||
highestSkillScoreCandidates(scored)
|
||||
.map((a) => a.id)
|
||||
.sort(),
|
||||
).toEqual(['a', 'b']);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user