agent/php/tests/Feature/Mcp/BrainSchemaOrgTest.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

190 lines
6.1 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\Mcp\Tools\Agent\Brain\BrainRecall;
use Core\Mod\Agentic\Mcp\Tools\Agent\Brain\BrainRemember;
use Core\Mod\Agentic\Models\BrainMemory;
use Core\Mod\Agentic\Services\BrainService;
use Illuminate\Support\Str;
function passThroughBrainCircuitBreaker($app): void
{
$breaker = Mockery::mock(CircuitBreaker::class);
$breaker->shouldReceive('call')
->andReturnUsing(function (string $service, Closure $operation, ?Closure $fallback = null): mixed {
return $operation();
});
$app->instance(CircuitBreaker::class, $breaker);
}
test('BrainSchemaOrg_brain_remember_Good_accepts_org_and_forwards_it', function (): void {
$workspace = createWorkspace();
$brain = new class extends BrainService
{
public array $remembered = [];
public function remember(array $attributes): BrainMemory
{
$this->remembered = $attributes;
$memory = new BrainMemory;
$memory->forceFill(array_merge([
'id' => Str::uuid()->toString(),
'workspace_id' => $attributes['workspace_id'],
'agent_id' => $attributes['agent_id'],
'type' => $attributes['type'],
'content' => $attributes['content'],
'tags' => $attributes['tags'] ?? [],
'org' => $attributes['org'] ?? null,
'project' => $attributes['project'] ?? null,
'confidence' => $attributes['confidence'] ?? 0.8,
'supersedes_id' => $attributes['supersedes_id'] ?? null,
], $attributes));
$memory->exists = true;
return $memory;
}
};
passThroughBrainCircuitBreaker($this->app);
$this->app->instance(BrainService::class, $brain);
$tool = new BrainRemember;
$result = $tool->handle([
'content' => 'Shared organisation memory.',
'type' => 'fact',
'org' => 'core',
], [
'workspace_id' => $workspace->id,
'session_id' => 'session-1',
]);
expect($tool->inputSchema()['properties'])->toHaveKey('org')
->and($tool->inputSchema()['properties']['org']['maxLength'])->toBe(128)
->and($result['success'])->toBeTrue()
->and($brain->remembered['org'])->toBe('core')
->and($result['memory']['org'])->toBe('core');
});
test('BrainSchemaOrg_brain_remember_Bad_rejects_org_longer_than_128_chars', function (): void {
$workspace = createWorkspace();
passThroughBrainCircuitBreaker($this->app);
$tool = new BrainRemember;
$tool->handle([
'content' => 'Shared organisation memory.',
'type' => 'fact',
'org' => str_repeat('o', 129),
], [
'workspace_id' => $workspace->id,
'session_id' => 'session-1',
]);
})->throws(InvalidArgumentException::class, 'org exceeds maximum length of 128 characters');
test('BrainSchemaOrg_brain_recall_Bad_accepts_org_filter_and_forwards_it', function (): void {
$workspace = createWorkspace();
$brain = new class extends BrainService
{
public array $captured = [];
public function recall(
string $query,
int $topK,
array $filter,
int $workspaceId,
array $keywords = [],
array $boostKeywords = [],
): array {
$this->captured = [
'query' => $query,
'topK' => $topK,
'filter' => $filter,
'workspace_id' => $workspaceId,
];
return [
'memories' => [],
'scores' => [],
];
}
};
passThroughBrainCircuitBreaker($this->app);
$this->app->instance(BrainService::class, $brain);
$tool = new BrainRecall;
$result = $tool->handle([
'query' => 'org-filtered recall',
'filter' => [
'org' => 'core',
],
], [
'workspace_id' => $workspace->id,
]);
expect($tool->inputSchema()['properties']['filter']['properties'])->toHaveKey('org')
->and($tool->inputSchema()['properties']['filter']['properties']['org']['maxLength'])->toBe(128)
->and($result['success'])->toBeTrue()
->and($brain->captured['filter']['org'])->toBe('core')
->and($brain->captured['workspace_id'])->toBe($workspace->id);
});
test('BrainSchemaOrg_brain_recall_Ugly_rejects_filter_org_longer_than_128_chars', function (): void {
$workspace = createWorkspace();
passThroughBrainCircuitBreaker($this->app);
$tool = new BrainRecall;
$tool->handle([
'query' => 'org-filtered recall',
'filter' => [
'org' => str_repeat('o', 129),
],
], [
'workspace_id' => $workspace->id,
]);
})->throws(InvalidArgumentException::class, 'org exceeds maximum length of 128 characters');
test('BrainSchemaOrg_brain_list_Ugly_accepts_org_filter_without_validation_error', function (): void {
$workspace = createWorkspace();
passThroughBrainCircuitBreaker($this->app);
$matching = BrainMemory::create([
'workspace_id' => $workspace->id,
'agent_id' => 'virgil',
'type' => 'fact',
'content' => 'Core memory.',
'confidence' => 0.8,
'org' => 'core',
'project' => 'agent',
]);
BrainMemory::create([
'workspace_id' => $workspace->id,
'agent_id' => 'virgil',
'type' => 'fact',
'content' => 'Other org memory.',
'confidence' => 0.8,
'org' => 'other-org',
'project' => 'agent',
]);
$tool = new BrainList;
$result = $tool->handle([
'org' => 'core',
], [
'workspace_id' => $workspace->id,
]);
expect($tool->inputSchema()['properties'])->toHaveKey('org')
->and($tool->inputSchema()['properties']['org']['maxLength'])->toBe(128)
->and($result['success'])->toBeTrue()
->and($result['count'])->toBe(1)
->and($result['memories'][0]['id'])->toBe($matching->id)
->and($result['memories'][0]['org'])->toBe('core');
});