Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 125 additions & 0 deletions test/unit/AgentActionValidator.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { describe, expect, it, vi } from 'vitest';
import { AgentActionValidator, type AgentActionRequest } from '../../src/domain/services/AgentActionService.js';
import type { GraphPort } from '../../src/ports/GraphPort.js';
import type { RoadmapQueryPort } from '../../src/ports/RoadmapPort.js';
import type { Quest } from '../../src/domain/entities/Quest.js';

function makeGraphPort(existing: string[] = []): GraphPort {
const hasNode = vi.fn(async (id: string) => existing.includes(id));
const graph = {
worldline: () => ({
hasNode,
}),
};
return {
getGraph: vi.fn(async () => graph),
reset: vi.fn(),
};
}

function makeRoadmap(quest: Quest | null): RoadmapQueryPort {
return {
getQuests: vi.fn(),
getQuest: vi.fn(async (id: string) => (id === quest?.id ? quest : null)),
getOutgoingEdges: vi.fn(async () => quest
? [
{ from: quest.id, to: 'intent:AGENT-PROTOCOL', type: 'authorized-by' },
{ from: quest.id, to: 'campaign:AGENT', type: 'belongs-to' },
]
: []),
getIncomingEdges: vi.fn(async () => []),
};
}

function makeQuest(overrides?: Partial<Quest>): Quest {
return {
id: 'task:AGT-006',
title: 'Agent protocol',
status: 'READY',
hours: 3,
priority: 'P3',
taskKind: 'delivery',
description: 'Agent briefings and next-action recommendations.',
assignedTo: undefined,
campaignId: 'campaign:AGENT',
intentId: 'intent:AGENT-PROTOCOL',
dependsOn: [],
readyBy: undefined,
readyAt: undefined,
completedAt: undefined,
suggestedBy: undefined,
suggestedAt: undefined,
rejectedBy: undefined,
rejectedAt: undefined,
rejectionRationale: undefined,
reopenedBy: undefined,
reopenedAt: undefined,
...overrides,
} as Quest;
}

describe('AgentActionValidator', () => {
it('rejects human-only actions for agent principals', async () => {
const validator = new AgentActionValidator(
makeGraphPort(),
makeRoadmap(makeQuest()),
'agent.hal',
{ openSession: vi.fn() } as any,
);

const assessment = await validator.validate({
kind: 'intent',
targetId: 'task:AGT-006',
dryRun: true,
args: {},
});

expect(assessment.allowed).toBe(false);
expect(assessment.requiresHumanApproval).toBe(true);
expect(assessment.validation).toMatchObject({
valid: false,
code: 'human-only-action',
});
});

it('validates packet actions by auto-deriving traceability ids when absent', async () => {
const validator = new AgentActionValidator(
makeGraphPort([]),
makeRoadmap(makeQuest()),
'agent.hal',
{ openSession: vi.fn() } as any,
);

const request: AgentActionRequest = {
kind: 'packet',
targetId: 'task:AGT-006',
dryRun: true,
args: {
storyTitle: 'Agent protocol story',
persona: 'agent maintainer',
goal: 'recommend the next action',
benefit: 'so the agent can pick useful work',
requirementDescription: 'The agent protocol must recommend and validate actions.',
criterionDescription: 'Given a READY quest, the recommender offers a claim.',
},
};

const assessment = await validator.validate(request);

expect(assessment.allowed).toBe(true);
expect(assessment.normalizedArgs).toMatchObject({
storyId: 'story:AGT-006',
requirementId: 'req:AGT-006',
criterionId: 'criterion:AGT-006',
requirementKind: 'functional',
priority: 'must',
verifiable: true,
});
expect(assessment.sideEffects).toEqual([
'story -> create',
'requirement -> create',
'criterion -> create',
'align traceability edges',
]);
});
});
124 changes: 124 additions & 0 deletions test/unit/AgentRecommender.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { describe, expect, it, vi } from 'vitest';
import type { ReadinessAssessment } from '../../src/domain/services/ReadinessService.js';
import type { AgentActionCandidate, AgentDependencyContext } from '../../src/domain/services/AgentRecommender.js';
import { AgentRecommender } from '../../src/domain/services/AgentRecommender.js';
import type { AgentActionRequest, AgentActionValidator } from '../../src/domain/services/AgentActionService.js';
import type { QuestNode } from '../../src/domain/models/dashboard.js';

function makeQuest(overrides?: Partial<QuestNode>): QuestNode {
return {
id: 'task:AGT-006',
title: 'Agent protocol',
status: 'READY',
hours: 3,
priority: 'P3',
taskKind: 'delivery',
description: 'Agent briefings and next-action recommendations.',
assignedTo: undefined,
campaignId: 'campaign:AGENT',
intentId: 'intent:AGENT-PROTOCOL',
dependsOn: [],
readyBy: undefined,
readyAt: undefined,
completedAt: undefined,
suggestedBy: undefined,
suggestedAt: undefined,
rejectedBy: undefined,
rejectedAt: undefined,
rejectionRationale: undefined,
reopenedBy: undefined,
reopenedAt: undefined,
...overrides,
};
}

function makeValidator(): AgentActionValidator {
const validate = vi.fn(async (request: AgentActionRequest) => ({
kind: request.kind,
targetId: request.targetId,
allowed: true,
dryRun: request.dryRun ?? false,
requiresHumanApproval: false,
validation: { valid: true, code: null, reasons: [] },
normalizedArgs: request.args,
underlyingCommand: `xyph ${request.kind} ${request.targetId}`,
sideEffects: [],
}));
return {
validate,
} as unknown as AgentActionValidator;
}

function makeDependency(overrides?: Partial<AgentDependencyContext>): AgentDependencyContext {
return {
isExecutable: true,
isFrontier: true,
dependsOn: [],
dependents: [],
blockedBy: [],
topologicalIndex: 0,
transitiveDownstream: 0,
...overrides,
};
}

describe('AgentRecommender', () => {
it('recommends claim for a frontier READY quest', async () => {
const validator = makeValidator();
const recommender = new AgentRecommender(validator, 'agent.hal');

const candidates = await recommender.recommendForQuest(
makeQuest(),
null,
makeDependency(),
);

expect(candidates).toHaveLength(1);
expect(candidates[0]).toMatchObject<Partial<AgentActionCandidate>>({
kind: 'claim',
targetId: 'task:AGT-006',
allowed: true,
blockedBy: [],
underlyingCommand: 'xyph claim task:AGT-006',
dryRunSummary: 'Move the quest into IN_PROGRESS and assign it to the current agent.',
});
expect(vi.mocked(validator.validate)).toHaveBeenCalledWith({
kind: 'claim',
targetId: 'task:AGT-006',
dryRun: true,
args: {},
});
});

it('recommends ready and packet for a PLANNED quest with missing traceability', async () => {
const validator = makeValidator();
const recommender = new AgentRecommender(validator, 'agent.hal');
const readiness: ReadinessAssessment = {
valid: true,
questId: 'task:AGT-006',
unmet: [
{ code: 'missing-requirement', field: 'traceability', message: 'Need a requirement' },
],
};

const candidates = await recommender.recommendForQuest(
makeQuest({ status: 'PLANNED' }),
readiness,
makeDependency(),
);

expect(candidates.map((candidate) => candidate.kind)).toEqual(['ready', 'packet']);
expect(candidates[0]).toMatchObject<Partial<AgentActionCandidate>>({
kind: 'ready',
targetId: 'task:AGT-006',
allowed: true,
dryRunSummary: 'Move the quest into READY and record the readiness ceremony metadata.',
});
expect(candidates[1]).toMatchObject<Partial<AgentActionCandidate>>({
kind: 'packet',
targetId: 'task:AGT-006',
allowed: true,
dryRunSummary: 'Create or link a story, requirement, and criterion chain for this quest.',
});
});
});
Loading