agent/php/tests/Feature/Brain/CircuitBreakerTest.php
Snider 6832d40587 fix(agent/brain): batch — org maxLength + retry semantics + forget index cleanup
Codex 5.5 batch lane processed 8 brain Mantis tickets. 4 implemented,
1 stale, 3 deferred.

Tickets implemented:
- #313 — MCP schemas (BrainRemember/Recall/List): org field maxLength=128 with runtime validation; recall filter.org also bounded; pest test coverage added
- #314 — BrainList: removed withCircuitBreaker('brain') from DB-only handler; CircuitBreakerTest updated to assert no breaker call
- #315 — BrainService.retryableHttp(): now retries 408 (request-timeout), 429 (rate-limit), and 5xx; honours Retry-After header; focused retry tests added
- #326 — BrainService.forget(): dispatches DeleteFromIndex only when row has indexed_at (was unconditional); SupersedeForgetIndexCleanupTest covers never-indexed case

Tickets stale-fixed: #316 (RememberKnowledge already rejects missing/deleted supersedes target before dangling retry)
Tickets deferred: #121 (cross-surface audit), #311 (retry-inside-breaker architectural redesign), #312 (no authoritative org claim in MCP request context yet)

Co-authored-by: Codex <noreply@openai.com>
Closes tasks.lthn.sh/view.php?id=313
Closes tasks.lthn.sh/view.php?id=314
Closes tasks.lthn.sh/view.php?id=315
Closes tasks.lthn.sh/view.php?id=326
2026-04-25 14:55:40 +01:00

122 lines
3.8 KiB
PHP

<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
use Core\Mcp\Services\CircuitBreaker;
use Core\Mod\Agentic\Mcp\Tools\Agent\Brain\BrainList;
use Core\Mod\Agentic\Models\BrainMemory;
use Core\Mod\Agentic\Services\BrainService;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
function retryableBrainService(): BrainService
{
return new class extends BrainService
{
public array $sleepCalls = [];
protected function sleepMilliseconds(int $milliseconds): void
{
$this->sleepCalls[] = $milliseconds;
}
};
}
test('CircuitBreaker_brain_list_Good_executes_without_circuit_breaker_for_db_only_query', function (): void {
$workspace = createWorkspace();
$breaker = Mockery::mock(CircuitBreaker::class);
BrainMemory::create([
'workspace_id' => $workspace->id,
'agent_id' => 'virgil',
'type' => 'fact',
'content' => 'DB-backed list result.',
'confidence' => 0.8,
]);
$breaker->shouldNotReceive('call');
$this->app->instance(CircuitBreaker::class, $breaker);
$result = (new BrainList)->handle([], [
'workspace_id' => $workspace->id,
]);
expect($result['success'])->toBeTrue()
->and($result['count'])->toBe(1)
->and($result['memories'][0]['content'])->toBe('DB-backed list result.');
});
test('CircuitBreaker_retryable_http_Bad_retries_qdrant_requests_on_503', function (): void {
$brain = retryableBrainService();
Http::fake([
'http://localhost:6334/collections/openbrain/points' => Http::sequence()
->push(['error' => 'unavailable'], 503)
->push(['result' => ['status' => 'ok']], 200),
]);
$brain->qdrantUpsert([
['id' => 'memory-1', 'vector' => [0.1, 0.2], 'payload' => ['type' => 'fact']],
]);
expect($brain->sleepCalls)->toBe([100]);
Http::assertSentCount(2);
Http::assertSent(fn (Request $request): bool => $request->url() === 'http://localhost:6334/collections/openbrain/points'
&& $request->method() === 'PUT');
});
test('CircuitBreaker_retryable_http_Ugly_does_not_retry_qdrant_requests_on_401', function (): void {
$brain = retryableBrainService();
Http::fake([
'http://localhost:6334/collections/openbrain/points' => Http::sequence()
->push(['error' => 'unauthorised'], 401)
->push(['result' => ['status' => 'ok']], 200),
]);
expect(fn () => $brain->qdrantUpsert([
['id' => 'memory-2', 'vector' => [0.3, 0.4], 'payload' => ['type' => 'fact']],
]))->toThrow(RuntimeException::class, 'Qdrant upsert failed: 401');
expect($brain->sleepCalls)->toBe([]);
Http::assertSentCount(1);
});
test('CircuitBreaker_retryable_http_retries_qdrant_requests_on_429_using_retry_after_header', function (): void {
$brain = retryableBrainService();
Http::fake([
'http://localhost:6334/collections/openbrain/points' => Http::sequence()
->push(['error' => 'rate limited'], 429, ['Retry-After' => '2'])
->push(['result' => ['status' => 'ok']], 200),
]);
$brain->qdrantUpsert([
['id' => 'memory-3', 'vector' => [0.5, 0.6], 'payload' => ['type' => 'fact']],
]);
expect($brain->sleepCalls)->toBe([2000]);
Http::assertSentCount(2);
});
test('CircuitBreaker_retryable_http_retries_qdrant_requests_on_408', function (): void {
$brain = retryableBrainService();
Http::fake([
'http://localhost:6334/collections/openbrain/points' => Http::sequence()
->push(['error' => 'timeout'], 408)
->push(['result' => ['status' => 'ok']], 200),
]);
$brain->qdrantUpsert([
['id' => 'memory-4', 'vector' => [0.7, 0.8], 'payload' => ['type' => 'fact']],
]);
expect($brain->sleepCalls)->toBe([100]);
Http::assertSentCount(2);
});