agent/php/tests/Feature/Console/AgenticSyncProfilesCommandTest.php
Snider 48b2c2fe58 feat(agent/php): artisan agentic:sync-profiles + agentic:dispatch-queue (#829)
agentic:sync-profiles iterates AgentProfile rows, calls GET {gateway}/v1/models
via Http::withToken, infers capability_tags from exposed model ids
(claude-opus → handoff/analysis/core; gpt-5.4-mini → dispatch/cheap;
embedding-* → embedding), leaves last_dispatched_at untouched.

agentic:dispatch-queue uses extended MantisClient->listOpen() (new
small wrapper), skips assigned tickets + 5min in-flight markers, runs
ProfileSelector::pickFor, adds suppression note via MantisClient->note,
queues DispatchMantisTicketJob up to --limit (default 3). Both
commands emit progress via Log.

Pest Feature tests use Http::fake + Queue::fake. Tests register the
new commands directly (Boot.php registration is a deferred follow-up
per #837 lane note).

Codex note: php -l clean; pest blocked by unrelated repo migration
infra (dedicated brain connection + SQLite-incompatible agent_sessions
rename).

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

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

130 lines
4.5 KiB
PHP

<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
use Core\Mod\Agentic\Console\Commands\AgenticSyncProfilesCommand;
use Core\Mod\Agentic\Models\AgentProfile;
use Illuminate\Console\Command;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Http;
beforeEach(function (): void {
if (! is_string(config('app.key')) || config('app.key') === '') {
config(['app.key' => 'base64:'.base64_encode(random_bytes(32))]);
}
$this->app->make(Kernel::class)->registerCommand(
$this->app->make(AgenticSyncProfilesCommand::class),
);
});
function syncProfilesProfile(array $attributes = []): AgentProfile
{
return AgentProfile::create(array_merge([
'name' => $attributes['name'] ?? 'dispatch-profile',
'gateway_url' => $attributes['gateway_url'] ?? 'https://gateway.example.test',
'api_key_cipher' => $attributes['api_key_cipher'] ?? 'plain-secret',
'cost_class' => 'C',
'capability_tags' => ['dispatch'],
'quota_headroom_pct' => 100,
'enabled' => true,
'last_dispatched_at' => null,
], $attributes));
}
test('AgenticSyncProfilesCommand_handle_Good_synchronises_gateway_models_without_touching_dispatch_usage', function (): void {
$lastDispatchedAt = Carbon::parse('2026-04-26 09:15:00');
$opusProfile = syncProfilesProfile([
'name' => 'claude-opus-profile',
'gateway_url' => 'https://gateway-opus.example.test',
'api_key_cipher' => 'opus-token',
'capability_tags' => ['stale'],
'last_dispatched_at' => $lastDispatchedAt,
]);
$miniProfile = syncProfilesProfile([
'name' => 'gpt-mini-profile',
'gateway_url' => 'https://gateway-mini.example.test',
'api_key_cipher' => 'mini-token',
'capability_tags' => ['old-tag'],
'enabled' => false,
]);
Http::fake([
'https://gateway-opus.example.test/v1/models' => Http::response([
'data' => [
['id' => 'claude-opus-4-1'],
['id' => 'embedding-3-large'],
],
], 200),
'https://gateway-mini.example.test/v1/models' => Http::response([
'data' => [
['id' => 'gpt-5.4-mini'],
],
], 200),
]);
$this->artisan('agentic:sync-profiles')
->expectsOutputToContain('Synchronised 2 profile(s); 0 failed.')
->assertSuccessful();
Http::assertSent(fn (Request $request): bool => $request->method() === 'GET'
&& $request->url() === 'https://gateway-opus.example.test/v1/models'
&& $request->hasHeader('Authorization', 'Bearer opus-token'));
Http::assertSent(fn (Request $request): bool => $request->method() === 'GET'
&& $request->url() === 'https://gateway-mini.example.test/v1/models'
&& $request->hasHeader('Authorization', 'Bearer mini-token'));
expect($opusProfile->fresh()->capability_tags)->toBe([
'analysis',
'core',
'embedding',
'handoff',
])->and($opusProfile->fresh()->last_dispatched_at?->equalTo($lastDispatchedAt))->toBeTrue()
->and($miniProfile->fresh()->capability_tags)->toBe([
'cheap',
'dispatch',
]);
});
test('AgenticSyncProfilesCommand_handle_Bad_continues_past_gateway_failures_and_returns_failure', function (): void {
$healthyProfile = syncProfilesProfile([
'name' => 'healthy-profile',
'gateway_url' => 'https://gateway-healthy.example.test',
'api_key_cipher' => 'healthy-token',
]);
$brokenProfile = syncProfilesProfile([
'name' => 'broken-profile',
'gateway_url' => 'https://gateway-broken.example.test',
'api_key_cipher' => 'broken-token',
'capability_tags' => ['keep-me'],
]);
Http::fake([
'https://gateway-healthy.example.test/v1/models' => Http::response([
'data' => [
['id' => 'gpt-5.4-mini'],
],
], 200),
'https://gateway-broken.example.test/v1/models' => Http::response([], 500),
]);
$this->artisan('agentic:sync-profiles')
->expectsOutputToContain('Failed to synchronise broken-profile: Gateway models request failed: 500')
->expectsOutputToContain('Synchronised 1 profile(s); 1 failed.')
->assertExitCode(Command::FAILURE);
expect($healthyProfile->fresh()->capability_tags)->toBe([
'cheap',
'dispatch',
])->and($brokenProfile->fresh()->capability_tags)->toBe([
'keep-me',
]);
});