Phase 3 lane: queueable Job that resolves a profile via ProfileSelector,
posts POST {gateway}/v1/responses to the chosen Hermes gateway, persists
ticket_id/profile_id/response_id/run_id/status in agent_dispatches, and
chains CaptureDispatchResultJob.
Migration 2026_04_25_000003 creates agent_dispatches table (FK-free,
Postgres-compatible).
HermesClient: thin Laravel HTTP wrapper around the Hermes /v1/responses
endpoint with Authorization header + JSON body.
DispatchMantisTicketJob behaviour:
- Resolves profile via ProfileSelector::pickFor()
- Null-profile → log warn + ->release(60) requeue
- Otherwise POSTs to gateway, persists AgentDispatch row, queues
CaptureDispatchResultJob
AgentDispatch Eloquent model with minimal $fillable.
Pest Feature test (Http::fake): verifies request shape, persisted row,
downstream capture-job queueing, and the no-profile requeue path.
Test file conditionally aliases minimal stubs for sibling-lane services
so this file remains runnable before #826/#828 fully land in dev.
Codex note: php -l clean; pest skipped (no vendor/).
Closes tasks.lthn.sh/view.php?id=827
Co-authored-by: Codex <noreply@openai.com>
180 lines
5.4 KiB
PHP
180 lines
5.4 KiB
PHP
<?php
|
|
|
|
// SPDX-License-Identifier: EUPL-1.2
|
|
|
|
declare(strict_types=1);
|
|
|
|
use Core\Mod\Agentic\Jobs\CaptureDispatchResultJob;
|
|
use Core\Mod\Agentic\Jobs\DispatchMantisTicketJob;
|
|
use Core\Mod\Agentic\Models\AgentDispatch;
|
|
use Core\Mod\Agentic\Models\AgentProfile;
|
|
use Core\Mod\Agentic\Services\HermesClient;
|
|
use Core\Mod\Agentic\Services\MantisClient;
|
|
use Core\Mod\Agentic\Services\ProfileSelector;
|
|
use Illuminate\Bus\Queueable;
|
|
use Illuminate\Contracts\Queue\Job as QueueJobContract;
|
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
|
use Illuminate\Foundation\Bus\Dispatchable;
|
|
use Illuminate\Queue\InteractsWithQueue;
|
|
use Illuminate\Queue\SerializesModels;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Facades\Queue;
|
|
|
|
if (! class_exists(CaptureDispatchResultJob::class)) {
|
|
class DispatchMantisTicketJobTestCaptureDispatchResultJob implements ShouldQueue
|
|
{
|
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
|
|
|
public function __construct(
|
|
public int $ticketId,
|
|
public string $responseId,
|
|
) {}
|
|
}
|
|
|
|
class_alias(
|
|
DispatchMantisTicketJobTestCaptureDispatchResultJob::class,
|
|
CaptureDispatchResultJob::class,
|
|
);
|
|
}
|
|
|
|
if (! class_exists(MantisClient::class)) {
|
|
class DispatchMantisTicketJobTestMantisClient {}
|
|
|
|
class_alias(
|
|
DispatchMantisTicketJobTestMantisClient::class,
|
|
MantisClient::class,
|
|
);
|
|
}
|
|
|
|
if (! class_exists(ProfileSelector::class)) {
|
|
class DispatchMantisTicketJobTestProfileSelector
|
|
{
|
|
public function pickFor(mixed $ticket): ?AgentProfile
|
|
{
|
|
return null;
|
|
}
|
|
}
|
|
|
|
class_alias(
|
|
DispatchMantisTicketJobTestProfileSelector::class,
|
|
ProfileSelector::class,
|
|
);
|
|
}
|
|
|
|
it('dispatches with the ticket identifier payload', function () {
|
|
Queue::fake();
|
|
|
|
DispatchMantisTicketJob::dispatch(827);
|
|
|
|
Queue::assertPushedOn('ai', DispatchMantisTicketJob::class);
|
|
Queue::assertPushed(DispatchMantisTicketJob::class, function ($queuedJob) {
|
|
return $queuedJob->ticketId === 827;
|
|
});
|
|
});
|
|
|
|
it('posts the prompt to Hermes, records the dispatch, and queues result capture', function () {
|
|
Queue::fake();
|
|
|
|
Http::fake([
|
|
'https://hermes.example.test/v1/responses' => Http::response([
|
|
'id' => 'resp_123',
|
|
'run_id' => 'run_456',
|
|
'status' => 'queued',
|
|
], 200),
|
|
]);
|
|
|
|
$profile = AgentProfile::query()->create([
|
|
'name' => 'Hermes Default',
|
|
'gateway_url' => 'https://hermes.example.test',
|
|
'api_key_cipher' => 'hermes-test-key',
|
|
'cost_class' => 'm',
|
|
'capability_tags' => ['mantis'],
|
|
'quota_headroom_pct' => 100,
|
|
'enabled' => true,
|
|
]);
|
|
|
|
$ticket = [
|
|
'body' => 'Investigate the failing regression test in the dispatch pipeline.',
|
|
];
|
|
|
|
$mantisClient = Mockery::mock(MantisClient::class);
|
|
$mantisClient->shouldReceive('get')
|
|
->once()
|
|
->with(827)
|
|
->andReturn($ticket);
|
|
|
|
$profileSelector = Mockery::mock(ProfileSelector::class);
|
|
$profileSelector->shouldReceive('pickFor')
|
|
->once()
|
|
->with($ticket)
|
|
->andReturn($profile);
|
|
|
|
$this->app->instance(MantisClient::class, $mantisClient);
|
|
$this->app->instance(ProfileSelector::class, $profileSelector);
|
|
|
|
$job = new DispatchMantisTicketJob(827);
|
|
|
|
$this->app->call([$job, 'handle'], [
|
|
'hermesClient' => new HermesClient,
|
|
]);
|
|
|
|
Http::assertSent(function ($request) {
|
|
return $request->method() === 'POST'
|
|
&& $request->url() === 'https://hermes.example.test/v1/responses'
|
|
&& $request->hasHeader('Authorization', 'Bearer hermes-test-key')
|
|
&& $request['model'] === 'default'
|
|
&& $request['input'] === 'Investigate the failing regression test in the dispatch pipeline.'
|
|
&& $request['metadata']['conversation'] === 'mantis-827'
|
|
&& $request['metadata']['ticket_id'] === 827;
|
|
});
|
|
|
|
$dispatch = AgentDispatch::query()->sole();
|
|
|
|
expect($dispatch->ticket_id)->toBe(827)
|
|
->and($dispatch->profile_id)->toBe($profile->id)
|
|
->and($dispatch->response_id)->toBe('resp_123')
|
|
->and($dispatch->run_id)->toBe('run_456')
|
|
->and($dispatch->status)->toBe('queued');
|
|
|
|
Queue::assertPushed(CaptureDispatchResultJob::class, 1);
|
|
});
|
|
|
|
it('logs and releases the job when no profile is available', function () {
|
|
Http::fake();
|
|
|
|
$ticket = [
|
|
'body' => 'Retry this ticket once a profile becomes available.',
|
|
];
|
|
|
|
$mantisClient = Mockery::mock(MantisClient::class);
|
|
$mantisClient->shouldReceive('get')
|
|
->once()
|
|
->with(912)
|
|
->andReturn($ticket);
|
|
|
|
$profileSelector = Mockery::mock(ProfileSelector::class);
|
|
$profileSelector->shouldReceive('pickFor')
|
|
->once()
|
|
->with($ticket)
|
|
->andReturn(null);
|
|
|
|
Log::shouldReceive('warning')
|
|
->once()
|
|
->with('DispatchMantisTicketJob: no agent profile available for Mantis ticket', [
|
|
'ticket_id' => 912,
|
|
]);
|
|
|
|
$queuedJob = Mockery::mock(QueueJobContract::class);
|
|
$queuedJob->shouldReceive('release')
|
|
->once()
|
|
->with(60);
|
|
|
|
$job = new DispatchMantisTicketJob(912);
|
|
$job->setJob($queuedJob);
|
|
$job->handle($mantisClient, new HermesClient, $profileSelector);
|
|
|
|
expect(AgentDispatch::query()->count())->toBe(0);
|
|
|
|
Http::assertNothingSent();
|
|
});
|