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:
parent
fae5abceb8
commit
48b2c2fe58
5 changed files with 858 additions and 0 deletions
281
php/Console/Commands/AgenticDispatchQueueCommand.php
Normal file
281
php/Console/Commands/AgenticDispatchQueueCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
214
php/Console/Commands/AgenticSyncProfilesCommand.php
Normal file
214
php/Console/Commands/AgenticSyncProfilesCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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", [
|
||||
|
|
|
|||
215
php/tests/Feature/Console/AgenticDispatchQueueCommandTest.php
Normal file
215
php/tests/Feature/Console/AgenticDispatchQueueCommandTest.php
Normal 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);
|
||||
});
|
||||
130
php/tests/Feature/Console/AgenticSyncProfilesCommandTest.php
Normal file
130
php/tests/Feature/Console/AgenticSyncProfilesCommandTest.php
Normal 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',
|
||||
]);
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue