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>
This commit is contained in:
Snider 2026-04-26 01:08:35 +01:00
parent fae5abceb8
commit 48b2c2fe58
5 changed files with 858 additions and 0 deletions

View file

@ -0,0 +1,281 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
namespace Core\Mod\Agentic\Console\Commands;
use Core\Mod\Agentic\Jobs\DispatchMantisTicketJob;
use Core\Mod\Agentic\Services\MantisClient;
use Core\Mod\Agentic\Services\ProfileSelector;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
use Throwable;
class AgenticDispatchQueueCommand extends Command
{
private const IN_FLIGHT_MARKER_PREFIX = 'Picked up by agentic dispatch queue';
private const IN_FLIGHT_WINDOW_MINUTES = 5;
protected $signature = 'agentic:dispatch-queue
{--limit=3 : Maximum Mantis tickets to queue}';
protected $description = 'Queue open Mantis tickets for agentic dispatch';
public function handle(MantisClient $mantisClient, ProfileSelector $profileSelector): int
{
$limit = (int) $this->option('limit');
if ($limit < 1) {
$this->error('--limit must be greater than zero.');
return self::FAILURE;
}
$tickets = $mantisClient->listOpen();
if ($tickets === []) {
$this->info('No open Mantis tickets awaiting dispatch.');
Log::info('agentic:dispatch-queue found no open tickets', [
'limit' => $limit,
]);
return self::SUCCESS;
}
Log::info('agentic:dispatch-queue starting', [
'limit' => $limit,
'open_tickets' => count($tickets),
]);
$queued = 0;
$skippedAssigned = 0;
$skippedSuppressed = 0;
$skippedUnavailable = 0;
$failed = 0;
foreach ($tickets as $ticket) {
if ($queued >= $limit) {
break;
}
$ticketId = $this->ticketId($ticket);
if ($ticketId === null) {
$failed++;
Log::warning('agentic:dispatch-queue skipped ticket with invalid identifier', [
'ticket' => $ticket,
]);
continue;
}
if (! $this->isUnassigned($ticket)) {
$skippedAssigned++;
Log::info('agentic:dispatch-queue skipped assigned ticket', [
'ticket_id' => $ticketId,
]);
continue;
}
if ($this->hasRecentDispatchMarker($ticket)) {
$skippedSuppressed++;
Log::info('agentic:dispatch-queue skipped in-flight ticket', [
'ticket_id' => $ticketId,
]);
continue;
}
$profile = $profileSelector->pickFor($ticket);
if ($profile === null) {
$skippedUnavailable++;
Log::info('agentic:dispatch-queue found no eligible profile for ticket', [
'ticket_id' => $ticketId,
]);
continue;
}
try {
$mantisClient->note($ticketId, $this->inFlightNote($profile->name));
DispatchMantisTicketJob::dispatch($ticketId);
$queued++;
$this->line("Queued Mantis ticket #{$ticketId} with profile {$profile->name}.");
Log::info('agentic:dispatch-queue queued ticket', [
'ticket_id' => $ticketId,
'profile_id' => $profile->id,
'profile_name' => $profile->name,
]);
} catch (Throwable $throwable) {
$failed++;
$this->warn("Failed to queue Mantis ticket #{$ticketId}: {$throwable->getMessage()}");
Log::warning('agentic:dispatch-queue failed to queue ticket', [
'ticket_id' => $ticketId,
'profile_id' => $profile->id,
'profile_name' => $profile->name,
'error' => $throwable->getMessage(),
]);
}
}
$this->info(
"Queued {$queued} Mantis ticket(s); "
."skipped {$skippedAssigned} assigned, {$skippedSuppressed} in-flight, "
."{$skippedUnavailable} without matching profile; {$failed} failed.",
);
Log::info('agentic:dispatch-queue completed', [
'queued' => $queued,
'skipped_assigned' => $skippedAssigned,
'skipped_suppressed' => $skippedSuppressed,
'skipped_unavailable' => $skippedUnavailable,
'failed' => $failed,
]);
return $failed === 0 ? self::SUCCESS : self::FAILURE;
}
/**
* @param array<string, mixed> $ticket
*/
private function ticketId(array $ticket): ?int
{
$ticketId = data_get($ticket, 'id');
if (is_int($ticketId)) {
return $ticketId;
}
if (is_string($ticketId) && ctype_digit($ticketId)) {
return (int) $ticketId;
}
return null;
}
/**
* @param array<string, mixed> $ticket
*/
private function isUnassigned(array $ticket): bool
{
foreach ([
data_get($ticket, 'handler.id'),
data_get($ticket, 'handler.name'),
data_get($ticket, 'handler.username'),
data_get($ticket, 'assigned_to.id'),
data_get($ticket, 'assigned_to.name'),
data_get($ticket, 'assigned_to.username'),
] as $candidate) {
if (is_scalar($candidate) && trim((string) $candidate) !== '') {
return false;
}
}
return blank(data_get($ticket, 'handler')) && blank(data_get($ticket, 'assigned_to'));
}
/**
* @param array<string, mixed> $ticket
*/
private function hasRecentDispatchMarker(array $ticket): bool
{
$notes = data_get($ticket, 'notes');
if (! is_array($notes)) {
return false;
}
$threshold = now()->subMinutes(self::IN_FLIGHT_WINDOW_MINUTES);
foreach ($notes as $note) {
$noteText = $this->noteText($note);
$createdAt = $this->noteTimestamp($note);
if ($noteText === null || $createdAt === null) {
continue;
}
if (str_contains($noteText, self::IN_FLIGHT_MARKER_PREFIX) && $createdAt->greaterThanOrEqualTo($threshold)) {
return true;
}
}
return false;
}
private function inFlightNote(string $profileName): string
{
return self::IN_FLIGHT_MARKER_PREFIX
." (profile: {$profileName}). Suppressing concurrent dispatch for 5min.";
}
private function noteText(mixed $note): ?string
{
$text = data_get($note, 'text');
if (! is_string($text)) {
return null;
}
$text = trim($text);
return $text === '' ? null : $text;
}
private function noteTimestamp(mixed $note): ?Carbon
{
foreach (['created_at', 'date_submitted', 'updated_at', 'last_modified'] as $key) {
$timestamp = $this->parseTimestamp(data_get($note, $key));
if ($timestamp instanceof Carbon) {
return $timestamp;
}
}
return null;
}
private function parseTimestamp(mixed $value): ?Carbon
{
if (is_int($value) || (is_string($value) && ctype_digit($value))) {
return Carbon::createFromTimestamp((int) $value);
}
if (is_string($value) && trim($value) !== '') {
try {
return Carbon::parse($value);
} catch (Throwable) {
return null;
}
}
foreach (['date', 'value', 'timestamp'] as $key) {
$nestedValue = data_get($value, $key);
if ($nestedValue === null) {
continue;
}
$timestamp = $this->parseTimestamp($nestedValue);
if ($timestamp instanceof Carbon) {
return $timestamp;
}
}
return null;
}
}

View file

@ -0,0 +1,214 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
namespace Core\Mod\Agentic\Console\Commands;
use Core\Mod\Agentic\Models\AgentProfile;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use RuntimeException;
use Throwable;
class AgenticSyncProfilesCommand extends Command
{
protected $signature = 'agentic:sync-profiles';
protected $description = 'Synchronise agent profile capability tags from gateway model catalogues';
public function handle(): int
{
$profiles = AgentProfile::query()
->orderBy('id')
->get();
if ($profiles->isEmpty()) {
$this->info('No agent profiles found to synchronise.');
Log::info('agentic:sync-profiles found no profiles');
return self::SUCCESS;
}
Log::info('agentic:sync-profiles starting', [
'profiles' => $profiles->count(),
]);
$synchronised = 0;
$failed = 0;
foreach ($profiles as $profile) {
try {
$modelIdentifiers = $this->fetchModelIdentifiers($profile);
$capabilityTags = $this->inferCapabilityTags($modelIdentifiers);
$profile->forceFill([
'capability_tags' => $capabilityTags,
])->save();
$synchronised++;
$this->line(
sprintf(
'Synchronised %s: %s',
$profile->name,
$capabilityTags === [] ? '[none]' : '['.implode(', ', $capabilityTags).']',
),
);
Log::info('agentic:sync-profiles synchronised profile', [
'profile_id' => $profile->id,
'profile_name' => $profile->name,
'model_count' => count($modelIdentifiers),
'capability_tags' => $capabilityTags,
]);
} catch (Throwable $throwable) {
$failed++;
$message = "Failed to synchronise {$profile->name}: {$throwable->getMessage()}";
$this->warn($message);
Log::warning('agentic:sync-profiles failed to synchronise profile', [
'profile_id' => $profile->id,
'profile_name' => $profile->name,
'error' => $throwable->getMessage(),
]);
}
}
$this->info("Synchronised {$synchronised} profile(s); {$failed} failed.");
Log::info('agentic:sync-profiles completed', [
'synchronised' => $synchronised,
'failed' => $failed,
]);
return $failed === 0 ? self::SUCCESS : self::FAILURE;
}
/**
* @return list<string>
*/
private function fetchModelIdentifiers(AgentProfile $profile): array
{
$response = Http::withToken((string) $profile->api_key_cipher)
->acceptJson()
->timeout(15)
->get($this->modelsUrl($profile));
if (! $response->successful()) {
throw new RuntimeException("Gateway models request failed: {$response->status()}");
}
$payload = $response->json();
$records = is_array($payload)
? ($payload['data'] ?? $payload['models'] ?? $payload)
: [];
if (! is_array($records)) {
return [];
}
$identifiers = [];
foreach ($records as $record) {
$identifier = $this->extractModelIdentifier($record);
if ($identifier === null) {
continue;
}
$identifiers[] = $identifier;
}
sort($identifiers, SORT_NATURAL | SORT_FLAG_CASE);
return array_values(array_unique($identifiers));
}
/**
* @return list<string>
*/
private function inferCapabilityTags(array $modelIdentifiers): array
{
$capabilityTags = [];
foreach ($modelIdentifiers as $modelIdentifier) {
foreach ($this->inferTagsForModel($modelIdentifier) as $tag) {
$capabilityTags[$tag] = $tag;
}
}
ksort($capabilityTags);
return array_values($capabilityTags);
}
/**
* @return list<string>
*/
private function inferTagsForModel(string $modelIdentifier): array
{
$tags = [];
$isSmallReasoner = (
(str_contains($modelIdentifier, 'gpt') || str_contains($modelIdentifier, 'o3') || str_contains($modelIdentifier, 'o4'))
&& (str_contains($modelIdentifier, 'mini') || str_contains($modelIdentifier, 'nano'))
);
if (str_contains($modelIdentifier, 'embedding')) {
$tags[] = 'embedding';
}
if (str_contains($modelIdentifier, 'claude-opus') || str_contains($modelIdentifier, 'opus')) {
array_push($tags, 'analysis', 'core', 'handoff');
} elseif (! $isSmallReasoner && (
str_contains($modelIdentifier, 'claude')
|| str_contains($modelIdentifier, 'sonnet')
|| str_contains($modelIdentifier, 'haiku')
|| str_contains($modelIdentifier, 'gpt')
|| str_contains($modelIdentifier, 'o3')
|| str_contains($modelIdentifier, 'o4')
)) {
$tags[] = 'analysis';
}
if ($isSmallReasoner) {
array_push($tags, 'cheap', 'dispatch');
}
return array_values(array_unique($tags));
}
private function modelsUrl(AgentProfile $profile): string
{
return rtrim($profile->gateway_url, '/').'/v1/models';
}
private function extractModelIdentifier(mixed $record): ?string
{
if (is_string($record)) {
return $this->normaliseModelIdentifier($record);
}
foreach (['id', 'name', 'model'] as $key) {
$value = data_get($record, $key);
if (! is_string($value)) {
continue;
}
return $this->normaliseModelIdentifier($value);
}
return null;
}
private function normaliseModelIdentifier(string $modelIdentifier): ?string
{
$normalised = strtolower(trim($modelIdentifier));
return $normalised === '' ? null : $normalised;
}
}

View file

@ -17,6 +17,24 @@ class MantisClient
private ?string $token = null,
) {}
/**
* @return list<array<string, mixed>>
*/
public function listOpen(): array
{
$response = $this->request()->get('/api/rest/issues', [
'status' => 'new',
]);
if (! $response->successful()) {
throw new RuntimeException("Mantis listOpen failed: {$response->status()}");
}
$issues = $response->json('issues');
return is_array($issues) ? array_values(array_filter($issues, 'is_array')) : [];
}
public function note(int $ticketId, string $text): void
{
$response = $this->request()->post("/api/rest/issues/{$ticketId}/notes", [

View file

@ -0,0 +1,215 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
use Core\Mod\Agentic\Console\Commands\AgenticDispatchQueueCommand;
use Core\Mod\Agentic\Jobs\DispatchMantisTicketJob;
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;
use Illuminate\Support\Facades\Queue;
beforeEach(function (): void {
if (! is_string(config('app.key')) || config('app.key') === '') {
config(['app.key' => 'base64:'.base64_encode(random_bytes(32))]);
}
config([
'agentic.mantis.base_url' => 'https://tasks.example.test',
'agentic.mantis.token' => 'mantis-token-123',
]);
Carbon::setTestNow('2026-04-26 12:00:00');
$this->app->make(Kernel::class)->registerCommand(
$this->app->make(AgenticDispatchQueueCommand::class),
);
});
afterEach(function (): void {
Carbon::setTestNow();
});
function dispatchQueueProfile(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('AgenticDispatchQueueCommand_handle_Good_queues_up_to_the_default_limit_and_marks_tickets_in_flight', function (): void {
Queue::fake();
dispatchQueueProfile([
'name' => 'core-profile',
'cost_class' => 'A',
'capability_tags' => ['core'],
]);
dispatchQueueProfile([
'name' => 'review-profile',
'cost_class' => 'B',
'capability_tags' => ['review'],
'quota_headroom_pct' => 50,
]);
dispatchQueueProfile([
'name' => 'general-profile',
'cost_class' => 'C',
'capability_tags' => ['dispatch'],
]);
Http::fake([
'https://tasks.example.test/api/rest/issues?status=new' => Http::response([
'issues' => [
[
'id' => 900,
'handler' => [
'id' => 7,
'name' => 'assigned-user',
],
'summary' => 'Already assigned ticket',
'tags' => ['core'],
],
[
'id' => 901,
'notes' => [
[
'text' => 'Picked up by agentic dispatch queue (profile: stale-profile). Suppressing concurrent dispatch for 5min.',
'created_at' => now()->subMinutes(2)->toIso8601String(),
],
],
'summary' => 'Recently claimed ticket',
'tags' => ['review'],
],
[
'id' => 902,
'severity' => 'critical',
'summary' => 'Core escalation',
'tags' => ['core'],
],
[
'id' => 903,
'severity' => 'major',
'summary' => 'Review queue item',
'tags' => ['review'],
],
[
'id' => 904,
'severity' => 'minor',
'summary' => 'General maintenance',
'tags' => [],
],
],
], 200),
'https://tasks.example.test/api/rest/issues/*/notes' => Http::response([], 201),
]);
$this->artisan('agentic:dispatch-queue')
->expectsOutputToContain('Queued 3 Mantis ticket(s); skipped 1 assigned, 1 in-flight, 0 without matching profile; 0 failed.')
->assertSuccessful();
Queue::assertPushedOn('ai', DispatchMantisTicketJob::class);
Queue::assertPushed(DispatchMantisTicketJob::class, 3);
Queue::assertPushed(DispatchMantisTicketJob::class, fn (DispatchMantisTicketJob $job): bool => $job->ticketId === 902);
Queue::assertPushed(DispatchMantisTicketJob::class, fn (DispatchMantisTicketJob $job): bool => $job->ticketId === 903);
Queue::assertPushed(DispatchMantisTicketJob::class, fn (DispatchMantisTicketJob $job): bool => $job->ticketId === 904);
Queue::assertNotPushed(DispatchMantisTicketJob::class, fn (DispatchMantisTicketJob $job): bool => in_array($job->ticketId, [900, 901], true));
Http::assertSent(fn (Request $request): bool => $request->method() === 'GET'
&& $request->url() === 'https://tasks.example.test/api/rest/issues?status=new'
&& $request->hasHeader('Authorization', 'mantis-token-123'));
Http::assertSent(fn (Request $request): bool => $request->method() === 'POST'
&& $request->url() === 'https://tasks.example.test/api/rest/issues/902/notes'
&& $request['text'] === 'Picked up by agentic dispatch queue (profile: core-profile). Suppressing concurrent dispatch for 5min.');
Http::assertSent(fn (Request $request): bool => $request->method() === 'POST'
&& $request->url() === 'https://tasks.example.test/api/rest/issues/903/notes'
&& $request['text'] === 'Picked up by agentic dispatch queue (profile: review-profile). Suppressing concurrent dispatch for 5min.');
Http::assertSent(fn (Request $request): bool => $request->method() === 'POST'
&& $request->url() === 'https://tasks.example.test/api/rest/issues/904/notes'
&& $request['text'] === 'Picked up by agentic dispatch queue (profile: general-profile). Suppressing concurrent dispatch for 5min.');
Http::assertSentCount(4);
});
test('AgenticDispatchQueueCommand_handle_Bad_rejects_non_positive_limits', function (): void {
Queue::fake();
Http::fake();
$this->artisan('agentic:dispatch-queue', ['--limit' => 0])
->expectsOutput('--limit must be greater than zero.')
->assertExitCode(Command::FAILURE);
Queue::assertNothingPushed();
Http::assertNothingSent();
});
test('AgenticDispatchQueueCommand_handle_Ugly_honours_a_custom_limit_after_skips', function (): void {
Queue::fake();
dispatchQueueProfile([
'name' => 'core-profile',
'cost_class' => 'A',
'capability_tags' => ['core'],
]);
dispatchQueueProfile([
'name' => 'general-profile',
'cost_class' => 'C',
'capability_tags' => ['dispatch'],
]);
Http::fake([
'https://tasks.example.test/api/rest/issues?status=new' => Http::response([
'issues' => [
[
'id' => 910,
'handler' => [
'id' => 3,
'name' => 'assigned-user',
],
'summary' => 'Assigned first',
'tags' => ['core'],
],
[
'id' => 911,
'severity' => 'critical',
'summary' => 'First queueable ticket',
'tags' => ['core'],
],
[
'id' => 912,
'severity' => 'minor',
'summary' => 'Would queue next if limit allowed',
'tags' => [],
],
],
], 200),
'https://tasks.example.test/api/rest/issues/*/notes' => Http::response([], 201),
]);
$this->artisan('agentic:dispatch-queue', ['--limit' => 1])
->expectsOutputToContain('Queued 1 Mantis ticket(s); skipped 1 assigned, 0 in-flight, 0 without matching profile; 0 failed.')
->assertSuccessful();
Queue::assertPushed(DispatchMantisTicketJob::class, 1);
Queue::assertPushed(DispatchMantisTicketJob::class, fn (DispatchMantisTicketJob $job): bool => $job->ticketId === 911);
Queue::assertNotPushed(DispatchMantisTicketJob::class, fn (DispatchMantisTicketJob $job): bool => $job->ticketId === 912);
Http::assertSentCount(2);
});

View file

@ -0,0 +1,130 @@
<?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',
]);
});