diff --git a/test/unit/AgentActionValidator.test.ts b/test/unit/AgentActionValidator.test.ts new file mode 100644 index 00000000..37479521 --- /dev/null +++ b/test/unit/AgentActionValidator.test.ts @@ -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 { + 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', + ]); + }); +}); diff --git a/test/unit/AgentRecommender.test.ts b/test/unit/AgentRecommender.test.ts new file mode 100644 index 00000000..cd9d0a92 --- /dev/null +++ b/test/unit/AgentRecommender.test.ts @@ -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 { + 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 { + 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>({ + 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>({ + kind: 'ready', + targetId: 'task:AGT-006', + allowed: true, + dryRunSummary: 'Move the quest into READY and record the readiness ceremony metadata.', + }); + expect(candidates[1]).toMatchObject>({ + kind: 'packet', + targetId: 'task:AGT-006', + allowed: true, + dryRunSummary: 'Create or link a story, requirement, and criterion chain for this quest.', + }); + }); +});