agent/php/tests/Feature/DispatchMantisTicketJobTest.php
Snider 82ffd420e0 feat(agent/php): DispatchMantisTicketJob + HermesClient + agent_dispatches table (#827)
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>
2026-04-26 00:52:37 +01:00

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();
});