agent/php/tests/Feature/Mcp/BrainSchemaOrgTest.php
Snider c616ff1e32 fix(brain): close openbrain audit gaps — org scoping + index cleanup + reindex flags + MCP schemas + circuit layer
Closes the 5 PARTIAL items flagged in docs/AUDIT-openbrain-20260424.md.

- Gap A (org scoping persisted on writes): new migration adds `org`
  nullable+indexed column to brain_memories; BrainMemory fillable;
  RememberKnowledge action forwards org; BrainService::remember
  persists it.

- Gap B (supersede/forget Elastic cleanup): BrainService::forget
  dispatches DeleteFromIndex (handles both Qdrant + Elastic); supersede
  path dispatches cleanup for the old memory id before replacing it.
  DeleteFromIndex itself untouched — already handled both indexes.

- Gap C (brain:reindex flags): --org, --project, --stale (null OR
  >14d old), --dry-run (count+stop), --elastic-only added to the
  artisan command.

- Gap D (MCP schemas expose org): brain_remember, brain_recall,
  brain_list now accept `org` in input schema + forward into
  action/service.

- Gap E (resilience uneven): brain_list now wrapped in
  withCircuitBreaker('brain', ...) matching the pattern used by
  BrainRemember/Recall/Forget. BrainService gains retryableHttp()
  helper — 100/300/900ms exponential backoff, retries only on 5xx +
  connection errors, not on 4xx. Qdrant calls route through it;
  Ollama left alone (EmbedMemory job has its own retry).

Tests (Good/Bad/Ugly per gap):
- Feature/Brain/OrgScopingTest.php
- Feature/Brain/SupersedeForgetIndexCleanupTest.php
- Feature/Brain/ReindexFlagsTest.php
- Feature/Mcp/BrainSchemaOrgTest.php
- Feature/Brain/CircuitBreakerTest.php

php -l clean on all 13 files. Pest binary not in this checkout —
CI path validates the full suite.

Closes tasks.lthn.sh/view.php?id=107

Co-authored-by: Codex <noreply@openai.com>
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-24 08:14:06 +01:00

155 lines
4.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\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($result['success'])->toBeTrue()
->and($brain->remembered['org'])->toBe('core')
->and($result['memory']['org'])->toBe('core');
});
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($result['success'])->toBeTrue()
->and($brain->captured['filter']['org'])->toBe('core')
->and($brain->captured['workspace_id'])->toBe($workspace->id);
});
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($result['success'])->toBeTrue()
->and($result['count'])->toBe(1)
->and($result['memories'][0]['id'])->toBe($matching->id)
->and($result['memories'][0]['org'])->toBe('core');
});