agent/php/tests/Unit/ProfileSelectorTest.php
Snider 060f5abb66 feat(agent/php): ProfileSelector + ShapeClassifier services per Phase 3 (#826)
ShapeClassifier::classify($ticket) returns 'A'|'B'|'C' per policy v1:
- (severity=critical OR priority=urgent) → A
- has tag in ['security','crypto','core'] → A
- (severity=major OR priority=high) → B
- everything else → C

ProfileSelector::pickFor($ticket) walks AgentProfile::active(), matches
capability tags case-insensitively against ticket.tags:
- Class A: cheapest matching profile (cost_class alphabetic order)
- Class B: any active profile with quota_headroom_pct >= 25
- Class C: deterministic round-robin via last_dispatched_at

Pest Unit tests cover Good (matching profile picked), Bad (no match → null),
Ugly (all profiles disabled → null), plus class A/B headroom gating + class C
round-robin determinism.

Codex note: php -l clean; pest skipped — no vendor/ at this repo root
(downstream lab/lthn.ai owns composer install).

Closes tasks.lthn.sh/view.php?id=826

Co-authored-by: Codex <noreply@openai.com>
2026-04-26 00:48:37 +01:00

194 lines
5.1 KiB
PHP

<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
use Core\Mod\Agentic\Models\AgentProfile;
use Core\Mod\Agentic\Services\ProfileSelector;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
uses(RefreshDatabase::class);
beforeEach(function (): void {
if (! is_string(config('app.key')) || config('app.key') === '') {
config(['app.key' => 'base64:'.base64_encode(random_bytes(32))]);
}
Carbon::setTestNow('2026-04-26 12:00:00');
});
afterEach(function (): void {
Carbon::setTestNow();
});
function seedAgentProfile(array $overrides = []): AgentProfile
{
static $sequence = 0;
$sequence++;
return AgentProfile::create(array_merge([
'name' => "profile-{$sequence}",
'gateway_url' => "https://gateway-{$sequence}.example.com",
'api_key_cipher' => "secret-{$sequence}",
'cost_class' => 'C',
'capability_tags' => ['dispatch'],
'quota_headroom_pct' => 100,
'enabled' => true,
'last_dispatched_at' => null,
], $overrides));
}
test('ProfileSelector_pickFor_Good_picks_the_cheapest_class_A_profile_with_matching_capability', function (): void {
$selector = new ProfileSelector;
$cheapestMatch = seedAgentProfile([
'name' => 'security-a',
'cost_class' => 'A',
'capability_tags' => ['security'],
]);
seedAgentProfile([
'name' => 'security-b',
'cost_class' => 'B',
'capability_tags' => ['security'],
]);
seedAgentProfile([
'name' => 'dispatch-a',
'cost_class' => 'A',
'capability_tags' => ['dispatch'],
]);
$picked = $selector->pickFor([
'id' => 826,
'severity' => 'critical',
'priority' => 'normal',
'project' => 'mantis',
'category' => 'security',
'summary' => 'Triage a critical security issue',
'tags' => ['security'],
]);
expect($picked?->id)->toBe($cheapestMatch->id)
->and($picked?->last_dispatched_at)->not->toBeNull();
});
test('ProfileSelector_pickFor_Good_requires_headroom_for_class_B_matches', function (): void {
$selector = new ProfileSelector;
seedAgentProfile([
'name' => 'review-low-headroom',
'capability_tags' => ['review'],
'quota_headroom_pct' => 20,
]);
$eligible = seedAgentProfile([
'name' => 'review-eligible',
'capability_tags' => ['review'],
'quota_headroom_pct' => 25,
]);
seedAgentProfile([
'name' => 'review-disabled',
'capability_tags' => ['review'],
'quota_headroom_pct' => 90,
'enabled' => false,
]);
$picked = $selector->pickFor([
'id' => 827,
'severity' => 'major',
'priority' => 'normal',
'project' => 'mantis',
'category' => 'review',
'summary' => 'High-impact review task',
'tags' => ['review'],
]);
expect($picked?->id)->toBe($eligible->id);
});
test('ProfileSelector_pickFor_Good_round_robins_class_C_profiles_deterministically', function (): void {
$selector = new ProfileSelector;
$first = seedAgentProfile(['name' => 'round-robin-1']);
$second = seedAgentProfile(['name' => 'round-robin-2']);
$third = seedAgentProfile(['name' => 'round-robin-3']);
$ticket = [
'id' => 828,
'severity' => 'minor',
'priority' => 'normal',
'project' => 'mantis',
'category' => 'general',
'summary' => 'General maintenance',
'tags' => [],
];
$pickedIds = [
$selector->pickFor($ticket)?->id,
$selector->pickFor($ticket)?->id,
$selector->pickFor($ticket)?->id,
$selector->pickFor($ticket)?->id,
];
expect($pickedIds)->toBe([
$first->id,
$second->id,
$third->id,
$first->id,
]);
});
test('ProfileSelector_pickFor_Bad_returns_null_when_no_matching_capability_exists', function (): void {
$selector = new ProfileSelector;
seedAgentProfile([
'name' => 'dispatch-only',
'cost_class' => 'A',
'capability_tags' => ['dispatch'],
]);
$picked = $selector->pickFor([
'id' => 829,
'severity' => 'critical',
'priority' => 'normal',
'project' => 'mantis',
'category' => 'crypto',
'summary' => 'Needs crypto specialist',
'tags' => ['crypto'],
]);
expect($picked)->toBeNull();
});
test('ProfileSelector_pickFor_Ugly_returns_null_when_all_profiles_are_disabled', function (): void {
$selector = new ProfileSelector;
seedAgentProfile([
'name' => 'disabled-one',
'enabled' => false,
'capability_tags' => ['dispatch'],
]);
seedAgentProfile([
'name' => 'disabled-two',
'enabled' => false,
'capability_tags' => ['review'],
]);
$picked = $selector->pickFor([
'id' => 830,
'severity' => 'minor',
'priority' => 'normal',
'project' => 'mantis',
'category' => 'general',
'summary' => 'No active profiles left',
'tags' => [],
]);
expect($picked)->toBeNull();
});