Compare commits

..

No commits in common. "main" and "feat/phase-0-assessment" have entirely different histories.

117 changed files with 512 additions and 7717 deletions

View file

@ -1,63 +0,0 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
name: PHP ${{ matrix.php }}
runs-on: ubuntu-latest
container:
image: lthn/build:php-${{ matrix.php }}
strategy:
fail-fast: true
matrix:
php: ["8.3", "8.4"]
steps:
- uses: actions/checkout@v4
- name: Clone sister packages
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
echo "Cloning php-framework into ../php-framework"
git clone --depth 1 \
"https://x-access-token:${GITHUB_TOKEN}@forge.lthn.ai/core/php-framework.git" \
../php-framework
ls -la ../php-framework/composer.json
- name: Configure path repositories
run: |
composer config repositories.core path ../php-framework --no-interaction
- name: Install dependencies
run: composer install --prefer-dist --no-interaction --no-progress
- name: Run Pint
run: |
if [ -f vendor/bin/pint ]; then
vendor/bin/pint --test
else
echo "Pint not installed, skipping"
fi
- name: Run unit tests
run: |
if [ -f vendor/bin/pest ]; then
if [ -d tests/Unit ] || [ -d tests/unit ]; then
vendor/bin/pest tests/Unit --ci
elif [ -d src/Tests/Unit ]; then
vendor/bin/pest src/Tests/Unit --ci
else
echo "No unit test directory found, skipping"
fi
elif [ -f vendor/bin/phpunit ]; then
vendor/bin/phpunit --testsuite=Unit
else
echo "No test runner found, skipping"
fi

View file

@ -4,8 +4,8 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Console\Commands; namespace Core\Mod\Agentic\Console\Commands;
use Core\Mod\Agentic\Models\AgentPlan;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Core\Mod\Agentic\Models\AgentPlan;
use Mod\Content\Jobs\GenerateContentJob; use Mod\Content\Jobs\GenerateContentJob;
use Mod\Content\Models\ContentBrief; use Mod\Content\Models\ContentBrief;
use Mod\Content\Services\AIGatewayService; use Mod\Content\Services\AIGatewayService;

View file

@ -4,9 +4,9 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Console\Commands; namespace Core\Mod\Agentic\Console\Commands;
use Illuminate\Console\Command;
use Core\Mod\Agentic\Models\AgentPlan; use Core\Mod\Agentic\Models\AgentPlan;
use Core\Mod\Agentic\Services\PlanTemplateService; use Core\Mod\Agentic\Services\PlanTemplateService;
use Illuminate\Console\Command;
class PlanCommand extends Command class PlanCommand extends Command
{ {

View file

@ -4,8 +4,8 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Console\Commands; namespace Core\Mod\Agentic\Console\Commands;
use Core\Mod\Agentic\Models\Task;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Core\Mod\Agentic\Models\Task;
class TaskCommand extends Command class TaskCommand extends Command
{ {

View file

@ -18,22 +18,13 @@ class ForAgentsController extends Controller
{ {
public function __invoke(): JsonResponse public function __invoke(): JsonResponse
{ {
$ttl = (int) config('mcp.cache.for_agents_ttl', 3600); // Cache for 1 hour since this is static data
$data = Cache::remember('agentic.for-agents.json', 3600, function () {
$data = Cache::remember($this->cacheKey(), $ttl, function () {
return $this->getAgentData(); return $this->getAgentData();
}); });
return response()->json($data) return response()->json($data)
->header('Cache-Control', "public, max-age={$ttl}"); ->header('Cache-Control', 'public, max-age=3600');
}
/**
* Namespaced cache key, configurable to prevent cross-module collisions.
*/
public function cacheKey(): string
{
return (string) config('mcp.cache.for_agents_key', 'agentic.for-agents.json');
} }
private function getAgentData(): array private function getAgentData(): array

View file

@ -4,13 +4,13 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Jobs; namespace Core\Mod\Agentic\Jobs;
use Mod\Content\Models\ContentTask;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Mod\Content\Models\ContentTask;
class BatchContentGeneration implements ShouldQueue class BatchContentGeneration implements ShouldQueue
{ {

View file

@ -5,13 +5,14 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Jobs; namespace Core\Mod\Agentic\Jobs;
use Core\Mod\Agentic\Services\AgenticManager; use Core\Mod\Agentic\Services\AgenticManager;
use Mod\Content\Models\ContentTask;
use Mod\Content\Services\ContentProcessingService;
use Core\Tenant\Services\EntitlementService; use Core\Tenant\Services\EntitlementService;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Mod\Content\Models\ContentTask;
use Throwable; use Throwable;
class ProcessContentTask implements ShouldQueue class ProcessContentTask implements ShouldQueue
@ -32,6 +33,7 @@ class ProcessContentTask implements ShouldQueue
public function handle( public function handle(
AgenticManager $ai, AgenticManager $ai,
ContentProcessingService $processor,
EntitlementService $entitlements EntitlementService $entitlements
): void { ): void {
$this->task->markProcessing(); $this->task->markProcessing();
@ -101,6 +103,11 @@ class ProcessContentTask implements ShouldQueue
] ]
); );
} }
// If this task has a target, process the output
if ($this->task->target_type && $this->task->target_id) {
$this->processOutput($response->content, $processor);
}
} }
public function failed(Throwable $exception): void public function failed(Throwable $exception): void
@ -108,18 +115,35 @@ class ProcessContentTask implements ShouldQueue
$this->task->markFailed($exception->getMessage()); $this->task->markFailed($exception->getMessage());
} }
/**
* Interpolate template variables.
*/
private function interpolateVariables(string $template, array $data): string private function interpolateVariables(string $template, array $data): string
{ {
foreach ($data as $key => $value) { foreach ($data as $key => $value) {
$placeholder = '{{{'.$key.'}}}';
if (is_string($value)) { if (is_string($value)) {
$template = str_replace($placeholder, $value, $template); $template = str_replace("{{{$key}}}", $value, $template);
} elseif (is_array($value)) { } elseif (is_array($value)) {
$template = str_replace($placeholder, json_encode($value), $template); $template = str_replace("{{{$key}}}", json_encode($value), $template);
} }
} }
return $template; return $template;
} }
/**
* Process the AI output based on target type.
*/
private function processOutput(string $content, ContentProcessingService $processor): void
{
$target = $this->task->target;
if (! $target) {
return;
}
// Handle different target types
// This can be extended for different content types
// For now, just log that processing occurred
}
} }

View file

@ -15,12 +15,12 @@ use Core\Mcp\Tools\ListRoutes;
use Core\Mcp\Tools\ListSites; use Core\Mcp\Tools\ListSites;
use Core\Mcp\Tools\ListTables; use Core\Mcp\Tools\ListTables;
use Core\Mcp\Tools\QueryDatabase; use Core\Mcp\Tools\QueryDatabase;
use Mod\Bio\Mcp\BioResource;
use Laravel\Mcp\Server;
use Core\Mod\Agentic\Mcp\Prompts\AnalysePerformancePrompt; use Core\Mod\Agentic\Mcp\Prompts\AnalysePerformancePrompt;
use Core\Mod\Agentic\Mcp\Prompts\ConfigureNotificationsPrompt; use Core\Mod\Agentic\Mcp\Prompts\ConfigureNotificationsPrompt;
use Core\Mod\Agentic\Mcp\Prompts\CreateBioPagePrompt; use Core\Mod\Agentic\Mcp\Prompts\CreateBioPagePrompt;
use Core\Mod\Agentic\Mcp\Prompts\SetupQrCampaignPrompt; use Core\Mod\Agentic\Mcp\Prompts\SetupQrCampaignPrompt;
use Laravel\Mcp\Server;
use Mod\Bio\Mcp\BioResource;
class HostHub extends Server class HostHub extends Server
{ {

View file

@ -4,9 +4,9 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Content; namespace Core\Mod\Agentic\Mcp\Tools\Agent\Content;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Mod\Content\Jobs\GenerateContentJob; use Mod\Content\Jobs\GenerateContentJob;
use Mod\Content\Models\ContentBrief; use Mod\Content\Models\ContentBrief;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
/** /**
* Queue multiple briefs for batch content generation. * Queue multiple briefs for batch content generation.

View file

@ -4,11 +4,11 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Content; namespace Core\Mod\Agentic\Mcp\Tools\Agent\Content;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Core\Mod\Agentic\Models\AgentPlan;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Core\Mod\Agentic\Models\AgentPlan;
use Mod\Content\Enums\BriefContentType; use Mod\Content\Enums\BriefContentType;
use Mod\Content\Models\ContentBrief; use Mod\Content\Models\ContentBrief;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
/** /**
* Create a content brief for AI generation. * Create a content brief for AI generation.

View file

@ -4,9 +4,9 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Content; namespace Core\Mod\Agentic\Mcp\Tools\Agent\Content;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Mod\Content\Enums\BriefContentType; use Mod\Content\Enums\BriefContentType;
use Mod\Content\Models\ContentBrief; use Mod\Content\Models\ContentBrief;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
/** /**
* Get details of a specific content brief including generated content. * Get details of a specific content brief including generated content.

View file

@ -4,9 +4,9 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Content; namespace Core\Mod\Agentic\Mcp\Tools\Agent\Content;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Mod\Content\Enums\BriefContentType; use Mod\Content\Enums\BriefContentType;
use Mod\Content\Models\ContentBrief; use Mod\Content\Models\ContentBrief;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
/** /**
* List content briefs with optional status filter. * List content briefs with optional status filter.

View file

@ -4,12 +4,12 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Content; namespace Core\Mod\Agentic\Mcp\Tools\Agent\Content;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Core\Mod\Agentic\Models\AgentPlan;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Core\Mod\Agentic\Models\AgentPlan;
use Mod\Content\Enums\BriefContentType; use Mod\Content\Enums\BriefContentType;
use Mod\Content\Jobs\GenerateContentJob; use Mod\Content\Jobs\GenerateContentJob;
use Mod\Content\Models\ContentBrief; use Mod\Content\Models\ContentBrief;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
/** /**
* Create content briefs from plan tasks and queue for generation. * Create content briefs from plan tasks and queue for generation.

View file

@ -4,10 +4,10 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Content; namespace Core\Mod\Agentic\Mcp\Tools\Agent\Content;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Mod\Content\Jobs\GenerateContentJob; use Mod\Content\Jobs\GenerateContentJob;
use Mod\Content\Models\ContentBrief; use Mod\Content\Models\ContentBrief;
use Mod\Content\Services\AIGatewayService; use Mod\Content\Services\AIGatewayService;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
/** /**
* Generate content for a brief using AI pipeline. * Generate content for a brief using AI pipeline.

View file

@ -4,9 +4,9 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Content; namespace Core\Mod\Agentic\Mcp\Tools\Agent\Content;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Mod\Content\Models\ContentBrief; use Mod\Content\Models\ContentBrief;
use Mod\Content\Services\AIGatewayService; use Mod\Content\Services\AIGatewayService;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
/** /**
* Get content generation pipeline status. * Get content generation pipeline status.

View file

@ -4,8 +4,8 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Content; namespace Core\Mod\Agentic\Mcp\Tools\Agent\Content;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Mod\Content\Models\AIUsage; use Mod\Content\Models\AIUsage;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
/** /**
* Get AI usage statistics for content generation. * Get AI usage statistics for content generation.

View file

@ -4,9 +4,9 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Phase; namespace Core\Mod\Agentic\Mcp\Tools\Agent\Phase;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Core\Mod\Agentic\Models\AgentPhase; use Core\Mod\Agentic\Models\AgentPhase;
use Core\Mod\Agentic\Models\AgentPlan; use Core\Mod\Agentic\Models\AgentPlan;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
/** /**
* Add a checkpoint note to a phase. * Add a checkpoint note to a phase.

View file

@ -4,9 +4,9 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Phase; namespace Core\Mod\Agentic\Mcp\Tools\Agent\Phase;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Core\Mod\Agentic\Models\AgentPhase; use Core\Mod\Agentic\Models\AgentPhase;
use Core\Mod\Agentic\Models\AgentPlan; use Core\Mod\Agentic\Models\AgentPlan;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
/** /**
* Get details of a specific phase within a plan. * Get details of a specific phase within a plan.

View file

@ -4,8 +4,8 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Plan; namespace Core\Mod\Agentic\Mcp\Tools\Agent\Plan;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Core\Mod\Agentic\Models\AgentPlan; use Core\Mod\Agentic\Models\AgentPlan;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
/** /**
* Archive a completed or abandoned plan. * Archive a completed or abandoned plan.

View file

@ -5,10 +5,10 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Plan; namespace Core\Mod\Agentic\Mcp\Tools\Agent\Plan;
use Core\Mcp\Dependencies\ToolDependency; use Core\Mcp\Dependencies\ToolDependency;
use Illuminate\Support\Str;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool; use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Core\Mod\Agentic\Models\AgentPhase; use Core\Mod\Agentic\Models\AgentPhase;
use Core\Mod\Agentic\Models\AgentPlan; use Core\Mod\Agentic\Models\AgentPlan;
use Illuminate\Support\Str;
/** /**
* Create a new work plan with phases and tasks. * Create a new work plan with phases and tasks.
@ -99,7 +99,7 @@ class PlanCreate extends AgentTool
// Determine workspace_id - never fall back to hardcoded value in multi-tenant environment // Determine workspace_id - never fall back to hardcoded value in multi-tenant environment
$workspaceId = $context['workspace_id'] ?? null; $workspaceId = $context['workspace_id'] ?? null;
if ($workspaceId === null) { if ($workspaceId === null) {
return $this->error('workspace_id is required. Ensure you have authenticated with a valid API key and started a session. See: https://host.uk.com/ai'); return $this->error('workspace_id is required but could not be determined from context');
} }
$plan = AgentPlan::create([ $plan = AgentPlan::create([

View file

@ -5,8 +5,8 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Plan; namespace Core\Mod\Agentic\Mcp\Tools\Agent\Plan;
use Core\Mcp\Dependencies\ToolDependency; use Core\Mcp\Dependencies\ToolDependency;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Core\Mod\Agentic\Models\AgentPlan; use Core\Mod\Agentic\Models\AgentPlan;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
/** /**
* Get detailed information about a specific plan. * Get detailed information about a specific plan.
@ -71,7 +71,7 @@ class PlanGet extends AgentTool
// Validate workspace context for tenant isolation // Validate workspace context for tenant isolation
$workspaceId = $context['workspace_id'] ?? null; $workspaceId = $context['workspace_id'] ?? null;
if ($workspaceId === null) { if ($workspaceId === null) {
return $this->error('workspace_id is required. Ensure you have authenticated with a valid API key and started a session. See: https://host.uk.com/ai'); return $this->error('workspace_id is required for plan operations');
} }
$format = $this->optional($args, 'format', 'json'); $format = $this->optional($args, 'format', 'json');

View file

@ -5,8 +5,8 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Plan; namespace Core\Mod\Agentic\Mcp\Tools\Agent\Plan;
use Core\Mcp\Dependencies\ToolDependency; use Core\Mcp\Dependencies\ToolDependency;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Core\Mod\Agentic\Models\AgentPlan; use Core\Mod\Agentic\Models\AgentPlan;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
/** /**
* List all work plans with their current status and progress. * List all work plans with their current status and progress.
@ -71,7 +71,7 @@ class PlanList extends AgentTool
// Validate workspace context for tenant isolation // Validate workspace context for tenant isolation
$workspaceId = $context['workspace_id'] ?? null; $workspaceId = $context['workspace_id'] ?? null;
if ($workspaceId === null) { if ($workspaceId === null) {
return $this->error('workspace_id is required. Ensure you have authenticated with a valid API key and started a session. See: https://host.uk.com/ai'); return $this->error('workspace_id is required for plan operations');
} }
// Query plans with workspace scope to prevent cross-tenant access // Query plans with workspace scope to prevent cross-tenant access

View file

@ -4,8 +4,8 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Plan; namespace Core\Mod\Agentic\Mcp\Tools\Agent\Plan;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Core\Mod\Agentic\Models\AgentPlan; use Core\Mod\Agentic\Models\AgentPlan;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
/** /**
* Update the status of a plan. * Update the status of a plan.

View file

@ -4,8 +4,8 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Session; namespace Core\Mod\Agentic\Mcp\Tools\Agent\Session;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Core\Mod\Agentic\Models\AgentSession; use Core\Mod\Agentic\Models\AgentSession;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
/** /**
* Record an artifact created/modified during the session. * Record an artifact created/modified during the session.

View file

@ -4,8 +4,8 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Session; namespace Core\Mod\Agentic\Mcp\Tools\Agent\Session;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Core\Mod\Agentic\Services\AgentSessionService; use Core\Mod\Agentic\Services\AgentSessionService;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
/** /**
* Continue from a previous session (multi-agent handoff). * Continue from a previous session (multi-agent handoff).

View file

@ -4,8 +4,8 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Session; namespace Core\Mod\Agentic\Mcp\Tools\Agent\Session;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Core\Mod\Agentic\Models\AgentSession; use Core\Mod\Agentic\Models\AgentSession;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
/** /**
* End the current session. * End the current session.

View file

@ -4,8 +4,8 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Session; namespace Core\Mod\Agentic\Mcp\Tools\Agent\Session;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Core\Mod\Agentic\Models\AgentSession; use Core\Mod\Agentic\Models\AgentSession;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
/** /**
* Prepare session for handoff to another agent. * Prepare session for handoff to another agent.

View file

@ -4,8 +4,8 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Session; namespace Core\Mod\Agentic\Mcp\Tools\Agent\Session;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Core\Mod\Agentic\Services\AgentSessionService; use Core\Mod\Agentic\Services\AgentSessionService;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
/** /**
* List sessions, optionally filtered by status. * List sessions, optionally filtered by status.

View file

@ -4,8 +4,8 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Session; namespace Core\Mod\Agentic\Mcp\Tools\Agent\Session;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Core\Mod\Agentic\Services\AgentSessionService; use Core\Mod\Agentic\Services\AgentSessionService;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
/** /**
* Resume a paused or handed-off session. * Resume a paused or handed-off session.

View file

@ -5,10 +5,10 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Session; namespace Core\Mod\Agentic\Mcp\Tools\Agent\Session;
use Core\Mcp\Dependencies\ToolDependency; use Core\Mcp\Dependencies\ToolDependency;
use Illuminate\Support\Str;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool; use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Core\Mod\Agentic\Models\AgentPlan; use Core\Mod\Agentic\Models\AgentPlan;
use Core\Mod\Agentic\Models\AgentSession; use Core\Mod\Agentic\Models\AgentSession;
use Illuminate\Support\Str;
/** /**
* Start a new agent session for a plan. * Start a new agent session for a plan.
@ -88,7 +88,7 @@ class SessionStart extends AgentTool
// Determine workspace_id - never fall back to hardcoded value in multi-tenant environment // Determine workspace_id - never fall back to hardcoded value in multi-tenant environment
$workspaceId = $context['workspace_id'] ?? $plan?->workspace_id ?? null; $workspaceId = $context['workspace_id'] ?? $plan?->workspace_id ?? null;
if ($workspaceId === null) { if ($workspaceId === null) {
return $this->error('workspace_id is required. Ensure you have authenticated with a valid API key and started a session, or provide a valid plan_slug to infer workspace context. See: https://host.uk.com/ai'); return $this->error('workspace_id is required but could not be determined from context or plan');
} }
$session = AgentSession::create([ $session = AgentSession::create([

View file

@ -5,8 +5,8 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Tools\Agent\State; namespace Core\Mod\Agentic\Mcp\Tools\Agent\State;
use Core\Mcp\Dependencies\ToolDependency; use Core\Mcp\Dependencies\ToolDependency;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Core\Mod\Agentic\Models\AgentPlan; use Core\Mod\Agentic\Models\AgentPlan;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
/** /**
* Get a workspace state value. * Get a workspace state value.
@ -71,7 +71,7 @@ class StateGet extends AgentTool
// Validate workspace context for tenant isolation // Validate workspace context for tenant isolation
$workspaceId = $context['workspace_id'] ?? null; $workspaceId = $context['workspace_id'] ?? null;
if ($workspaceId === null) { if ($workspaceId === null) {
return $this->error('workspace_id is required. Ensure you have authenticated with a valid API key and started a session. See: https://host.uk.com/ai'); return $this->error('workspace_id is required for state operations');
} }
// Query plan with workspace scope to prevent cross-tenant access // Query plan with workspace scope to prevent cross-tenant access

View file

@ -5,8 +5,8 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Tools\Agent\State; namespace Core\Mod\Agentic\Mcp\Tools\Agent\State;
use Core\Mcp\Dependencies\ToolDependency; use Core\Mcp\Dependencies\ToolDependency;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Core\Mod\Agentic\Models\AgentPlan; use Core\Mod\Agentic\Models\AgentPlan;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
/** /**
* List all state values for a plan. * List all state values for a plan.
@ -70,7 +70,7 @@ class StateList extends AgentTool
// Validate workspace context for tenant isolation // Validate workspace context for tenant isolation
$workspaceId = $context['workspace_id'] ?? null; $workspaceId = $context['workspace_id'] ?? null;
if ($workspaceId === null) { if ($workspaceId === null) {
return $this->error('workspace_id is required. Ensure you have authenticated with a valid API key and started a session. See: https://host.uk.com/ai'); return $this->error('workspace_id is required for state operations');
} }
// Query plan with workspace scope to prevent cross-tenant access // Query plan with workspace scope to prevent cross-tenant access

View file

@ -5,9 +5,9 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Tools\Agent\State; namespace Core\Mod\Agentic\Mcp\Tools\Agent\State;
use Core\Mcp\Dependencies\ToolDependency; use Core\Mcp\Dependencies\ToolDependency;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Core\Mod\Agentic\Models\AgentPlan; use Core\Mod\Agentic\Models\AgentPlan;
use Core\Mod\Agentic\Models\AgentWorkspaceState; use Core\Mod\Agentic\Models\AgentWorkspaceState;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
/** /**
* Set a workspace state value. * Set a workspace state value.
@ -81,7 +81,7 @@ class StateSet extends AgentTool
// Validate workspace context for tenant isolation // Validate workspace context for tenant isolation
$workspaceId = $context['workspace_id'] ?? null; $workspaceId = $context['workspace_id'] ?? null;
if ($workspaceId === null) { if ($workspaceId === null) {
return $this->error('workspace_id is required. Ensure you have authenticated with a valid API key and started a session. See: https://host.uk.com/ai'); return $this->error('workspace_id is required for state operations');
} }
// Query plan with workspace scope to prevent cross-tenant access // Query plan with workspace scope to prevent cross-tenant access

View file

@ -4,8 +4,8 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Template; namespace Core\Mod\Agentic\Mcp\Tools\Agent\Template;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Core\Mod\Agentic\Services\PlanTemplateService; use Core\Mod\Agentic\Services\PlanTemplateService;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
/** /**
* Create a new plan from a template. * Create a new plan from a template.

View file

@ -4,8 +4,8 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Template; namespace Core\Mod\Agentic\Mcp\Tools\Agent\Template;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Core\Mod\Agentic\Services\PlanTemplateService; use Core\Mod\Agentic\Services\PlanTemplateService;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
/** /**
* List available plan templates. * List available plan templates.

View file

@ -4,8 +4,8 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Template; namespace Core\Mod\Agentic\Mcp\Tools\Agent\Template;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Core\Mod\Agentic\Services\PlanTemplateService; use Core\Mod\Agentic\Services\PlanTemplateService;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
/** /**
* Preview a template with variables. * Preview a template with variables.

View file

@ -5,9 +5,9 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Middleware; namespace Core\Mod\Agentic\Middleware;
use Closure; use Closure;
use Illuminate\Http\Request;
use Core\Mod\Agentic\Models\AgentApiKey; use Core\Mod\Agentic\Models\AgentApiKey;
use Core\Mod\Agentic\Services\AgentApiKeyService; use Core\Mod\Agentic\Services\AgentApiKeyService;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
/** /**

View file

@ -1,65 +0,0 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Create prompts and prompt_versions tables.
*
* Guarded with hasTable() so this migration is idempotent and
* can coexist with the consolidated app-level migration.
*/
public function up(): void
{
Schema::disableForeignKeyConstraints();
if (! Schema::hasTable('prompts')) {
Schema::create('prompts', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('category')->nullable();
$table->text('description')->nullable();
$table->text('system_prompt')->nullable();
$table->text('user_template')->nullable();
$table->json('variables')->nullable();
$table->string('model')->nullable();
$table->json('model_config')->nullable();
$table->boolean('is_active')->default(true);
$table->timestamps();
$table->index('category');
$table->index('is_active');
});
}
if (! Schema::hasTable('prompt_versions')) {
Schema::create('prompt_versions', function (Blueprint $table) {
$table->id();
$table->foreignId('prompt_id')->constrained()->cascadeOnDelete();
$table->unsignedInteger('version');
$table->text('system_prompt')->nullable();
$table->text('user_template')->nullable();
$table->json('variables')->nullable();
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
$table->timestamps();
$table->index(['prompt_id', 'version']);
});
}
Schema::enableForeignKeyConstraints();
}
public function down(): void
{
Schema::disableForeignKeyConstraints();
Schema::dropIfExists('prompt_versions');
Schema::dropIfExists('prompts');
Schema::enableForeignKeyConstraints();
}
};

View file

@ -1,51 +0,0 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Add performance indexes for frequently queried columns (DB-002).
*
* Analysis per acceptance criteria:
* - agent_sessions.session_id: the ->unique() constraint in migration 000001
* creates a unique index (agent_sessions_session_id_unique) which the query
* optimiser uses for string lookups. No additional index required.
* - agent_plans.slug: ->unique() already creates agent_plans_slug_unique; the
* plain agent_plans_slug_index added separately is redundant and is dropped.
* A compound (workspace_id, slug) index is added for the common routing
* pattern: WHERE workspace_id = ? AND slug = ?
* - agent_workspace_states.key: already indexed via ->index('key') in
* migration 000003. No additional index required.
*/
public function up(): void
{
if (Schema::hasTable('agent_plans')) {
Schema::table('agent_plans', function (Blueprint $table) {
// Drop the redundant plain slug index. The unique constraint on slug
// already provides agent_plans_slug_unique, which covers all lookup queries.
$table->dropIndex('agent_plans_slug_index');
// Compound index for the common routing pattern:
// AgentPlan::where('workspace_id', $id)->where('slug', $slug)->first()
$table->index(['workspace_id', 'slug'], 'agent_plans_workspace_slug_index');
});
}
}
public function down(): void
{
if (Schema::hasTable('agent_plans')) {
Schema::table('agent_plans', function (Blueprint $table) {
$table->dropIndex('agent_plans_workspace_slug_index');
// Restore the redundant slug index that was present before this migration.
$table->index('slug');
});
}
}
};

View file

@ -5,7 +5,6 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Models; namespace Core\Mod\Agentic\Models;
use Core\Tenant\Models\Workspace; use Core\Tenant\Models\Workspace;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Str; use Illuminate\Support\Str;
@ -121,7 +120,7 @@ class AgentApiKey extends Model
} }
// Scopes // Scopes
public function scopeActive(Builder $query): Builder public function scopeActive($query)
{ {
return $query->whereNull('revoked_at') return $query->whereNull('revoked_at')
->where(function ($q) { ->where(function ($q) {
@ -137,12 +136,12 @@ class AgentApiKey extends Model
return $query->where('workspace_id', $workspaceId); return $query->where('workspace_id', $workspaceId);
} }
public function scopeRevoked(Builder $query): Builder public function scopeRevoked($query)
{ {
return $query->whereNotNull('revoked_at'); return $query->whereNotNull('revoked_at');
} }
public function scopeExpired(Builder $query): Builder public function scopeExpired($query)
{ {
return $query->whereNotNull('expires_at') return $query->whereNotNull('expires_at')
->where('expires_at', '<=', now()); ->where('expires_at', '<=', now());

View file

@ -4,12 +4,11 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Models; namespace Core\Mod\Agentic\Models;
use Core\Mod\Agentic\Database\Factories\AgentPhaseFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Core\Mod\Agentic\Database\Factories\AgentPhaseFactory;
/** /**
* Agent Phase - individual phase within a plan. * Agent Phase - individual phase within a plan.
@ -83,22 +82,22 @@ class AgentPhase extends Model
} }
// Scopes // Scopes
public function scopePending(Builder $query): Builder public function scopePending($query)
{ {
return $query->where('status', self::STATUS_PENDING); return $query->where('status', self::STATUS_PENDING);
} }
public function scopeInProgress(Builder $query): Builder public function scopeInProgress($query)
{ {
return $query->where('status', self::STATUS_IN_PROGRESS); return $query->where('status', self::STATUS_IN_PROGRESS);
} }
public function scopeCompleted(Builder $query): Builder public function scopeCompleted($query)
{ {
return $query->where('status', self::STATUS_COMPLETED); return $query->where('status', self::STATUS_COMPLETED);
} }
public function scopeBlocked(Builder $query): Builder public function scopeBlocked($query)
{ {
return $query->where('status', self::STATUS_BLOCKED); return $query->where('status', self::STATUS_BLOCKED);
} }
@ -317,17 +316,11 @@ class AgentPhase extends Model
public function checkDependencies(): array public function checkDependencies(): array
{ {
$dependencies = $this->dependencies ?? []; $dependencies = $this->dependencies ?? [];
if (empty($dependencies)) {
return [];
}
$blockers = []; $blockers = [];
$deps = AgentPhase::whereIn('id', $dependencies)->get(); foreach ($dependencies as $depId) {
$dep = AgentPhase::find($depId);
foreach ($deps as $dep) { if ($dep && ! $dep->isCompleted() && ! $dep->isSkipped()) {
if (! $dep->isCompleted() && ! $dep->isSkipped()) {
$blockers[] = [ $blockers[] = [
'phase_id' => $dep->id, 'phase_id' => $dep->id,
'phase_order' => $dep->order, 'phase_order' => $dep->order,

View file

@ -4,15 +4,14 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Models; namespace Core\Mod\Agentic\Models;
use Core\Mod\Agentic\Database\Factories\AgentPlanFactory;
use Core\Tenant\Concerns\BelongsToWorkspace;
use Core\Tenant\Models\Workspace;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Core\Mod\Agentic\Database\Factories\AgentPlanFactory;
use Core\Tenant\Concerns\BelongsToWorkspace;
use Core\Tenant\Models\Workspace;
use Spatie\Activitylog\LogOptions; use Spatie\Activitylog\LogOptions;
use Spatie\Activitylog\Traits\LogsActivity; use Spatie\Activitylog\Traits\LogsActivity;
@ -100,17 +99,17 @@ class AgentPlan extends Model
} }
// Scopes // Scopes
public function scopeActive(Builder $query): Builder public function scopeActive($query)
{ {
return $query->where('status', self::STATUS_ACTIVE); return $query->where('status', self::STATUS_ACTIVE);
} }
public function scopeDraft(Builder $query): Builder public function scopeDraft($query)
{ {
return $query->where('status', self::STATUS_DRAFT); return $query->where('status', self::STATUS_DRAFT);
} }
public function scopeNotArchived(Builder $query): Builder public function scopeNotArchived($query)
{ {
return $query->where('status', '!=', self::STATUS_ARCHIVED); return $query->where('status', '!=', self::STATUS_ARCHIVED);
} }
@ -121,7 +120,7 @@ class AgentPlan extends Model
* This is a safe replacement for orderByRaw("FIELD(status, ...)") which * This is a safe replacement for orderByRaw("FIELD(status, ...)") which
* could be vulnerable to SQL injection if extended with user input. * could be vulnerable to SQL injection if extended with user input.
*/ */
public function scopeOrderByStatus(Builder $query, string $direction = 'asc'): Builder public function scopeOrderByStatus($query, string $direction = 'asc')
{ {
return $query->orderByRaw('CASE status return $query->orderByRaw('CASE status
WHEN ? THEN 1 WHEN ? THEN 1
@ -129,7 +128,7 @@ class AgentPlan extends Model
WHEN ? THEN 3 WHEN ? THEN 3
WHEN ? THEN 4 WHEN ? THEN 4
ELSE 5 ELSE 5
END '.($direction === 'desc' ? 'DESC' : 'ASC'), [self::STATUS_ACTIVE, self::STATUS_DRAFT, self::STATUS_COMPLETED, self::STATUS_ARCHIVED]); END ' . ($direction === 'desc' ? 'DESC' : 'ASC'), [self::STATUS_ACTIVE, self::STATUS_DRAFT, self::STATUS_COMPLETED, self::STATUS_ARCHIVED]);
} }
// Helpers // Helpers

View file

@ -4,13 +4,12 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Models; namespace Core\Mod\Agentic\Models;
use Core\Mod\Agentic\Database\Factories\AgentSessionFactory;
use Core\Tenant\Concerns\BelongsToWorkspace; use Core\Tenant\Concerns\BelongsToWorkspace;
use Core\Tenant\Models\Workspace; use Core\Tenant\Models\Workspace;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Core\Mod\Agentic\Database\Factories\AgentSessionFactory;
use Ramsey\Uuid\Uuid; use Ramsey\Uuid\Uuid;
/** /**
@ -108,12 +107,12 @@ class AgentSession extends Model
} }
// Scopes // Scopes
public function scopeActive(Builder $query): Builder public function scopeActive($query)
{ {
return $query->where('status', self::STATUS_ACTIVE); return $query->where('status', self::STATUS_ACTIVE);
} }
public function scopeForPlan(Builder $query, AgentPlan|int $plan): Builder public function scopeForPlan($query, AgentPlan|int $plan)
{ {
$planId = $plan instanceof AgentPlan ? $plan->id : $plan; $planId = $plan instanceof AgentPlan ? $plan->id : $plan;

View file

@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Models; namespace Core\Mod\Agentic\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -55,14 +54,14 @@ class AgentWorkspaceState extends Model
} }
// Scopes // Scopes
public function scopeForPlan(Builder $query, AgentPlan|int $plan): Builder public function scopeForPlan($query, AgentPlan|int $plan)
{ {
$planId = $plan instanceof AgentPlan ? $plan->id : $plan; $planId = $plan instanceof AgentPlan ? $plan->id : $plan;
return $query->where('agent_plan_id', $planId); return $query->where('agent_plan_id', $planId);
} }
public function scopeOfType(Builder $query, string $type): Builder public function scopeOfType($query, string $type)
{ {
return $query->where('type', $type); return $query->where('type', $type);
} }

View file

@ -4,11 +4,10 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Models; namespace Core\Mod\Agentic\Models;
use Illuminate\Database\Eloquent\Builder; use Mod\Content\Models\ContentTask;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Mod\Content\Models\ContentTask;
class Prompt extends Model class Prompt extends Model
{ {
@ -83,7 +82,7 @@ class Prompt extends Model
/** /**
* Scope to only active prompts. * Scope to only active prompts.
*/ */
public function scopeActive(Builder $query): Builder public function scopeActive($query)
{ {
return $query->where('is_active', true); return $query->where('is_active', true);
} }
@ -91,7 +90,7 @@ class Prompt extends Model
/** /**
* Scope by category. * Scope by category.
*/ */
public function scopeCategory(Builder $query, string $category): Builder public function scopeCategory($query, string $category)
{ {
return $query->where('category', $category); return $query->where('category', $category);
} }
@ -99,7 +98,7 @@ class Prompt extends Model
/** /**
* Scope by model provider. * Scope by model provider.
*/ */
public function scopeForModel(Builder $query, string $model): Builder public function scopeForModel($query, string $model)
{ {
return $query->where('model', $model); return $query->where('model', $model);
} }

View file

@ -4,9 +4,8 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Models; namespace Core\Mod\Agentic\Models;
use Core\Tenant\Concerns\BelongsToWorkspace;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Core\Tenant\Concerns\BelongsToWorkspace;
class Task extends Model class Task extends Model
{ {
@ -27,22 +26,22 @@ class Task extends Model
'line_ref' => 'integer', 'line_ref' => 'integer',
]; ];
public function scopePending(Builder $query): Builder public function scopePending($query)
{ {
return $query->where('status', 'pending'); return $query->where('status', 'pending');
} }
public function scopeInProgress(Builder $query): Builder public function scopeInProgress($query)
{ {
return $query->where('status', 'in_progress'); return $query->where('status', 'in_progress');
} }
public function scopeDone(Builder $query): Builder public function scopeDone($query)
{ {
return $query->where('status', 'done'); return $query->where('status', 'done');
} }
public function scopeActive(Builder $query): Builder public function scopeActive($query)
{ {
return $query->whereIn('status', ['pending', 'in_progress']); return $query->whereIn('status', ['pending', 'in_progress']);
} }
@ -61,7 +60,7 @@ class Task extends Model
WHEN ? THEN 3 WHEN ? THEN 3
WHEN ? THEN 4 WHEN ? THEN 4
ELSE 5 ELSE 5
END '.($direction === 'desc' ? 'DESC' : 'ASC'), ['urgent', 'high', 'normal', 'low']); END ' . ($direction === 'desc' ? 'DESC' : 'ASC'), ['urgent', 'high', 'normal', 'low']);
} }
/** /**
@ -77,7 +76,7 @@ class Task extends Model
WHEN ? THEN 2 WHEN ? THEN 2
WHEN ? THEN 3 WHEN ? THEN 3
ELSE 4 ELSE 4
END '.($direction === 'desc' ? 'DESC' : 'ASC'), ['in_progress', 'pending', 'done']); END ' . ($direction === 'desc' ? 'DESC' : 'ASC'), ['in_progress', 'pending', 'done']);
} }
public function getStatusBadgeAttribute(): string public function getStatusBadgeAttribute(): string

View file

@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Models; namespace Core\Mod\Agentic\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -133,7 +132,7 @@ class WorkspaceState extends Model
/** /**
* Scope: for plan. * Scope: for plan.
*/ */
public function scopeForPlan(Builder $query, int $planId): Builder public function scopeForPlan($query, int $planId)
{ {
return $query->where('agent_plan_id', $planId); return $query->where('agent_plan_id', $planId);
} }
@ -141,7 +140,7 @@ class WorkspaceState extends Model
/** /**
* Scope: by type. * Scope: by type.
*/ */
public function scopeByType(Builder $query, string $type): Builder public function scopeByType($query, string $type)
{ {
return $query->where('type', $type); return $query->where('type', $type);
} }

View file

@ -4,10 +4,10 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Services; namespace Core\Mod\Agentic\Services;
use Core\Mod\Agentic\Models\AgentApiKey;
use Core\Tenant\Models\Workspace; use Core\Tenant\Models\Workspace;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Core\Mod\Agentic\Models\AgentApiKey;
/** /**
* Agent API Key Service. * Agent API Key Service.
@ -156,9 +156,6 @@ class AgentApiKeyService
// Clear rate limit cache // Clear rate limit cache
Cache::forget($this->getRateLimitCacheKey($key)); Cache::forget($this->getRateLimitCacheKey($key));
// Clear permitted tools cache so the revoked key can no longer access tools
app(AgentToolRegistry::class)->flushCacheForApiKey($key->id);
} }
/** /**
@ -167,9 +164,6 @@ class AgentApiKeyService
public function updatePermissions(AgentApiKey $key, array $permissions): void public function updatePermissions(AgentApiKey $key, array $permissions): void
{ {
$key->updatePermissions($permissions); $key->updatePermissions($permissions);
// Invalidate cached tool list so the new permissions take effect immediately
app(AgentToolRegistry::class)->flushCacheForApiKey($key->id);
} }
/** /**

View file

@ -17,221 +17,106 @@ use Illuminate\Http\Request;
* - Absence of typical browser indicators * - Absence of typical browser indicators
* *
* Part of the Trees for Agents system for rewarding AI agent referrals. * Part of the Trees for Agents system for rewarding AI agent referrals.
*
* Detection priority (highest to lowest):
* 1. MCP token header (X-MCP-Token) registered agents with explicit identity
* 2. User-Agent provider patterns matches known AI client strings
* 3. Non-agent bot patterns rules out search crawlers and monitoring tools
* 4. Browser indicators rules out real browser traffic
* 5. Unknown agent fallback programmatic access with no identifying UA
*
* Usage:
* ```php
* $detection = app(AgentDetection::class);
*
* // From a full HTTP request (checks MCP token first, then User-Agent)
* $identity = $detection->identify($request);
*
* // From a User-Agent string directly
* $identity = $detection->identifyFromUserAgent('claude-code/1.0 anthropic-api');
*
* // Quick boolean check
* if ($detection->isAgent($request)) {
* // credit the referral tree
* }
*
* // Inspect the result
* echo $identity->provider; // e.g. "anthropic"
* echo $identity->model; // e.g. "claude-sonnet" or null
* echo $identity->confidence; // e.g. "high"
* echo $identity->isAgent(); // true / false
* ```
*/ */
class AgentDetection class AgentDetection
{ {
/** /**
* User-Agent patterns for known AI providers. * User-Agent patterns for known AI providers.
* *
* Each entry maps a provider key to an array of detection patterns and optional * @var array<string, array{pattern: string, model_pattern: ?string}>
* model-specific sub-patterns. Patterns are tested in order; the first match wins.
*
* Provider patterns (case-insensitive):
*
* - anthropic:
* Examples: "claude-code/1.0", "Anthropic-API/2.0 claude-sonnet",
* "Claude AI Assistant/1.0", "claude code (agentic)"
*
* - openai:
* Examples: "ChatGPT-User/1.0", "OpenAI/1.0 python-httpx/0.26",
* "GPT-4-turbo/2024-04", "o1-preview/2024-09", "o1-mini/1.0"
*
* - google:
* Examples: "Google-AI/1.0", "Gemini/1.5-pro", "Google Bard/0.1",
* "PaLM API/1.0 google-generativeai/0.3"
*
* - meta:
* Examples: "Meta AI/1.0", "LLaMA/2.0 meta-ai", "Llama-3/2024-04",
* "Llama-2-chat/70B"
*
* - mistral:
* Examples: "Mistral/0.1.0 mistralai-python/0.1", "Mixtral-8x7B/1.0",
* "MistralAI-Large/latest"
*
* Model patterns narrow the detection to a specific model variant within a provider
* when the User-Agent includes version/model information.
*
* @var array<string, array{patterns: string[], model_patterns: array<string, string>}>
*/ */
protected const PROVIDER_PATTERNS = [ protected const PROVIDER_PATTERNS = [
'anthropic' => [ 'anthropic' => [
'patterns' => [ 'patterns' => [
'/claude[\s\-_]?code/i', // e.g. "claude-code/1.0", "claude code" '/claude[\s\-_]?code/i',
'/\banthopic\b/i', // e.g. "Anthropic/1.0" (intentional typo tolerance) '/\banthopic\b/i',
'/\banthropic[\s\-_]?api\b/i', // e.g. "Anthropic-API/2.0" '/\banthropic[\s\-_]?api\b/i',
'/\bclaude\b.*\bai\b/i', // e.g. "Claude AI Assistant/1.0" '/\bclaude\b.*\bai\b/i',
'/\bclaude\b.*\bassistant\b/i', // e.g. "Claude-Assistant/2.1" '/\bclaude\b.*\bassistant\b/i',
], ],
'model_patterns' => [ 'model_patterns' => [
'claude-opus' => '/claude[\s\-_]?opus/i', // e.g. "claude-opus-4-5" 'claude-opus' => '/claude[\s\-_]?opus/i',
'claude-sonnet' => '/claude[\s\-_]?sonnet/i', // e.g. "claude-sonnet-4-6" 'claude-sonnet' => '/claude[\s\-_]?sonnet/i',
'claude-haiku' => '/claude[\s\-_]?haiku/i', // e.g. "claude-haiku-4-5" 'claude-haiku' => '/claude[\s\-_]?haiku/i',
], ],
], ],
'openai' => [ 'openai' => [
'patterns' => [ 'patterns' => [
'/\bChatGPT\b/i', // e.g. "ChatGPT-User/1.0" '/\bChatGPT\b/i',
'/\bOpenAI\b/i', // e.g. "OpenAI/1.0 python-httpx/0.26" '/\bOpenAI\b/i',
'/\bGPT[\s\-_]?4\b/i', // e.g. "GPT-4-turbo/2024-04" '/\bGPT[\s\-_]?4\b/i',
'/\bGPT[\s\-_]?3\.?5\b/i', // e.g. "GPT-3.5-turbo/1.0" '/\bGPT[\s\-_]?3\.?5\b/i',
'/\bo1[\s\-_]?preview\b/i', // e.g. "o1-preview/2024-09" '/\bo1[\s\-_]?preview\b/i',
'/\bo1[\s\-_]?mini\b/i', // e.g. "o1-mini/1.0" '/\bo1[\s\-_]?mini\b/i',
], ],
'model_patterns' => [ 'model_patterns' => [
'gpt-4' => '/\bGPT[\s\-_]?4/i', // e.g. "GPT-4o", "GPT-4-turbo" 'gpt-4' => '/\bGPT[\s\-_]?4/i',
'gpt-3.5' => '/\bGPT[\s\-_]?3\.?5/i', // e.g. "GPT-3.5-turbo" 'gpt-3.5' => '/\bGPT[\s\-_]?3\.?5/i',
'o1' => '/\bo1[\s\-_]?(preview|mini)?\b/i', // e.g. "o1", "o1-preview", "o1-mini" 'o1' => '/\bo1[\s\-_]?(preview|mini)?\b/i',
], ],
], ],
'google' => [ 'google' => [
'patterns' => [ 'patterns' => [
'/\bGoogle[\s\-_]?AI\b/i', // e.g. "Google-AI/1.0" '/\bGoogle[\s\-_]?AI\b/i',
'/\bGemini\b/i', // e.g. "Gemini/1.5-pro", "gemini-flash" '/\bGemini\b/i',
'/\bBard\b/i', // e.g. "Google Bard/0.1" (legacy) '/\bBard\b/i',
'/\bPaLM\b/i', // e.g. "PaLM API/1.0" (legacy) '/\bPaLM\b/i',
], ],
'model_patterns' => [ 'model_patterns' => [
'gemini-pro' => '/gemini[\s\-_]?(1\.5[\s\-_]?)?pro/i', // e.g. "gemini-1.5-pro" 'gemini-pro' => '/gemini[\s\-_]?(1\.5[\s\-_]?)?pro/i',
'gemini-ultra' => '/gemini[\s\-_]?(1\.5[\s\-_]?)?ultra/i', // e.g. "gemini-ultra" 'gemini-ultra' => '/gemini[\s\-_]?(1\.5[\s\-_]?)?ultra/i',
'gemini-flash' => '/gemini[\s\-_]?(1\.5[\s\-_]?)?flash/i', // e.g. "gemini-1.5-flash" 'gemini-flash' => '/gemini[\s\-_]?(1\.5[\s\-_]?)?flash/i',
], ],
], ],
'meta' => [ 'meta' => [
'patterns' => [ 'patterns' => [
'/\bMeta[\s\-_]?AI\b/i', // e.g. "Meta AI/1.0" '/\bMeta[\s\-_]?AI\b/i',
'/\bLLaMA\b/i', // e.g. "LLaMA/2.0 meta-ai" '/\bLLaMA\b/i',
'/\bLlama[\s\-_]?[23]\b/i', // e.g. "Llama-3/2024-04", "Llama-2-chat" '/\bLlama[\s\-_]?[23]\b/i',
], ],
'model_patterns' => [ 'model_patterns' => [
'llama-3' => '/llama[\s\-_]?3/i', // e.g. "Llama-3-8B", "llama3-70b" 'llama-3' => '/llama[\s\-_]?3/i',
'llama-2' => '/llama[\s\-_]?2/i', // e.g. "Llama-2-chat/70B" 'llama-2' => '/llama[\s\-_]?2/i',
], ],
], ],
'mistral' => [ 'mistral' => [
'patterns' => [ 'patterns' => [
'/\bMistral\b/i', // e.g. "Mistral/0.1.0 mistralai-python/0.1" '/\bMistral\b/i',
'/\bMixtral\b/i', // e.g. "Mixtral-8x7B/1.0" '/\bMixtral\b/i',
], ],
'model_patterns' => [ 'model_patterns' => [
'mistral-large' => '/mistral[\s\-_]?large/i', // e.g. "mistral-large-latest" 'mistral-large' => '/mistral[\s\-_]?large/i',
'mistral-medium' => '/mistral[\s\-_]?medium/i', // e.g. "mistral-medium" 'mistral-medium' => '/mistral[\s\-_]?medium/i',
'mixtral' => '/mixtral/i', // e.g. "Mixtral-8x7B-Instruct" 'mixtral' => '/mixtral/i',
], ],
], ],
]; ];
/** /**
* Patterns that indicate a typical web browser. * Patterns that indicate a typical web browser.
* * If none of these are present, it might be programmatic access.
* If none of these tokens appear in a User-Agent string, the request is likely
* programmatic (a script, CLI tool, or potential agent). The patterns cover all
* major browser families and legacy rendering engine identifiers.
*
* Examples of matching User-Agents:
* - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0"
* - "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_2) ... Safari/537.36"
* - "Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/115.0"
* - "Mozilla/5.0 ... Edg/120.0" Microsoft Edge (Chromium)
* - "Opera/9.80 ... OPR/106.0" Opera
* - "Mozilla/4.0 (compatible; MSIE 8.0; Windows NT 6.1)" Internet Explorer
* - "Mozilla/5.0 ... Trident/7.0; rv:11.0" IE 11 (Trident engine)
*/ */
protected const BROWSER_INDICATORS = [ protected const BROWSER_INDICATORS = [
'/\bMozilla\b/i', // All Gecko/WebKit/Blink browsers include "Mozilla/5.0" '/\bMozilla\b/i',
'/\bChrome\b/i', // Chrome, Chromium, and most Chromium-based browsers '/\bChrome\b/i',
'/\bSafari\b/i', // Safari and WebKit-based browsers '/\bSafari\b/i',
'/\bFirefox\b/i', // Mozilla Firefox '/\bFirefox\b/i',
'/\bEdge\b/i', // Microsoft Edge (legacy "Edge/" and Chromium "Edg/") '/\bEdge\b/i',
'/\bOpera\b/i', // Opera ("Opera/" classic, "OPR/" modern) '/\bOpera\b/i',
'/\bMSIE\b/i', // Internet Explorer (e.g. "MSIE 11.0") '/\bMSIE\b/i',
'/\bTrident\b/i', // IE 11 Trident rendering engine token '/\bTrident\b/i',
]; ];
/** /**
* Known bot patterns that are NOT AI agents. * Known bot patterns that are NOT AI agents.
* * These should return notAnAgent, not unknown.
* These should resolve to `AgentIdentity::notAnAgent()` rather than
* `AgentIdentity::unknownAgent()`, because we can positively identify them
* as a specific non-AI automated client (crawler, monitoring, HTTP library, etc.).
*
* Categories and example User-Agents:
*
* Search engine crawlers:
* - "Googlebot/2.1 (+http://www.google.com/bot.html)"
* - "Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)"
* - "Mozilla/5.0 (compatible; YandexBot/3.0; +http://yandex.com/bots)"
* - "DuckDuckBot/1.0; (+http://duckduckgo.com/duckduckbot.html)"
* - "Mozilla/5.0 (compatible; Baiduspider/2.0; +http://www.baidu.com/search/spider.html)"
* - "Applebot/0.1 (+http://www.apple.com/go/applebot)"
*
* Social media / link-preview bots:
* - "facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)"
* - "Twitterbot/1.0"
* - "LinkedInBot/1.0 (compatible; Mozilla/5.0; Apache-HttpClient/4.5)"
* - "Slackbot-LinkExpanding 1.0 (+https://api.slack.com/robots)"
* - "DiscordBot (https://discordapp.com) 1.0"
* - "TelegramBot (like TwitterBot)"
* - "WhatsApp/2.23.20 A"
*
* SEO / analytics crawlers:
* - "Mozilla/5.0 (compatible; SemrushBot/7~bl; +http://www.semrush.com/bot.html)"
* - "Mozilla/5.0 (compatible; AhrefsBot/7.0; +http://ahrefs.com/robot/)"
*
* Generic HTTP clients (scripts, developer tools):
* - "curl/7.88.1"
* - "Wget/1.21.4"
* - "python-requests/2.31.0"
* - "Go-http-client/2.0"
* - "PostmanRuntime/7.35.0"
* - "insomnia/2023.5.8"
* - "axios/1.6.0"
* - "node-fetch/2.6.11"
*
* Uptime / monitoring services:
* - "UptimeRobot/2.0 (+http://www.uptimerobot.com/)"
* - "Pingdom.com_bot_version_1.4 (http://www.pingdom.com/)"
* - "Datadog Agent/7.45.0"
* - "NewRelicPinger/v1 AccountId=12345"
*/ */
protected const NON_AGENT_BOTS = [ protected const NON_AGENT_BOTS = [
// Search engine crawlers
'/\bGooglebot\b/i', '/\bGooglebot\b/i',
'/\bBingbot\b/i', '/\bBingbot\b/i',
'/\bYandexBot\b/i', '/\bYandexBot\b/i',
'/\bDuckDuckBot\b/i', '/\bDuckDuckBot\b/i',
'/\bBaiduspider\b/i', '/\bBaiduspider\b/i',
'/\bApplebot\b/i',
// Social media / link-preview bots
'/\bfacebookexternalhit\b/i', '/\bfacebookexternalhit\b/i',
'/\bTwitterbot\b/i', '/\bTwitterbot\b/i',
'/\bLinkedInBot\b/i', '/\bLinkedInBot\b/i',
@ -239,22 +124,17 @@ class AgentDetection
'/\bDiscordBot\b/i', '/\bDiscordBot\b/i',
'/\bTelegramBot\b/i', '/\bTelegramBot\b/i',
'/\bWhatsApp\//i', '/\bWhatsApp\//i',
'/\bApplebot\b/i',
// SEO / analytics crawlers
'/\bSEMrushBot\b/i', '/\bSEMrushBot\b/i',
'/\bAhrefsBot\b/i', '/\bAhrefsBot\b/i',
// Generic HTTP clients
'/\bcurl\b/i', '/\bcurl\b/i',
'/\bwget\b/i', '/\bwget\b/i',
'/\bpython-requests\b/i', '/\bpython-requests\b/i',
'/\bgo-http-client\b/i', '/\bgo-http-client\b/i',
'/\bPostman/i', '/\bPostman\b/i',
'/\bInsomnia\b/i', '/\bInsomnia\b/i',
'/\baxios\b/i', '/\baxios\b/i',
'/\bnode-fetch\b/i', '/\bnode-fetch\b/i',
// Uptime / monitoring services
'/\bUptimeRobot\b/i', '/\bUptimeRobot\b/i',
'/\bPingdom\b/i', '/\bPingdom\b/i',
'/\bDatadog\b/i', '/\bDatadog\b/i',
@ -262,19 +142,7 @@ class AgentDetection
]; ];
/** /**
* The MCP token header used to identify registered AI agents. * The MCP token header name.
*
* Agents send this header to bypass User-Agent heuristics and declare their
* identity explicitly. Two token formats are supported:
*
* - Opaque AgentApiKey token (prefix "ak_"):
* Looked up in the database. Grants highest confidence when the key is active.
* Example: `X-MCP-Token: ak_a1b2c3d4e5f6...`
*
* - Structured provider:model:secret token:
* Encodes provider and model directly in the token value.
* Example: `X-MCP-Token: anthropic:claude-sonnet:mysecret`
* Example: `X-MCP-Token: openai:gpt-4:xyz789`
*/ */
protected const MCP_TOKEN_HEADER = 'X-MCP-Token'; protected const MCP_TOKEN_HEADER = 'X-MCP-Token';

View file

@ -4,10 +4,10 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Services; namespace Core\Mod\Agentic\Services;
use Core\Mod\Agentic\Models\AgentPlan;
use Core\Mod\Agentic\Models\AgentSession;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Core\Mod\Agentic\Models\AgentPlan;
use Core\Mod\Agentic\Models\AgentSession;
/** /**
* Agent Session Service - manages session persistence for agent continuity. * Agent Session Service - manages session persistence for agent continuity.

View file

@ -7,9 +7,8 @@ namespace Core\Mod\Agentic\Services;
use Core\Api\Models\ApiKey; use Core\Api\Models\ApiKey;
use Core\Mcp\Dependencies\HasDependencies; use Core\Mcp\Dependencies\HasDependencies;
use Core\Mcp\Services\ToolDependencyService; use Core\Mcp\Services\ToolDependencyService;
use Core\Mod\Agentic\Mcp\Tools\Agent\Contracts\AgentToolInterface;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache; use Core\Mod\Agentic\Mcp\Tools\Agent\Contracts\AgentToolInterface;
/** /**
* Registry for MCP Agent Server tools. * Registry for MCP Agent Server tools.
@ -99,57 +98,24 @@ class AgentToolRegistry
); );
} }
/**
* Cache TTL for permitted tool lists (1 hour).
*/
public const CACHE_TTL = 3600;
/** /**
* Get tools accessible by an API key. * Get tools accessible by an API key.
* *
* Results are cached per API key for {@see CACHE_TTL} seconds to avoid
* repeated O(n) filtering on every request (PERF-002).
* Use {@see flushCacheForApiKey()} to invalidate on permission changes.
*
* @return Collection<string, AgentToolInterface> * @return Collection<string, AgentToolInterface>
*/ */
public function forApiKey(ApiKey $apiKey): Collection public function forApiKey(ApiKey $apiKey): Collection
{ {
$cacheKey = $this->apiKeyCacheKey($apiKey->getKey()); return $this->all()->filter(function (AgentToolInterface $tool) use ($apiKey) {
// Check if API key has required scopes
$permittedNames = Cache::remember($cacheKey, self::CACHE_TTL, function () use ($apiKey) { foreach ($tool->requiredScopes() as $scope) {
return $this->all()->filter(function (AgentToolInterface $tool) use ($apiKey) { if (! $apiKey->hasScope($scope)) {
// Check if API key has required scopes return false;
foreach ($tool->requiredScopes() as $scope) {
if (! $apiKey->hasScope($scope)) {
return false;
}
} }
}
// Check if API key has tool-level permission // Check if API key has tool-level permission
return $this->apiKeyCanAccessTool($apiKey, $tool->name()); return $this->apiKeyCanAccessTool($apiKey, $tool->name());
})->keys()->all();
}); });
return $this->all()->only($permittedNames);
}
/**
* Flush the cached tool list for an API key.
*
* Call this whenever an API key's permissions or tool scopes change.
*/
public function flushCacheForApiKey(int|string $apiKeyId): void
{
Cache::forget($this->apiKeyCacheKey($apiKeyId));
}
/**
* Build the cache key for a given API key ID.
*/
private function apiKeyCacheKey(int|string $apiKeyId): string
{
return "agent_tool_registry:api_key:{$apiKeyId}";
} }
/** /**

View file

@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Services; namespace Core\Mod\Agentic\Services;
use Illuminate\Support\Facades\Log;
use InvalidArgumentException; use InvalidArgumentException;
class AgenticManager class AgenticManager
@ -92,46 +91,22 @@ class AgenticManager
/** /**
* Register all AI providers. * Register all AI providers.
*
* Logs a warning for each provider whose API key is absent so that
* misconfiguration is surfaced at boot time rather than on the first
* API call. Set the corresponding environment variable to silence it:
*
* ANTHROPIC_API_KEY Claude
* GOOGLE_AI_API_KEY Gemini
* OPENAI_API_KEY OpenAI
*/ */
private function registerProviders(): void private function registerProviders(): void
{ {
// Use null coalescing since config() returns null for missing env vars // Use null coalescing since config() returns null for missing env vars
$claudeKey = config('services.anthropic.api_key') ?? '';
$geminiKey = config('services.google.ai_api_key') ?? '';
$openaiKey = config('services.openai.api_key') ?? '';
if (empty($claudeKey)) {
Log::warning("Agentic: 'claude' provider has no API key configured. Set ANTHROPIC_API_KEY to enable it.");
}
if (empty($geminiKey)) {
Log::warning("Agentic: 'gemini' provider has no API key configured. Set GOOGLE_AI_API_KEY to enable it.");
}
if (empty($openaiKey)) {
Log::warning("Agentic: 'openai' provider has no API key configured. Set OPENAI_API_KEY to enable it.");
}
$this->providers['claude'] = new ClaudeService( $this->providers['claude'] = new ClaudeService(
apiKey: $claudeKey, apiKey: config('services.anthropic.api_key') ?? '',
model: config('services.anthropic.model') ?? 'claude-sonnet-4-20250514', model: config('services.anthropic.model') ?? 'claude-sonnet-4-20250514',
); );
$this->providers['gemini'] = new GeminiService( $this->providers['gemini'] = new GeminiService(
apiKey: $geminiKey, apiKey: config('services.google.ai_api_key') ?? '',
model: config('services.google.ai_model') ?? 'gemini-2.0-flash', model: config('services.google.ai_model') ?? 'gemini-2.0-flash',
); );
$this->providers['openai'] = new OpenAIService( $this->providers['openai'] = new OpenAIService(
apiKey: $openaiKey, apiKey: config('services.openai.api_key') ?? '',
model: config('services.openai.model') ?? 'gpt-4o-mini', model: config('services.openai.model') ?? 'gpt-4o-mini',
); );
} }

View file

@ -4,13 +4,12 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Services; namespace Core\Mod\Agentic\Services;
use Core\Mod\Agentic\Services\Concerns\HasRetry;
use Core\Mod\Agentic\Services\Concerns\HasStreamParsing;
use Generator; use Generator;
use Illuminate\Http\Client\PendingRequest; use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log; use Core\Mod\Agentic\Services\Concerns\HasRetry;
use Throwable; use Core\Mod\Agentic\Services\Concerns\HasStreamParsing;
use RuntimeException;
class ClaudeService implements AgenticProviderInterface class ClaudeService implements AgenticProviderInterface
{ {
@ -60,47 +59,28 @@ class ClaudeService implements AgenticProviderInterface
); );
} }
/**
* Stream a completion from Claude.
*
* Yields text chunks as strings on success.
*
* On failure, yields a single error event array and terminates:
* ['type' => 'error', 'message' => string]
*
* @return Generator<string|array{type: 'error', message: string}>
*/
public function stream( public function stream(
string $systemPrompt, string $systemPrompt,
string $userPrompt, string $userPrompt,
array $config = [] array $config = []
): Generator { ): Generator {
try { $response = $this->client()
$response = $this->client() ->withOptions(['stream' => true])
->withOptions(['stream' => true]) ->post(self::API_URL, [
->post(self::API_URL, [ 'model' => $config['model'] ?? $this->model,
'model' => $config['model'] ?? $this->model, 'max_tokens' => $config['max_tokens'] ?? 4096,
'max_tokens' => $config['max_tokens'] ?? 4096, 'temperature' => $config['temperature'] ?? 1.0,
'temperature' => $config['temperature'] ?? 1.0, 'stream' => true,
'stream' => true, 'system' => $systemPrompt,
'system' => $systemPrompt, 'messages' => [
'messages' => [ ['role' => 'user', 'content' => $userPrompt],
['role' => 'user', 'content' => $userPrompt], ],
],
]);
yield from $this->parseSSEStream(
$response->getBody(),
fn (array $data) => $data['delta']['text'] ?? null
);
} catch (Throwable $e) {
Log::error('Claude stream error', [
'message' => $e->getMessage(),
'exception' => $e,
]); ]);
yield ['type' => 'error', 'message' => $e->getMessage()]; yield from $this->parseSSEStream(
} $response->getBody(),
fn (array $data) => $data['delta']['text'] ?? null
);
} }
public function name(): string public function name(): string

View file

@ -25,6 +25,7 @@ trait HasRetry
* *
* @param callable $callback Function that returns Response * @param callable $callback Function that returns Response
* @param string $provider Provider name for error messages * @param string $provider Provider name for error messages
* @return Response
* *
* @throws RuntimeException * @throws RuntimeException
*/ */

View file

@ -119,7 +119,6 @@ trait HasStreamParsing
$inString = false; $inString = false;
$escape = false; $escape = false;
$objectStart = -1; $objectStart = -1;
$scanPos = 0;
while (! $stream->eof()) { while (! $stream->eof()) {
$chunk = $stream->read(8192); $chunk = $stream->read(8192);
@ -130,10 +129,9 @@ trait HasStreamParsing
$buffer .= $chunk; $buffer .= $chunk;
// Parse JSON objects from the buffer, continuing from where // Parse JSON objects from the buffer
// the previous iteration left off to preserve parser state.
$length = strlen($buffer); $length = strlen($buffer);
$i = $scanPos; $i = 0;
while ($i < $length) { while ($i < $length) {
$char = $buffer[$i]; $char = $buffer[$i];
@ -178,7 +176,6 @@ trait HasStreamParsing
$buffer = substr($buffer, $i + 1); $buffer = substr($buffer, $i + 1);
$length = strlen($buffer); $length = strlen($buffer);
$i = -1; // Will be incremented to 0 $i = -1; // Will be incremented to 0
$scanPos = 0;
$objectStart = -1; $objectStart = -1;
} }
} }
@ -186,9 +183,6 @@ trait HasStreamParsing
$i++; $i++;
} }
// Save scan position so we resume from here on the next chunk
$scanPos = $i;
} }
} }
} }

View file

@ -4,8 +4,8 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Services; namespace Core\Mod\Agentic\Services;
use Illuminate\Support\Facades\File;
use Mod\Content\Models\ContentItem; use Mod\Content\Models\ContentItem;
use Illuminate\Support\Facades\File;
use Symfony\Component\Yaml\Yaml; use Symfony\Component\Yaml\Yaml;
class ContentService class ContentService
@ -118,21 +118,15 @@ class ContentService
/** /**
* Generate content for a batch. * Generate content for a batch.
* *
* Progress is persisted to a state file after each article so the batch
* can be resumed after a partial failure. Call generateBatch() or
* resumeBatch() again to pick up from the last saved state.
*
* @param string $batchId Batch identifier (e.g., 'batch-001-link-getting-started') * @param string $batchId Batch identifier (e.g., 'batch-001-link-getting-started')
* @param string $provider AI provider ('gemini' for bulk, 'claude' for refinement) * @param string $provider AI provider ('gemini' for bulk, 'claude' for refinement)
* @param bool $dryRun If true, shows what would be generated without creating files * @param bool $dryRun If true, shows what would be generated without creating files
* @param int $maxRetries Extra attempts per article on failure (0 = no retry)
* @return array Generation results * @return array Generation results
*/ */
public function generateBatch( public function generateBatch(
string $batchId, string $batchId,
string $provider = 'gemini', string $provider = 'gemini',
bool $dryRun = false, bool $dryRun = false
int $maxRetries = 1,
): array { ): array {
$spec = $this->loadBatch($batchId); $spec = $this->loadBatch($batchId);
if (! $spec) { if (! $spec) {
@ -150,13 +144,6 @@ class ContentService
$promptTemplate = $this->loadPromptTemplate('help-article'); $promptTemplate = $this->loadPromptTemplate('help-article');
// Load or initialise progress state (skipped for dry runs)
$progress = null;
if (! $dryRun) {
$progress = $this->loadBatchProgress($batchId)
?? $this->initialiseBatchState($batchId, $spec['articles'] ?? [], $provider);
}
foreach ($spec['articles'] ?? [] as $article) { foreach ($spec['articles'] ?? [] as $article) {
$slug = $article['slug'] ?? null; $slug = $article['slug'] ?? null;
if (! $slug) { if (! $slug) {
@ -165,13 +152,10 @@ class ContentService
$draftPath = $this->getDraftPath($spec, $slug); $draftPath = $this->getDraftPath($spec, $slug);
// Skip if draft file already exists on disk // Skip if already drafted
if (File::exists($draftPath)) { if (File::exists($draftPath)) {
$results['articles'][$slug] = ['status' => 'skipped', 'reason' => 'already drafted']; $results['articles'][$slug] = ['status' => 'skipped', 'reason' => 'already drafted'];
$results['skipped']++; $results['skipped']++;
if ($progress !== null) {
$progress['articles'][$slug]['status'] = 'skipped';
}
continue; continue;
} }
@ -182,166 +166,18 @@ class ContentService
continue; continue;
} }
// Skip articles successfully generated in a prior run
if (($progress['articles'][$slug]['status'] ?? 'pending') === 'generated') {
$results['articles'][$slug] = ['status' => 'skipped', 'reason' => 'previously generated'];
$results['skipped']++;
continue;
}
$priorAttempts = $progress['articles'][$slug]['attempts'] ?? 0;
$articleResult = $this->attemptArticleGeneration($article, $spec, $promptTemplate, $provider, $maxRetries);
if ($articleResult['status'] === 'generated') {
$results['articles'][$slug] = ['status' => 'generated', 'path' => $articleResult['path']];
$results['generated']++;
$progress['articles'][$slug] = [
'status' => 'generated',
'attempts' => $priorAttempts + $articleResult['attempts'],
'last_error' => null,
'generated_at' => now()->toIso8601String(),
'last_attempt_at' => now()->toIso8601String(),
];
} else {
$results['articles'][$slug] = ['status' => 'failed', 'error' => $articleResult['error']];
$results['failed']++;
$progress['articles'][$slug] = [
'status' => 'failed',
'attempts' => $priorAttempts + $articleResult['attempts'],
'last_error' => $articleResult['error'],
'generated_at' => null,
'last_attempt_at' => now()->toIso8601String(),
];
}
// Persist after each article so a crash mid-batch is recoverable
$progress['last_updated'] = now()->toIso8601String();
$this->saveBatchProgress($batchId, $progress);
}
if ($progress !== null) {
$progress['last_updated'] = now()->toIso8601String();
$this->saveBatchProgress($batchId, $progress);
}
return $results;
}
/**
* Resume a batch from its last saved state.
*
* Articles that were successfully generated are skipped; failed and
* pending articles are retried. Returns an error if no progress state
* exists (i.e. generateBatch() has never been called for this batch).
*/
public function resumeBatch(string $batchId, ?string $provider = null, int $maxRetries = 1): array
{
$progress = $this->loadBatchProgress($batchId);
if ($progress === null) {
return ['error' => "No progress state found for batch: {$batchId}"];
}
$provider ??= $progress['provider'] ?? 'gemini';
$result = $this->generateBatch($batchId, $provider, false, $maxRetries);
$result['resumed_from'] = $progress['last_updated'];
return $result;
}
/**
* Load batch progress state from the state file.
*
* Returns null when no state file exists (batch has not been started).
*/
public function loadBatchProgress(string $batchId): ?array
{
$path = $this->getProgressPath($batchId);
if (! File::exists($path)) {
return null;
}
$data = json_decode(File::get($path), true);
return is_array($data) ? $data : null;
}
/**
* Attempt to generate a single article with retry logic.
*
* Returns ['status' => 'generated', 'path' => ..., 'attempts' => N]
* or ['status' => 'failed', 'error' => ..., 'attempts' => N].
*/
protected function attemptArticleGeneration(
array $article,
array $spec,
string $promptTemplate,
string $provider,
int $maxRetries,
): array {
$draftPath = $this->getDraftPath($spec, $article['slug']);
$lastError = null;
$totalAttempts = $maxRetries + 1;
for ($attempt = 1; $attempt <= $totalAttempts; $attempt++) {
try { try {
$content = $this->generateArticle($article, $spec, $promptTemplate, $provider); $content = $this->generateArticle($article, $spec, $promptTemplate, $provider);
$this->saveDraft($draftPath, $content, $article); $this->saveDraft($draftPath, $content, $article);
$results['articles'][$slug] = ['status' => 'generated', 'path' => $draftPath];
return ['status' => 'generated', 'path' => $draftPath, 'attempts' => $attempt]; $results['generated']++;
} catch (\Exception $e) { } catch (\Exception $e) {
$lastError = $e->getMessage(); $results['articles'][$slug] = ['status' => 'failed', 'error' => $e->getMessage()];
$results['failed']++;
} }
} }
return ['status' => 'failed', 'error' => $lastError, 'attempts' => $totalAttempts]; return $results;
}
/**
* Initialise a fresh batch progress state.
*/
protected function initialiseBatchState(string $batchId, array $articles, string $provider): array
{
$articleStates = [];
foreach ($articles as $article) {
$slug = $article['slug'] ?? null;
if ($slug) {
$articleStates[$slug] = [
'status' => 'pending',
'attempts' => 0,
'last_error' => null,
'generated_at' => null,
'last_attempt_at' => null,
];
}
}
return [
'batch_id' => $batchId,
'provider' => $provider,
'started_at' => now()->toIso8601String(),
'last_updated' => now()->toIso8601String(),
'articles' => $articleStates,
];
}
/**
* Save batch progress state to the state file.
*/
protected function saveBatchProgress(string $batchId, array $state): void
{
File::put($this->getProgressPath($batchId), json_encode($state, JSON_PRETTY_PRINT));
}
/**
* Get the progress state file path for a batch.
*/
protected function getProgressPath(string $batchId): string
{
return base_path("{$this->batchPath}/{$batchId}.progress.json");
} }
/** /**

View file

@ -4,11 +4,12 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Services; namespace Core\Mod\Agentic\Services;
use Core\Mod\Agentic\Services\Concerns\HasRetry;
use Core\Mod\Agentic\Services\Concerns\HasStreamParsing;
use Generator; use Generator;
use Illuminate\Http\Client\PendingRequest; use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Core\Mod\Agentic\Services\Concerns\HasRetry;
use Core\Mod\Agentic\Services\Concerns\HasStreamParsing;
use RuntimeException;
class GeminiService implements AgenticProviderInterface class GeminiService implements AgenticProviderInterface
{ {

View file

@ -4,11 +4,12 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Services; namespace Core\Mod\Agentic\Services;
use Core\Mod\Agentic\Services\Concerns\HasRetry;
use Core\Mod\Agentic\Services\Concerns\HasStreamParsing;
use Generator; use Generator;
use Illuminate\Http\Client\PendingRequest; use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Core\Mod\Agentic\Services\Concerns\HasRetry;
use Core\Mod\Agentic\Services\Concerns\HasStreamParsing;
use RuntimeException;
class OpenAIService implements AgenticProviderInterface class OpenAIService implements AgenticProviderInterface
{ {

40
TODO.md
View file

@ -92,10 +92,10 @@ Production-quality task list for the AI agent orchestration package.
- Adds `agent_plan_id` FK and related columns to `agent_sessions` - Adds `agent_plan_id` FK and related columns to `agent_sessions`
- Includes proper indexes for slug, workspace, and status queries - Includes proper indexes for slug, workspace, and status queries
- [x] **DB-002: Missing indexes on frequently queried columns** (FIXED 2026-02-23) - [ ] **DB-002: Missing indexes on frequently queried columns**
- `agent_sessions.session_id` - unique() constraint creates implicit index; sufficient for lookups - `agent_sessions.session_id` - frequently looked up by string
- `agent_plans.slug` - redundant plain index dropped; compound (workspace_id, slug) index added - `agent_plans.slug` - used in URL routing
- `workspace_states.key` - already indexed via ->index('key') in migration 000003 - `workspace_states.key` - key lookup is common operation
### Error Handling ### Error Handling
@ -104,11 +104,10 @@ Production-quality task list for the AI agent orchestration package.
- Issue: No try/catch around streaming, could fail silently - Issue: No try/catch around streaming, could fail silently
- Fix: Wrap in exception handling, yield error events - Fix: Wrap in exception handling, yield error events
- [x] **ERR-002: ContentService has no batch failure recovery** (FIXED 2026-02-23) - [ ] **ERR-002: ContentService has no batch failure recovery**
- Location: `Services/ContentService.php::generateBatch()` - Location: `Services/ContentService.php::generateBatch()`
- Issue: Failed articles stop processing, no resume capability - Issue: Failed articles stop processing, no resume capability
- Fix: Added progress state file, per-article retry (maxRetries param), `resumeBatch()` method - Fix: Add progress tracking, allow resuming from failed point
- Tests: 6 new tests in `tests/Feature/ContentServiceTest.php` covering state persistence, resume, retries
--- ---
@ -116,15 +115,15 @@ Production-quality task list for the AI agent orchestration package.
### Developer Experience ### Developer Experience
- [x] **DX-001: Missing workspace context error messages unclear** (FIXED 2026-02-23) - [ ] **DX-001: Missing workspace context error messages unclear**
- Location: Multiple MCP tools - Location: Multiple MCP tools
- Issue: "workspace_id is required" didn't explain how to fix - Issue: "workspace_id is required" doesn't explain how to fix
- Fix: Updated error messages in PlanCreate, PlanGet, PlanList, StateSet, StateGet, StateList, SessionStart to include actionable guidance and link to documentation - Fix: Include context about authentication/session setup
- [x] **DX-002: AgenticManager doesn't validate API keys on init** (FIXED 2026-02-23) - [ ] **DX-002: AgenticManager doesn't validate API keys on init**
- Location: `Services/AgenticManager.php::registerProviders()` - Location: `Services/AgenticManager.php::registerProviders()`
- Issue: Empty API key creates provider that fails on first use - Issue: Empty API key creates provider that fails on first use
- Fix: `Log::warning()` emitted for each provider registered without an API key; message names the env var to set - Fix: Log warning or throw if provider configured without key
- [ ] **DX-003: Plan template variable errors not actionable** - [ ] **DX-003: Plan template variable errors not actionable**
- Location: `Services/PlanTemplateService.php::validateVariables()` - Location: `Services/PlanTemplateService.php::validateVariables()`
@ -137,19 +136,15 @@ Production-quality task list for the AI agent orchestration package.
- Issue: Two similar models for same purpose - Issue: Two similar models for same purpose
- Fix: Consolidate into single model, or clarify distinct purposes - Fix: Consolidate into single model, or clarify distinct purposes
- [x] **CQ-002: ApiKeyManager uses Core\Api\ApiKey, not AgentApiKey** (FIXED 2026-02-23) - [ ] **CQ-002: ApiKeyManager uses Core\Api\ApiKey, not AgentApiKey**
- Location: `View/Modal/Admin/ApiKeyManager.php` - Location: `View/Modal/Admin/ApiKeyManager.php`
- Issue: Livewire component uses different API key model than services - Issue: Livewire component uses different API key model than services
- Fix: Switched to `AgentApiKey` model and `AgentApiKeyService` throughout - Fix: Unify on AgentApiKey or document distinction
- Updated blade template to use `permissions`, `getMaskedKey()`, `togglePermission()`
- Added integration tests in `tests/Feature/ApiKeyManagerTest.php`
- [x] **CQ-003: ForAgentsController cache key not namespaced** (FIXED 2026-02-23) - [ ] **CQ-003: ForAgentsController cache key not namespaced**
- Location: `Controllers/ForAgentsController.php` - Location: `Controllers/ForAgentsController.php`
- Issue: `Cache::remember('agentic.for-agents.json', ...)` could collide - Issue: `Cache::remember('agentic.for-agents.json', ...)` could collide
- Fix: Cache key and TTL now driven by `mcp.cache.for_agents_key` / `mcp.cache.for_agents_ttl` config - Fix: Add workspace prefix or use config-based key
- Added `cacheKey()` public method and config entries in `config.php`
- Tests added in `tests/Feature/ForAgentsControllerTest.php`
### Performance ### Performance
@ -169,10 +164,10 @@ Production-quality task list for the AI agent orchestration package.
### Documentation Gaps ### Documentation Gaps
- [x] **DOC-001: Add PHPDoc to AgentDetection patterns** (FIXED 2026-02-23) - [ ] **DOC-001: Add PHPDoc to AgentDetection patterns**
- Location: `Services/AgentDetection.php` - Location: `Services/AgentDetection.php`
- Issue: User-Agent patterns undocumented - Issue: User-Agent patterns undocumented
- Fix: Added PHPDoc with real UA examples to all pattern constants, class-level usage examples, and MCP_TOKEN_HEADER docs - Fix: Document each pattern with agent examples
- [ ] **DOC-002: Document MCP tool dependency system** - [ ] **DOC-002: Document MCP tool dependency system**
- Location: `Mcp/Tools/Agent/` directory - Location: `Mcp/Tools/Agent/` directory
@ -288,7 +283,6 @@ Production-quality task list for the AI agent orchestration package.
### Database (Fixed) ### Database (Fixed)
- [x] DB-001: Missing agent_plans migration - Created 0001_01_01_000003_create_agent_plans_tables.php (2026-01-29) - [x] DB-001: Missing agent_plans migration - Created 0001_01_01_000003_create_agent_plans_tables.php (2026-01-29)
- [x] DB-002: Performance indexes - Dropped redundant slug index, added compound (workspace_id, slug) index (2026-02-23)
--- ---

View file

@ -68,17 +68,18 @@
</td> </td>
<td class="px-6 py-4 whitespace-nowrap"> <td class="px-6 py-4 whitespace-nowrap">
<code class="text-sm bg-zinc-100 dark:bg-zinc-700 px-2 py-1 rounded font-mono"> <code class="text-sm bg-zinc-100 dark:bg-zinc-700 px-2 py-1 rounded font-mono">
{{ $key->getMaskedKey() }} {{ $key->prefix }}_****
</code> </code>
</td> </td>
<td class="px-6 py-4"> <td class="px-6 py-4 whitespace-nowrap">
<div class="flex flex-wrap gap-1"> <div class="flex gap-1">
@foreach($key->permissions ?? [] as $permission) @foreach($key->scopes ?? [] as $scope)
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium
{{ str_ends_with($permission, '.read') ? 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300' : '' }} {{ $scope === 'read' ? 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300' : '' }}
{{ str_ends_with($permission, '.write') || str_ends_with($permission, '.send') || str_ends_with($permission, '.instantiate') ? 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300' : '' }} {{ $scope === 'write' ? 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300' : '' }}
{{ $scope === 'delete' ? 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300' : '' }}
"> ">
{{ $permission }} {{ $scope }}
</span> </span>
@endforeach @endforeach
</div> </div>
@ -130,11 +131,11 @@
<div class="space-y-3"> <div class="space-y-3">
<div> <div>
<p class="text-xs font-medium text-zinc-700 dark:text-zinc-300 mb-1">{{ __('mcp::mcp.keys.auth.header_recommended') }}</p> <p class="text-xs font-medium text-zinc-700 dark:text-zinc-300 mb-1">{{ __('mcp::mcp.keys.auth.header_recommended') }}</p>
<pre class="bg-zinc-100 dark:bg-zinc-900 rounded-lg p-2 overflow-x-auto text-xs"><code class="text-zinc-800 dark:text-zinc-200">Authorization: Bearer ak_****</code></pre> <pre class="bg-zinc-100 dark:bg-zinc-900 rounded-lg p-2 overflow-x-auto text-xs"><code class="text-zinc-800 dark:text-zinc-200">Authorization: Bearer hk_abc123_****</code></pre>
</div> </div>
<div> <div>
<p class="text-xs font-medium text-zinc-700 dark:text-zinc-300 mb-1">{{ __('mcp::mcp.keys.auth.header_api_key') }}</p> <p class="text-xs font-medium text-zinc-700 dark:text-zinc-300 mb-1">{{ __('mcp::mcp.keys.auth.header_api_key') }}</p>
<pre class="bg-zinc-100 dark:bg-zinc-900 rounded-lg p-2 overflow-x-auto text-xs"><code class="text-zinc-800 dark:text-zinc-200">X-API-Key: ak_****</code></pre> <pre class="bg-zinc-100 dark:bg-zinc-900 rounded-lg p-2 overflow-x-auto text-xs"><code class="text-zinc-800 dark:text-zinc-200">X-API-Key: hk_abc123_****</code></pre>
</div> </div>
</div> </div>
</div> </div>
@ -178,24 +179,37 @@
@enderror @enderror
</div> </div>
<!-- Permissions --> <!-- Scopes -->
<div> <div>
<core:label>{{ __('mcp::mcp.keys.create_modal.permissions_label') }}</core:label> <core:label>{{ __('mcp::mcp.keys.create_modal.permissions_label') }}</core:label>
<div class="mt-2 space-y-2"> <div class="mt-2 space-y-2">
@foreach($this->availablePermissions() as $permission => $description) <label class="flex items-center gap-2">
<label class="flex items-start gap-2"> <input
<input type="checkbox"
type="checkbox" wire:click="toggleScope('read')"
wire:click="togglePermission('{{ $permission }}')" {{ in_array('read', $newKeyScopes) ? 'checked' : '' }}
{{ in_array($permission, $newKeyPermissions) ? 'checked' : '' }} class="rounded border-zinc-300 text-cyan-600 focus:ring-cyan-500"
class="mt-0.5 rounded border-zinc-300 text-cyan-600 focus:ring-cyan-500" >
> <span class="text-sm text-zinc-700 dark:text-zinc-300">{{ __('mcp::mcp.keys.create_modal.permission_read') }}</span>
<span class="text-sm text-zinc-700 dark:text-zinc-300"> </label>
<span class="font-mono text-xs text-zinc-500">{{ $permission }}</span> <label class="flex items-center gap-2">
<span class="block text-xs text-zinc-500 dark:text-zinc-400">{{ $description }}</span> <input
</span> type="checkbox"
</label> wire:click="toggleScope('write')"
@endforeach {{ in_array('write', $newKeyScopes) ? 'checked' : '' }}
class="rounded border-zinc-300 text-cyan-600 focus:ring-cyan-500"
>
<span class="text-sm text-zinc-700 dark:text-zinc-300">{{ __('mcp::mcp.keys.create_modal.permission_write') }}</span>
</label>
<label class="flex items-center gap-2">
<input
type="checkbox"
wire:click="toggleScope('delete')"
{{ in_array('delete', $newKeyScopes) ? 'checked' : '' }}
class="rounded border-zinc-300 text-zinc-600 focus:ring-cyan-500"
>
<span class="text-sm text-zinc-700 dark:text-zinc-300">{{ __('mcp::mcp.keys.create_modal.permission_delete') }}</span>
</label>
</div> </div>
</div> </div>

View file

@ -4,8 +4,7 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\View\Modal\Admin; namespace Core\Mod\Agentic\View\Modal\Admin;
use Core\Mod\Agentic\Models\AgentApiKey; use Core\Api\Models\ApiKey;
use Core\Mod\Agentic\Services\AgentApiKeyService;
use Core\Tenant\Models\Workspace; use Core\Tenant\Models\Workspace;
use Livewire\Attributes\Layout; use Livewire\Attributes\Layout;
use Livewire\Component; use Livewire\Component;
@ -26,7 +25,7 @@ class ApiKeyManager extends Component
public string $newKeyName = ''; public string $newKeyName = '';
public array $newKeyPermissions = []; public array $newKeyScopes = ['read', 'write'];
public string $newKeyExpiry = 'never'; public string $newKeyExpiry = 'never';
@ -44,7 +43,7 @@ class ApiKeyManager extends Component
{ {
$this->showCreateModal = true; $this->showCreateModal = true;
$this->newKeyName = ''; $this->newKeyName = '';
$this->newKeyPermissions = []; $this->newKeyScopes = ['read', 'write'];
$this->newKeyExpiry = 'never'; $this->newKeyExpiry = 'never';
} }
@ -53,11 +52,6 @@ class ApiKeyManager extends Component
$this->showCreateModal = false; $this->showCreateModal = false;
} }
public function availablePermissions(): array
{
return AgentApiKey::availablePermissions();
}
public function createKey(): void public function createKey(): void
{ {
$this->validate([ $this->validate([
@ -71,14 +65,15 @@ class ApiKeyManager extends Component
default => null, default => null,
}; };
$key = app(AgentApiKeyService::class)->create( $result = ApiKey::generate(
workspace: $this->workspace, workspaceId: $this->workspace->id,
userId: auth()->id(),
name: $this->newKeyName, name: $this->newKeyName,
permissions: $this->newKeyPermissions, scopes: $this->newKeyScopes,
expiresAt: $expiresAt, expiresAt: $expiresAt,
); );
$this->newPlainKey = $key->plainTextKey; $this->newPlainKey = $result['plain_key'];
$this->showCreateModal = false; $this->showCreateModal = false;
$this->showNewKeyModal = true; $this->showNewKeyModal = true;
@ -93,25 +88,25 @@ class ApiKeyManager extends Component
public function revokeKey(int $keyId): void public function revokeKey(int $keyId): void
{ {
$key = AgentApiKey::forWorkspace($this->workspace)->findOrFail($keyId); $key = $this->workspace->apiKeys()->findOrFail($keyId);
$key->revoke(); $key->revoke();
session()->flash('message', 'API key revoked.'); session()->flash('message', 'API key revoked.');
} }
public function togglePermission(string $permission): void public function toggleScope(string $scope): void
{ {
if (in_array($permission, $this->newKeyPermissions)) { if (in_array($scope, $this->newKeyScopes)) {
$this->newKeyPermissions = array_values(array_diff($this->newKeyPermissions, [$permission])); $this->newKeyScopes = array_values(array_diff($this->newKeyScopes, [$scope]));
} else { } else {
$this->newKeyPermissions[] = $permission; $this->newKeyScopes[] = $scope;
} }
} }
public function render() public function render()
{ {
return view('agentic::admin.api-key-manager', [ return view('mcp::admin.api-key-manager', [
'keys' => AgentApiKey::forWorkspace($this->workspace)->orderByDesc('created_at')->get(), 'keys' => $this->workspace->apiKeys()->orderByDesc('created_at')->get(),
]); ]);
} }
} }

View file

@ -4,8 +4,6 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\View\Modal\Admin; namespace Core\Mod\Agentic\View\Modal\Admin;
use Core\Mod\Agentic\Models\AgentApiKey;
use Core\Mod\Agentic\Services\AgentApiKeyService;
use Core\Tenant\Models\Workspace; use Core\Tenant\Models\Workspace;
use Illuminate\Contracts\View\View; use Illuminate\Contracts\View\View;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
@ -15,6 +13,8 @@ use Livewire\Attributes\Title;
use Livewire\Attributes\Url; use Livewire\Attributes\Url;
use Livewire\Component; use Livewire\Component;
use Livewire\WithPagination; use Livewire\WithPagination;
use Core\Mod\Agentic\Models\AgentApiKey;
use Core\Mod\Agentic\Services\AgentApiKeyService;
use Symfony\Component\HttpFoundation\StreamedResponse; use Symfony\Component\HttpFoundation\StreamedResponse;
#[Title('API Keys')] #[Title('API Keys')]

View file

@ -4,12 +4,12 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\View\Modal\Admin; namespace Core\Mod\Agentic\View\Modal\Admin;
use Core\Mcp\Models\McpToolCallStat;
use Core\Mod\Agentic\Models\AgentPlan; use Core\Mod\Agentic\Models\AgentPlan;
use Core\Mod\Agentic\Models\AgentSession; use Core\Mod\Agentic\Models\AgentSession;
use Illuminate\Cache\Lock; use Core\Mcp\Models\McpToolCallStat;
use Illuminate\Contracts\View\View; use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Cache\Lock;
use Livewire\Attributes\Computed; use Livewire\Attributes\Computed;
use Livewire\Attributes\Layout; use Livewire\Attributes\Layout;
use Livewire\Attributes\Title; use Livewire\Attributes\Title;

View file

@ -4,13 +4,13 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\View\Modal\Admin; namespace Core\Mod\Agentic\View\Modal\Admin;
use Core\Mod\Agentic\Models\AgentSession;
use Illuminate\Contracts\View\View; use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use Livewire\Attributes\Computed; use Livewire\Attributes\Computed;
use Livewire\Attributes\Layout; use Livewire\Attributes\Layout;
use Livewire\Attributes\Title; use Livewire\Attributes\Title;
use Livewire\Component; use Livewire\Component;
use Core\Mod\Agentic\Models\AgentSession;
#[Title('Session Detail')] #[Title('Session Detail')]
#[Layout('hub::admin.layouts.app')] #[Layout('hub::admin.layouts.app')]

View file

@ -4,9 +4,9 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\View\Modal\Admin; namespace Core\Mod\Agentic\View\Modal\Admin;
use Core\Tenant\Models\Workspace;
use Core\Mcp\Models\McpToolCall; use Core\Mcp\Models\McpToolCall;
use Core\Mcp\Models\McpToolCallStat; use Core\Mcp\Models\McpToolCallStat;
use Core\Tenant\Models\Workspace;
use Illuminate\Contracts\View\View; use Illuminate\Contracts\View\View;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Livewire\Attributes\Computed; use Livewire\Attributes\Computed;

View file

@ -4,9 +4,9 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\View\Modal\Admin; namespace Core\Mod\Agentic\View\Modal\Admin;
use Core\Tenant\Models\Workspace;
use Core\Mcp\Models\McpToolCall; use Core\Mcp\Models\McpToolCall;
use Core\Mcp\Models\McpToolCallStat; use Core\Mcp\Models\McpToolCallStat;
use Core\Tenant\Models\Workspace;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Contracts\View\View; use Illuminate\Contracts\View\View;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;

View file

@ -1,53 +1,50 @@
{ {
"name": "host-uk/core-agentic", "name": "host-uk/core-agentic",
"description": "AI agent orchestration and MCP tools for Laravel", "description": "AI agent orchestration and MCP tools for Laravel",
"keywords": [ "keywords": [
"ai", "ai",
"agents", "agents",
"mcp", "mcp",
"orchestration" "orchestration"
], ],
"license": "EUPL-1.2", "license": "EUPL-1.2",
"require": { "require": {
"php": "^8.2", "php": "^8.2",
"host-uk/core": "dev-main" "host-uk/core": "dev-main"
}, },
"require-dev": { "require-dev": {
"laravel/pint": "^1.18", "laravel/pint": "^1.18",
"livewire/livewire": "^3.0", "orchestra/testbench": "^9.0|^10.0",
"orchestra/testbench": "^9.0|^10.0", "pestphp/pest": "^3.0"
"pestphp/pest": "^3.0", },
"pestphp/pest-plugin-livewire": "^3.0" "autoload": {
}, "psr-4": {
"autoload": { "Core\\Mod\\Agentic\\": "",
"psr-4": { "Core\\Service\\Agentic\\": "Service/"
"Core\\Mod\\Agentic\\": "", }
"Core\\Service\\Agentic\\": "Service/" },
} "autoload-dev": {
}, "psr-4": {
"autoload-dev": { "Core\\Mod\\Agentic\\Tests\\": "Tests/"
"psr-4": { }
"Core\\Mod\\Agentic\\Tests\\": "tests/", },
"Tests\\": "tests/" "extra": {
} "laravel": {
}, "providers": [
"extra": { "Core\\Mod\\Agentic\\Boot"
"laravel": { ]
"providers": [ }
"Core\\Mod\\Agentic\\Boot" },
] "scripts": {
} "lint": "pint",
}, "test": "pest"
"scripts": { },
"lint": "pint", "config": {
"test": "pest" "sort-packages": true,
}, "allow-plugins": {
"config": { "pestphp/pest-plugin": true
"sort-packages": true, }
"allow-plugins": { },
"pestphp/pest-plugin": true "minimum-stability": "dev",
} "prefer-stable": true
},
"minimum-stability": "dev",
"prefer-stable": true
} }

View file

@ -56,19 +56,4 @@ return [
'drafts_path' => 'app/Mod/Agentic/Resources/drafts', 'drafts_path' => 'app/Mod/Agentic/Resources/drafts',
], ],
/*
|--------------------------------------------------------------------------
| Cache Keys
|--------------------------------------------------------------------------
|
| Namespaced cache keys used by agentic endpoints. Override these in your
| application config to prevent collisions with other modules.
|
*/
'cache' => [
'for_agents_key' => 'agentic.for-agents.json',
'for_agents_ttl' => 3600,
],
]; ];

View file

@ -1,39 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
cacheDirectory=".phpunit.cache"
executionOrder="random"
requireCoverageMetadata="false"
beStrictAboutCoverageMetadata="false"
beStrictAboutOutputDuringTests="true"
failOnRisky="true"
failOnWarning="true">
<testsuites>
<testsuite name="Feature">
<directory>tests/Feature</directory>
</testsuite>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>src</directory>
</include>
</source>
<php>
<env name="APP_ENV" value="testing"/>
<env name="APP_DEBUG" value="true"/>
<env name="APP_KEY" value="base64:Kx0qLJZJAQcDSFE2gMpuOlwrJcC6kXHM0j0KJdMGqzQ="/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="CACHE_STORE" value="array"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
<env name="MAIL_MAILER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="TELESCOPE_ENABLED" value="false"/>
</php>
</phpunit>

View file

@ -1,3 +1,5 @@
<?php <?php
use Illuminate\Support\Facades\Route;
// API routes are registered via Core modules // API routes are registered via Core modules

View file

@ -4,10 +4,10 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Tests\Feature; namespace Core\Mod\Agentic\Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Core\Mod\Agentic\Models\AgentPhase; use Core\Mod\Agentic\Models\AgentPhase;
use Core\Mod\Agentic\Models\AgentPlan; use Core\Mod\Agentic\Models\AgentPlan;
use Core\Tenant\Models\Workspace; use Core\Tenant\Models\Workspace;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase; use Tests\TestCase;
class AgentPhaseTest extends TestCase class AgentPhaseTest extends TestCase
@ -286,78 +286,6 @@ class AgentPhaseTest extends TestCase
$this->assertEquals($dep2->id, $blockers[0]['phase_id']); $this->assertEquals($dep2->id, $blockers[0]['phase_id']);
} }
public function test_check_dependencies_returns_empty_when_no_dependencies(): void
{
$phase = AgentPhase::factory()->pending()->create([
'agent_plan_id' => $this->plan->id,
'dependencies' => null,
]);
$this->assertSame([], $phase->checkDependencies());
}
public function test_check_dependencies_not_blocked_by_skipped_phase(): void
{
$dep = AgentPhase::factory()->skipped()->create([
'agent_plan_id' => $this->plan->id,
'order' => 1,
]);
$phase = AgentPhase::factory()->pending()->create([
'agent_plan_id' => $this->plan->id,
'order' => 2,
'dependencies' => [$dep->id],
]);
$this->assertSame([], $phase->checkDependencies());
$this->assertTrue($phase->canStart());
}
public function test_check_dependencies_uses_single_query_for_multiple_deps(): void
{
$deps = AgentPhase::factory()->pending()->count(5)->create([
'agent_plan_id' => $this->plan->id,
]);
$phase = AgentPhase::factory()->pending()->create([
'agent_plan_id' => $this->plan->id,
'dependencies' => $deps->pluck('id')->toArray(),
]);
$queryCount = 0;
\DB::listen(function () use (&$queryCount) {
$queryCount++;
});
$blockers = $phase->checkDependencies();
$this->assertCount(5, $blockers);
$this->assertSame(1, $queryCount, 'checkDependencies() should issue exactly one query');
}
public function test_check_dependencies_blocker_contains_expected_keys(): void
{
$dep = AgentPhase::factory()->inProgress()->create([
'agent_plan_id' => $this->plan->id,
'order' => 1,
'name' => 'Blocker Phase',
]);
$phase = AgentPhase::factory()->pending()->create([
'agent_plan_id' => $this->plan->id,
'order' => 2,
'dependencies' => [$dep->id],
]);
$blockers = $phase->checkDependencies();
$this->assertCount(1, $blockers);
$this->assertEquals($dep->id, $blockers[0]['phase_id']);
$this->assertEquals(1, $blockers[0]['phase_order']);
$this->assertEquals('Blocker Phase', $blockers[0]['phase_name']);
$this->assertEquals(AgentPhase::STATUS_IN_PROGRESS, $blockers[0]['status']);
}
public function test_can_start_checks_dependencies(): void public function test_can_start_checks_dependencies(): void
{ {
$dep = AgentPhase::factory()->pending()->create([ $dep = AgentPhase::factory()->pending()->create([

View file

@ -4,10 +4,10 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Tests\Feature; namespace Core\Mod\Agentic\Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Core\Mod\Agentic\Models\AgentPhase; use Core\Mod\Agentic\Models\AgentPhase;
use Core\Mod\Agentic\Models\AgentPlan; use Core\Mod\Agentic\Models\AgentPlan;
use Core\Tenant\Models\Workspace; use Core\Tenant\Models\Workspace;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase; use Tests\TestCase;
class AgentPlanTest extends TestCase class AgentPlanTest extends TestCase

View file

@ -4,10 +4,10 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Tests\Feature; namespace Core\Mod\Agentic\Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Core\Mod\Agentic\Models\AgentPlan; use Core\Mod\Agentic\Models\AgentPlan;
use Core\Mod\Agentic\Models\AgentSession; use Core\Mod\Agentic\Models\AgentSession;
use Core\Tenant\Models\Workspace; use Core\Tenant\Models\Workspace;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase; use Tests\TestCase;
class AgentSessionTest extends TestCase class AgentSessionTest extends TestCase

View file

@ -1,254 +0,0 @@
<?php
declare(strict_types=1);
/**
* Integration tests for ApiKeyManager admin UI component.
*
* Verifies that ApiKeyManager consistently uses AgentApiKey model
* for all create, list, and revoke operations.
*/
use Core\Mod\Agentic\Models\AgentApiKey;
use Core\Mod\Agentic\Services\AgentApiKeyService;
use Core\Tenant\Models\Workspace;
// =========================================================================
// Model Consistency Tests
// =========================================================================
describe('ApiKeyManager model consistency', function () {
it('ApiKeyManager uses AgentApiKey class', function () {
$source = file_get_contents(__DIR__.'/../../View/Modal/Admin/ApiKeyManager.php');
expect($source)
->toContain('Core\Mod\Agentic\Models\AgentApiKey')
->not->toContain('Core\Api\Models\ApiKey')
->not->toContain('Core\Api\ApiKey');
});
it('ApiKeyManager uses AgentApiKeyService', function () {
$source = file_get_contents(__DIR__.'/../../View/Modal/Admin/ApiKeyManager.php');
expect($source)->toContain('Core\Mod\Agentic\Services\AgentApiKeyService');
});
it('ApiKeyManager does not reference old scopes property', function () {
$source = file_get_contents(__DIR__.'/../../View/Modal/Admin/ApiKeyManager.php');
expect($source)
->not->toContain('newKeyScopes')
->not->toContain('toggleScope');
});
it('blade template uses permissions not scopes', function () {
$source = file_get_contents(__DIR__.'/../../View/Blade/admin/api-key-manager.blade.php');
expect($source)
->toContain('$key->permissions')
->not->toContain('$key->scopes');
});
it('blade template uses getMaskedKey not prefix', function () {
$source = file_get_contents(__DIR__.'/../../View/Blade/admin/api-key-manager.blade.php');
expect($source)
->toContain('getMaskedKey()')
->not->toContain('$key->prefix');
});
it('blade template calls togglePermission not toggleScope', function () {
$source = file_get_contents(__DIR__.'/../../View/Blade/admin/api-key-manager.blade.php');
expect($source)
->toContain('togglePermission')
->not->toContain('toggleScope');
});
});
// =========================================================================
// AgentApiKey Integration Tests (via service, as used by ApiKeyManager)
// =========================================================================
describe('ApiKeyManager key creation integration', function () {
it('creates an AgentApiKey via service', function () {
$workspace = createWorkspace();
$service = app(AgentApiKeyService::class);
$key = $service->create(
workspace: $workspace,
name: 'Workspace MCP Key',
permissions: [AgentApiKey::PERM_PLANS_READ, AgentApiKey::PERM_SESSIONS_READ],
);
expect($key)->toBeInstanceOf(AgentApiKey::class)
->and($key->name)->toBe('Workspace MCP Key')
->and($key->workspace_id)->toBe($workspace->id)
->and($key->permissions)->toContain(AgentApiKey::PERM_PLANS_READ)
->and($key->plainTextKey)->toStartWith('ak_');
});
it('plain text key is only available once after creation', function () {
$workspace = createWorkspace();
$service = app(AgentApiKeyService::class);
$key = $service->create($workspace, 'One-time key');
expect($key->plainTextKey)->not->toBeNull();
$freshKey = AgentApiKey::find($key->id);
expect($freshKey->plainTextKey)->toBeNull();
});
it('creates key with expiry date', function () {
$workspace = createWorkspace();
$service = app(AgentApiKeyService::class);
$expiresAt = now()->addDays(30);
$key = $service->create(
workspace: $workspace,
name: 'Expiring Key',
expiresAt: $expiresAt,
);
expect($key->expires_at)->not->toBeNull()
->and($key->expires_at->toDateString())->toBe($expiresAt->toDateString());
});
it('creates key with no expiry when null passed', function () {
$workspace = createWorkspace();
$service = app(AgentApiKeyService::class);
$key = $service->create($workspace, 'Permanent Key', expiresAt: null);
expect($key->expires_at)->toBeNull();
});
});
// =========================================================================
// Workspace Scoping (used by ApiKeyManager::revokeKey and render)
// =========================================================================
describe('ApiKeyManager workspace scoping', function () {
it('forWorkspace scope returns only keys for given workspace', function () {
$workspace1 = createWorkspace();
$workspace2 = createWorkspace();
$key1 = createApiKey($workspace1, 'Key for workspace 1');
$key2 = createApiKey($workspace2, 'Key for workspace 2');
$keys = AgentApiKey::forWorkspace($workspace1)->get();
expect($keys)->toHaveCount(1)
->and($keys->first()->id)->toBe($key1->id);
});
it('forWorkspace accepts workspace model', function () {
$workspace = createWorkspace();
createApiKey($workspace, 'Key');
$keys = AgentApiKey::forWorkspace($workspace)->get();
expect($keys)->toHaveCount(1);
});
it('forWorkspace accepts workspace ID', function () {
$workspace = createWorkspace();
createApiKey($workspace, 'Key');
$keys = AgentApiKey::forWorkspace($workspace->id)->get();
expect($keys)->toHaveCount(1);
});
it('forWorkspace prevents cross-workspace key access', function () {
$workspace1 = createWorkspace();
$workspace2 = createWorkspace();
$key = createApiKey($workspace1, 'Workspace 1 key');
// Attempting to find workspace1's key while scoped to workspace2
$found = AgentApiKey::forWorkspace($workspace2)->find($key->id);
expect($found)->toBeNull();
});
});
// =========================================================================
// Revoke Integration (as used by ApiKeyManager::revokeKey)
// =========================================================================
describe('ApiKeyManager key revocation integration', function () {
it('revokes a key via service', function () {
$workspace = createWorkspace();
$key = createApiKey($workspace, 'Key to revoke');
$service = app(AgentApiKeyService::class);
expect($key->isActive())->toBeTrue();
$service->revoke($key);
expect($key->fresh()->isRevoked())->toBeTrue();
});
it('revoked key is inactive', function () {
$workspace = createWorkspace();
$key = createApiKey($workspace, 'Key to revoke');
$key->revoke();
expect($key->isActive())->toBeFalse()
->and($key->isRevoked())->toBeTrue();
});
it('revoking clears validation', function () {
$workspace = createWorkspace();
$key = createApiKey($workspace, 'Key to revoke');
$service = app(AgentApiKeyService::class);
$plainKey = $key->plainTextKey;
$service->revoke($key);
$validated = $service->validate($plainKey);
expect($validated)->toBeNull();
});
});
// =========================================================================
// Available Permissions (used by ApiKeyManager::availablePermissions)
// =========================================================================
describe('ApiKeyManager available permissions', function () {
it('AgentApiKey provides available permissions list', function () {
$permissions = AgentApiKey::availablePermissions();
expect($permissions)
->toBeArray()
->toHaveKey(AgentApiKey::PERM_PLANS_READ)
->toHaveKey(AgentApiKey::PERM_PLANS_WRITE)
->toHaveKey(AgentApiKey::PERM_SESSIONS_READ)
->toHaveKey(AgentApiKey::PERM_SESSIONS_WRITE);
});
it('permission constants match available permissions keys', function () {
$permissions = AgentApiKey::availablePermissions();
expect(array_keys($permissions))
->toContain(AgentApiKey::PERM_PLANS_READ)
->toContain(AgentApiKey::PERM_PHASES_WRITE)
->toContain(AgentApiKey::PERM_TEMPLATES_READ);
});
it('key can be created with any available permission', function () {
$workspace = createWorkspace();
$allPermissions = array_keys(AgentApiKey::availablePermissions());
$key = createApiKey($workspace, 'Full Access', $allPermissions);
expect($key->permissions)->toBe($allPermissions);
foreach ($allPermissions as $permission) {
expect($key->hasPermission($permission))->toBeTrue();
}
});
});

View file

@ -2,21 +2,9 @@
use Core\Mod\Agentic\Services\AgenticManager; use Core\Mod\Agentic\Services\AgenticManager;
use Core\Mod\Agentic\Services\AgenticProviderInterface; use Core\Mod\Agentic\Services\AgenticProviderInterface;
use Core\Mod\Agentic\Services\AgenticResponse;
use Core\Mod\Agentic\Services\ContentService; use Core\Mod\Agentic\Services\ContentService;
use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\File;
function makeAgenticResponse(string $content = '## Article Content'): AgenticResponse
{
return new AgenticResponse(
content: $content,
model: 'test-model',
inputTokens: 0,
outputTokens: 0,
durationMs: 0,
);
}
beforeEach(function () { beforeEach(function () {
$this->manager = Mockery::mock(AgenticManager::class); $this->manager = Mockery::mock(AgenticManager::class);
$this->service = new ContentService($this->manager); $this->service = new ContentService($this->manager);
@ -72,15 +60,11 @@ it('handles generation errors gracefully', function () {
File::put($testBatchPath, "# Test Batch\n**Service:** Test\n### Article 1:\n```yaml\nSLUG: test-slug-error\nTITLE: Test\n```"); File::put($testBatchPath, "# Test Batch\n**Service:** Test\n### Article 1:\n```yaml\nSLUG: test-slug-error\nTITLE: Test\n```");
// Clean up potential leftover draft and state files // Clean up potential leftover draft
$draftPath = base_path('app/Mod/Agentic/Resources/drafts/help/general/test-slug-error.md'); $draftPath = base_path('app/Mod/Agentic/Resources/drafts/help/general/test-slug-error.md');
$statePath = base_path('app/Mod/Agentic/Resources/tasks/batch-test-error.progress.json');
if (File::exists($draftPath)) { if (File::exists($draftPath)) {
File::delete($draftPath); File::delete($draftPath);
} }
if (File::exists($statePath)) {
File::delete($statePath);
}
try { try {
$results = $this->service->generateBatch('batch-test-error', 'gemini', false); $results = $this->service->generateBatch('batch-test-error', 'gemini', false);
@ -95,227 +79,5 @@ it('handles generation errors gracefully', function () {
if (File::exists($draftPath)) { if (File::exists($draftPath)) {
File::delete($draftPath); File::delete($draftPath);
} }
if (File::exists($statePath)) {
File::delete($statePath);
}
}
});
it('returns null progress when no state file exists', function () {
$progress = $this->service->loadBatchProgress('batch-nonexistent-xyz');
expect($progress)->toBeNull();
});
it('saves progress state after batch generation', function () {
$provider = Mockery::mock(AgenticProviderInterface::class);
$provider->shouldReceive('generate')->andThrow(new \Exception('API Error'));
$this->manager->shouldReceive('provider')->with('gemini')->andReturn($provider);
$promptPath = base_path('app/Mod/Agentic/Resources/prompts/content/help-article.md');
if (! File::exists($promptPath)) {
$this->markTestSkipped('Help article prompt not found');
}
$batchId = 'batch-test-progress';
$batchPath = base_path("app/Mod/Agentic/Resources/tasks/{$batchId}.md");
$statePath = base_path("app/Mod/Agentic/Resources/tasks/{$batchId}.progress.json");
File::put($batchPath, "# Test Batch\n**Service:** Test\n### Article 1:\n```yaml\nSLUG: progress-slug-a\nTITLE: Test A\n```\n### Article 2:\n```yaml\nSLUG: progress-slug-b\nTITLE: Test B\n```");
try {
$this->service->generateBatch($batchId, 'gemini', false, 0);
$progress = $this->service->loadBatchProgress($batchId);
expect($progress)->toBeArray();
expect($progress['batch_id'])->toBe($batchId);
expect($progress['provider'])->toBe('gemini');
expect($progress['articles'])->toHaveKeys(['progress-slug-a', 'progress-slug-b']);
expect($progress['articles']['progress-slug-a']['status'])->toBe('failed');
expect($progress['articles']['progress-slug-a']['attempts'])->toBe(1);
expect($progress['articles']['progress-slug-a']['last_error'])->toBe('API Error');
} finally {
File::deleteDirectory(base_path('app/Mod/Agentic/Resources/drafts/help/general'), true);
if (File::exists($batchPath)) {
File::delete($batchPath);
}
if (File::exists($statePath)) {
File::delete($statePath);
}
}
});
it('skips previously generated articles on second run', function () {
$callCount = 0;
$provider = Mockery::mock(AgenticProviderInterface::class);
$provider->shouldReceive('generate')
->andReturnUsing(function () use (&$callCount) {
$callCount++;
return makeAgenticResponse();
});
$this->manager->shouldReceive('provider')->with('gemini')->andReturn($provider);
$promptPath = base_path('app/Mod/Agentic/Resources/prompts/content/help-article.md');
if (! File::exists($promptPath)) {
$this->markTestSkipped('Help article prompt not found');
}
$batchId = 'batch-test-resume-skip';
$batchPath = base_path("app/Mod/Agentic/Resources/tasks/{$batchId}.md");
$statePath = base_path("app/Mod/Agentic/Resources/tasks/{$batchId}.progress.json");
$draftDir = base_path('app/Mod/Agentic/Resources/drafts/help/general');
File::put($batchPath, "# Test Batch\n**Service:** Test\n### Article 1:\n```yaml\nSLUG: resume-skip-slug-a\nTITLE: Test A\n```\n### Article 2:\n```yaml\nSLUG: resume-skip-slug-b\nTITLE: Test B\n```");
try {
// First run generates both articles
$first = $this->service->generateBatch($batchId, 'gemini', false, 0);
expect($first['generated'])->toBe(2);
expect($callCount)->toBe(2);
// Second run skips already-generated articles
$second = $this->service->generateBatch($batchId, 'gemini', false, 0);
expect($second['generated'])->toBe(0);
expect($second['skipped'])->toBe(2);
// Provider should not have been called again
expect($callCount)->toBe(2);
} finally {
foreach (['resume-skip-slug-a', 'resume-skip-slug-b'] as $slug) {
$draft = "{$draftDir}/{$slug}.md";
if (File::exists($draft)) {
File::delete($draft);
}
}
if (File::exists($batchPath)) {
File::delete($batchPath);
}
if (File::exists($statePath)) {
File::delete($statePath);
}
}
});
it('resume returns error when no prior state exists', function () {
$result = $this->service->resumeBatch('batch-no-state-xyz');
expect($result)->toHaveKey('error');
expect($result['error'])->toContain('No progress state found');
});
it('resume retries only failed and pending articles', function () {
$slugs = ['resume-retry-a', 'resume-retry-b'];
$callCount = 0;
$provider = Mockery::mock(AgenticProviderInterface::class);
$provider->shouldReceive('generate')
->andReturnUsing(function () use (&$callCount) {
$callCount++;
// Call 1: A on first run → fails
// Call 2: B on first run → succeeds
// Resume run: only A is retried (B is already generated)
if ($callCount === 1) {
throw new \Exception('Transient Error');
}
return makeAgenticResponse('## Content');
});
$this->manager->shouldReceive('provider')->with('gemini')->andReturn($provider);
$promptPath = base_path('app/Mod/Agentic/Resources/prompts/content/help-article.md');
if (! File::exists($promptPath)) {
$this->markTestSkipped('Help article prompt not found');
}
$batchId = 'batch-test-resume-retry';
$batchPath = base_path("app/Mod/Agentic/Resources/tasks/{$batchId}.md");
$statePath = base_path("app/Mod/Agentic/Resources/tasks/{$batchId}.progress.json");
$draftDir = base_path('app/Mod/Agentic/Resources/drafts/help/general');
File::put($batchPath, "# Test Batch\n**Service:** Test\n### Article 1:\n```yaml\nSLUG: resume-retry-a\nTITLE: Retry A\n```\n### Article 2:\n```yaml\nSLUG: resume-retry-b\nTITLE: Retry B\n```");
try {
// First run: A fails, B succeeds
$first = $this->service->generateBatch($batchId, 'gemini', false, 0);
expect($first['failed'])->toBe(1);
expect($first['generated'])->toBe(1);
expect($first['articles']['resume-retry-a']['status'])->toBe('failed');
expect($first['articles']['resume-retry-b']['status'])->toBe('generated');
// Resume: only retries failed article A
$resumed = $this->service->resumeBatch($batchId, 'gemini', 0);
expect($resumed)->toHaveKey('resumed_from');
expect($resumed['skipped'])->toBeGreaterThanOrEqual(1); // B is skipped
expect($resumed['articles']['resume-retry-b']['status'])->toBe('skipped');
} finally {
foreach ($slugs as $slug) {
$draft = "{$draftDir}/{$slug}.md";
if (File::exists($draft)) {
File::delete($draft);
}
}
if (File::exists($batchPath)) {
File::delete($batchPath);
}
if (File::exists($statePath)) {
File::delete($statePath);
}
}
});
it('retries individual failures up to maxRetries times', function () {
$callCount = 0;
$provider = Mockery::mock(AgenticProviderInterface::class);
$provider->shouldReceive('generate')
->andReturnUsing(function () use (&$callCount) {
$callCount++;
if ($callCount < 3) {
throw new \Exception("Attempt {$callCount} failed");
}
return makeAgenticResponse('## Content');
});
$this->manager->shouldReceive('provider')->with('gemini')->andReturn($provider);
$promptPath = base_path('app/Mod/Agentic/Resources/prompts/content/help-article.md');
if (! File::exists($promptPath)) {
$this->markTestSkipped('Help article prompt not found');
}
$batchId = 'batch-test-maxretries';
$batchPath = base_path("app/Mod/Agentic/Resources/tasks/{$batchId}.md");
$statePath = base_path("app/Mod/Agentic/Resources/tasks/{$batchId}.progress.json");
$draftPath = base_path('app/Mod/Agentic/Resources/drafts/help/general/maxretries-slug.md');
File::put($batchPath, "# Test Batch\n**Service:** Test\n### Article 1:\n```yaml\nSLUG: maxretries-slug\nTITLE: Retry Test\n```");
try {
// With maxRetries=2 (3 total attempts), succeeds on 3rd attempt
$results = $this->service->generateBatch($batchId, 'gemini', false, 2);
expect($results['generated'])->toBe(1);
expect($results['failed'])->toBe(0);
expect($results['articles']['maxretries-slug']['status'])->toBe('generated');
expect($callCount)->toBe(3);
$progress = $this->service->loadBatchProgress($batchId);
expect($progress['articles']['maxretries-slug']['status'])->toBe('generated');
expect($progress['articles']['maxretries-slug']['attempts'])->toBe(3);
} finally {
if (File::exists($batchPath)) {
File::delete($batchPath);
}
if (File::exists($statePath)) {
File::delete($statePath);
}
if (File::exists($draftPath)) {
File::delete($draftPath);
}
} }
}); });

View file

@ -1,148 +0,0 @@
<?php
declare(strict_types=1);
/**
* Tests for ForAgentsController cache key namespacing (CQ-003).
*
* Verifies that the cache key is config-based to prevent cross-module collisions,
* and that cache invalidation uses the same namespaced key.
*/
use Core\Mod\Agentic\Controllers\ForAgentsController;
use Illuminate\Support\Facades\Cache;
// =========================================================================
// Cache Key Tests
// =========================================================================
describe('ForAgentsController cache key', function () {
it('uses the default namespaced cache key', function () {
$controller = new ForAgentsController;
expect($controller->cacheKey())->toBe('agentic.for-agents.json');
});
it('uses a custom cache key when configured', function () {
config(['mcp.cache.for_agents_key' => 'custom-module.for-agents.json']);
$controller = new ForAgentsController;
expect($controller->cacheKey())->toBe('custom-module.for-agents.json');
});
it('returns to default key after config is cleared', function () {
config(['mcp.cache.for_agents_key' => null]);
$controller = new ForAgentsController;
expect($controller->cacheKey())->toBe('agentic.for-agents.json');
});
});
// =========================================================================
// Cache Behaviour Tests
// =========================================================================
describe('ForAgentsController cache behaviour', function () {
it('stores data under the namespaced cache key', function () {
Cache::fake();
$controller = new ForAgentsController;
$controller();
$key = $controller->cacheKey();
expect(Cache::has($key))->toBeTrue();
});
it('returns cached data on subsequent calls', function () {
Cache::fake();
$controller = new ForAgentsController;
$first = $controller();
$second = $controller();
expect($first->getContent())->toBe($second->getContent());
});
it('respects the configured TTL', function () {
config(['mcp.cache.for_agents_ttl' => 7200]);
Cache::fake();
$controller = new ForAgentsController;
$response = $controller();
expect($response->headers->get('Cache-Control'))->toContain('max-age=7200');
});
it('uses default TTL of 3600 when not configured', function () {
config(['mcp.cache.for_agents_ttl' => null]);
Cache::fake();
$controller = new ForAgentsController;
$response = $controller();
expect($response->headers->get('Cache-Control'))->toContain('max-age=3600');
});
it('can be invalidated using the namespaced key', function () {
Cache::fake();
$controller = new ForAgentsController;
$controller();
$key = $controller->cacheKey();
expect(Cache::has($key))->toBeTrue();
Cache::forget($key);
expect(Cache::has($key))->toBeFalse();
});
it('stores data under the custom key when configured', function () {
config(['mcp.cache.for_agents_key' => 'tenant-a.for-agents.json']);
Cache::fake();
$controller = new ForAgentsController;
$controller();
expect(Cache::has('tenant-a.for-agents.json'))->toBeTrue();
expect(Cache::has('agentic.for-agents.json'))->toBeFalse();
});
});
// =========================================================================
// Response Structure Tests
// =========================================================================
describe('ForAgentsController response', function () {
it('returns a JSON response', function () {
Cache::fake();
$controller = new ForAgentsController;
$response = $controller();
expect($response->headers->get('Content-Type'))->toContain('application/json');
});
it('response contains platform information', function () {
Cache::fake();
$controller = new ForAgentsController;
$response = $controller();
$data = json_decode($response->getContent(), true);
expect($data)->toHaveKey('platform')
->and($data['platform'])->toHaveKey('name');
});
it('response contains capabilities', function () {
Cache::fake();
$controller = new ForAgentsController;
$response = $controller();
$data = json_decode($response->getContent(), true);
expect($data)->toHaveKey('capabilities')
->and($data['capabilities'])->toHaveKey('mcp_servers');
});
});

View file

@ -1,272 +0,0 @@
<?php
declare(strict_types=1);
/**
* Tests for the BatchContentGeneration queue job.
*
* Covers job configuration, queue assignment, tag generation, and dispatch behaviour.
* The handle() integration requires ContentTask from host-uk/core and is tested
* via queue dispatch assertions and alias mocking where the table is unavailable.
*/
use Core\Mod\Agentic\Jobs\BatchContentGeneration;
use Core\Mod\Agentic\Jobs\ProcessContentTask;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Queue;
// =========================================================================
// Job Configuration Tests
// =========================================================================
describe('job configuration', function () {
it('has a 600 second timeout', function () {
$job = new BatchContentGeneration;
expect($job->timeout)->toBe(600);
});
it('defaults to normal priority', function () {
$job = new BatchContentGeneration;
expect($job->priority)->toBe('normal');
});
it('defaults to a batch size of 10', function () {
$job = new BatchContentGeneration;
expect($job->batchSize)->toBe(10);
});
it('accepts a custom priority', function () {
$job = new BatchContentGeneration('high');
expect($job->priority)->toBe('high');
});
it('accepts a custom batch size', function () {
$job = new BatchContentGeneration('normal', 25);
expect($job->batchSize)->toBe(25);
});
it('accepts both custom priority and batch size', function () {
$job = new BatchContentGeneration('low', 5);
expect($job->priority)->toBe('low')
->and($job->batchSize)->toBe(5);
});
it('implements ShouldQueue', function () {
$job = new BatchContentGeneration;
expect($job)->toBeInstanceOf(\Illuminate\Contracts\Queue\ShouldQueue::class);
});
});
// =========================================================================
// Queue Assignment Tests
// =========================================================================
describe('queue assignment', function () {
it('dispatches to the ai-batch queue', function () {
Queue::fake();
BatchContentGeneration::dispatch();
Queue::assertPushedOn('ai-batch', BatchContentGeneration::class);
});
it('dispatches with correct priority when specified', function () {
Queue::fake();
BatchContentGeneration::dispatch('high', 5);
Queue::assertPushed(BatchContentGeneration::class, function ($job) {
return $job->priority === 'high' && $job->batchSize === 5;
});
});
it('dispatches with default values when no arguments given', function () {
Queue::fake();
BatchContentGeneration::dispatch();
Queue::assertPushed(BatchContentGeneration::class, function ($job) {
return $job->priority === 'normal' && $job->batchSize === 10;
});
});
it('can be dispatched multiple times with different priorities', function () {
Queue::fake();
BatchContentGeneration::dispatch('high');
BatchContentGeneration::dispatch('low');
Queue::assertPushed(BatchContentGeneration::class, 2);
});
});
// =========================================================================
// Tag Generation Tests
// =========================================================================
describe('tags', function () {
it('always includes the batch-generation tag', function () {
$job = new BatchContentGeneration;
expect($job->tags())->toContain('batch-generation');
});
it('includes a priority tag for normal priority', function () {
$job = new BatchContentGeneration('normal');
expect($job->tags())->toContain('priority:normal');
});
it('includes a priority tag for high priority', function () {
$job = new BatchContentGeneration('high');
expect($job->tags())->toContain('priority:high');
});
it('includes a priority tag for low priority', function () {
$job = new BatchContentGeneration('low');
expect($job->tags())->toContain('priority:low');
});
it('returns exactly two tags', function () {
$job = new BatchContentGeneration;
expect($job->tags())->toHaveCount(2);
});
it('returns an array', function () {
$job = new BatchContentGeneration;
expect($job->tags())->toBeArray();
});
});
// =========================================================================
// Job Chaining / Dependencies Tests
// =========================================================================
describe('job chaining', function () {
it('ProcessContentTask can be dispatched from BatchContentGeneration logic', function () {
Queue::fake();
// Simulate what handle() does when tasks are found:
// dispatch a ProcessContentTask for each task
$mockTask = Mockery::mock('Mod\Content\Models\ContentTask');
ProcessContentTask::dispatch($mockTask);
Queue::assertPushed(ProcessContentTask::class, 1);
});
it('ProcessContentTask is dispatched to the ai queue', function () {
Queue::fake();
$mockTask = Mockery::mock('Mod\Content\Models\ContentTask');
ProcessContentTask::dispatch($mockTask);
Queue::assertPushedOn('ai', ProcessContentTask::class);
});
it('multiple ProcessContentTask jobs can be chained', function () {
Queue::fake();
$tasks = [
Mockery::mock('Mod\Content\Models\ContentTask'),
Mockery::mock('Mod\Content\Models\ContentTask'),
Mockery::mock('Mod\Content\Models\ContentTask'),
];
foreach ($tasks as $task) {
ProcessContentTask::dispatch($task);
}
Queue::assertPushed(ProcessContentTask::class, 3);
});
});
// =========================================================================
// Handle Empty Task Collection Tests
// =========================================================================
describe('handle with no matching tasks', function () {
it('logs an info message when no tasks are found', function () {
Log::shouldReceive('info')
->once()
->with('BatchContentGeneration: No normal priority tasks to process');
// Build an empty collection for the query result
$emptyCollection = collect([]);
$builder = Mockery::mock(\Illuminate\Database\Eloquent\Builder::class);
$builder->shouldReceive('where')->andReturnSelf();
$builder->shouldReceive('orWhere')->andReturnSelf();
$builder->shouldReceive('orderBy')->andReturnSelf();
$builder->shouldReceive('limit')->andReturnSelf();
$builder->shouldReceive('get')->andReturn($emptyCollection);
// Alias mock for the static query() call
$taskMock = Mockery::mock('alias:Mod\Content\Models\ContentTask');
$taskMock->shouldReceive('query')->andReturn($builder);
$job = new BatchContentGeneration('normal', 10);
$job->handle();
})->skip('Alias mocking requires process isolation; covered by integration tests.');
it('does not dispatch any ProcessContentTask when collection is empty', function () {
Queue::fake();
// Verify that when tasks is empty, no ProcessContentTask jobs are dispatched
// This tests the early-return path conceptually
$emptyTasks = collect([]);
if ($emptyTasks->isEmpty()) {
// Simulates handle() early return
Log::info('BatchContentGeneration: No normal priority tasks to process');
} else {
foreach ($emptyTasks as $task) {
ProcessContentTask::dispatch($task);
}
}
Queue::assertNothingPushed();
});
});
// =========================================================================
// Handle With Tasks Tests
// =========================================================================
describe('handle with matching tasks', function () {
it('dispatches one ProcessContentTask per task', function () {
Queue::fake();
$tasks = collect([
Mockery::mock('Mod\Content\Models\ContentTask'),
Mockery::mock('Mod\Content\Models\ContentTask'),
]);
// Simulate handle() dispatch loop
foreach ($tasks as $task) {
ProcessContentTask::dispatch($task);
}
Queue::assertPushed(ProcessContentTask::class, 2);
});
it('respects the batch size limit', function () {
// BatchContentGeneration queries with ->limit($this->batchSize)
// Verify the batch size property is used as the limit
$job = new BatchContentGeneration('normal', 5);
expect($job->batchSize)->toBe(5);
});
});

View file

@ -1,813 +0,0 @@
<?php
declare(strict_types=1);
/**
* Tests for the ProcessContentTask queue job.
*
* Covers job configuration, execution paths, error handling, retry logic,
* and the stub processOutput() implementation.
* Uses Mockery to isolate the job from external dependencies.
*/
use Core\Mod\Agentic\Jobs\ProcessContentTask;
use Core\Mod\Agentic\Services\AgenticManager;
use Core\Mod\Agentic\Services\AgenticProviderInterface;
use Core\Mod\Agentic\Services\AgenticResponse;
use Illuminate\Support\Facades\Queue;
// =========================================================================
// Helpers
// =========================================================================
/**
* Build a mock ContentTask with sensible defaults.
*
* @param array<string, mixed> $overrides
*/
function mockContentTask(array $overrides = []): \Mockery\MockInterface
{
$prompt = Mockery::mock('Mod\Content\Models\ContentPrompt');
$prompt->model = $overrides['prompt_model'] ?? 'claude';
$prompt->user_template = $overrides['user_template'] ?? 'Hello {{name}}';
$prompt->system_prompt = $overrides['system_prompt'] ?? 'You are helpful.';
$prompt->model_config = $overrides['model_config'] ?? [];
$prompt->id = $overrides['prompt_id'] ?? 1;
$task = Mockery::mock('Mod\Content\Models\ContentTask');
$task->id = $overrides['task_id'] ?? 1;
$task->prompt = array_key_exists('prompt', $overrides) ? $overrides['prompt'] : $prompt;
$task->workspace = $overrides['workspace'] ?? null;
$task->input_data = $overrides['input_data'] ?? [];
$task->target_type = $overrides['target_type'] ?? null;
$task->target_id = $overrides['target_id'] ?? null;
$task->target = $overrides['target'] ?? null;
$task->shouldReceive('markProcessing')->andReturnNull()->byDefault();
$task->shouldReceive('markFailed')->andReturnNull()->byDefault();
$task->shouldReceive('markCompleted')->andReturnNull()->byDefault();
return $task;
}
/**
* Build a mock AgenticResponse.
*/
function mockAgenticResponse(array $overrides = []): AgenticResponse
{
return new AgenticResponse(
content: $overrides['content'] ?? 'Generated content',
model: $overrides['model'] ?? 'claude-sonnet-4-20250514',
inputTokens: $overrides['inputTokens'] ?? 100,
outputTokens: $overrides['outputTokens'] ?? 50,
stopReason: $overrides['stopReason'] ?? 'end_turn',
durationMs: $overrides['durationMs'] ?? 1000,
raw: $overrides['raw'] ?? [],
);
}
/**
* Build a mock EntitlementResult.
*/
function mockEntitlementResult(bool $denied = false, string $message = ''): object
{
return new class($denied, $message)
{
public function __construct(
private readonly bool $denied,
public readonly string $message,
) {}
public function isDenied(): bool
{
return $this->denied;
}
};
}
// =========================================================================
// Job Configuration Tests
// =========================================================================
describe('job configuration', function () {
it('retries up to 3 times', function () {
$task = mockContentTask();
$job = new ProcessContentTask($task);
expect($job->tries)->toBe(3);
});
it('backs off for 60 seconds between retries', function () {
$task = mockContentTask();
$job = new ProcessContentTask($task);
expect($job->backoff)->toBe(60);
});
it('has a 300 second timeout', function () {
$task = mockContentTask();
$job = new ProcessContentTask($task);
expect($job->timeout)->toBe(300);
});
it('dispatches to the ai queue', function () {
Queue::fake();
$task = mockContentTask();
ProcessContentTask::dispatch($task);
Queue::assertPushedOn('ai', ProcessContentTask::class);
});
it('implements ShouldQueue', function () {
$task = mockContentTask();
$job = new ProcessContentTask($task);
expect($job)->toBeInstanceOf(\Illuminate\Contracts\Queue\ShouldQueue::class);
});
it('stores the task on the job', function () {
$task = mockContentTask(['task_id' => 42]);
$job = new ProcessContentTask($task);
expect($job->task->id)->toBe(42);
});
});
// =========================================================================
// Failed Handler Tests
// =========================================================================
describe('failed handler', function () {
it('marks the task as failed with the exception message', function () {
$task = mockContentTask();
$task->shouldReceive('markFailed')
->once()
->with('Something went wrong');
$job = new ProcessContentTask($task);
$job->failed(new \RuntimeException('Something went wrong'));
});
it('marks the task as failed with any throwable message', function () {
$task = mockContentTask();
$task->shouldReceive('markFailed')
->once()
->with('Database connection lost');
$job = new ProcessContentTask($task);
$job->failed(new \Exception('Database connection lost'));
});
it('uses the exception message verbatim', function () {
$task = mockContentTask();
$capturedMessage = null;
$task->shouldReceive('markFailed')
->once()
->andReturnUsing(function (string $message) use (&$capturedMessage) {
$capturedMessage = $message;
});
$job = new ProcessContentTask($task);
$job->failed(new \RuntimeException('Detailed error: code 503'));
expect($capturedMessage)->toBe('Detailed error: code 503');
});
});
// =========================================================================
// Handle Early Exit: Missing Prompt
// =========================================================================
describe('handle with missing prompt', function () {
it('marks the task failed when prompt is null', function () {
$task = mockContentTask(['prompt' => null]);
$task->shouldReceive('markProcessing')->once();
$task->shouldReceive('markFailed')
->once()
->with('Prompt not found');
$ai = Mockery::mock(AgenticManager::class);
$processor = Mockery::mock('Mod\Content\Services\ContentProcessingService');
$entitlements = Mockery::mock('Core\Tenant\Services\EntitlementService');
$job = new ProcessContentTask($task);
$job->handle($ai, $processor, $entitlements);
});
it('does not call the AI provider when prompt is missing', function () {
$task = mockContentTask(['prompt' => null]);
$task->shouldReceive('markProcessing')->once();
$task->shouldReceive('markFailed')->once();
$ai = Mockery::mock(AgenticManager::class);
$ai->shouldNotReceive('provider');
$processor = Mockery::mock('Mod\Content\Services\ContentProcessingService');
$entitlements = Mockery::mock('Core\Tenant\Services\EntitlementService');
$job = new ProcessContentTask($task);
$job->handle($ai, $processor, $entitlements);
});
});
// =========================================================================
// Handle Early Exit: Entitlement Denied
// =========================================================================
describe('handle with denied entitlement', function () {
it('marks the task failed when entitlement is denied', function () {
$workspace = Mockery::mock('Core\Tenant\Models\Workspace');
$task = mockContentTask(['workspace' => $workspace]);
$task->shouldReceive('markProcessing')->once();
$task->shouldReceive('markFailed')
->once()
->with('Entitlement denied: Insufficient credits');
$ai = Mockery::mock(AgenticManager::class);
$processor = Mockery::mock('Mod\Content\Services\ContentProcessingService');
$result = mockEntitlementResult(denied: true, message: 'Insufficient credits');
$entitlements = Mockery::mock('Core\Tenant\Services\EntitlementService');
$entitlements->shouldReceive('can')
->once()
->with($workspace, 'ai.credits')
->andReturn($result);
$job = new ProcessContentTask($task);
$job->handle($ai, $processor, $entitlements);
});
it('does not invoke the AI provider when entitlement is denied', function () {
$workspace = Mockery::mock('Core\Tenant\Models\Workspace');
$task = mockContentTask(['workspace' => $workspace]);
$task->shouldReceive('markProcessing')->once();
$task->shouldReceive('markFailed')->once();
$ai = Mockery::mock(AgenticManager::class);
$ai->shouldNotReceive('provider');
$processor = Mockery::mock('Mod\Content\Services\ContentProcessingService');
$result = mockEntitlementResult(denied: true, message: 'Out of credits');
$entitlements = Mockery::mock('Core\Tenant\Services\EntitlementService');
$entitlements->shouldReceive('can')->andReturn($result);
$job = new ProcessContentTask($task);
$job->handle($ai, $processor, $entitlements);
});
it('skips entitlement check when task has no workspace', function () {
$task = mockContentTask(['workspace' => null]);
$task->shouldReceive('markProcessing')->once();
$provider = Mockery::mock(AgenticProviderInterface::class);
$provider->shouldReceive('isAvailable')->andReturn(false);
$provider->shouldReceive('name')->andReturn('claude')->byDefault();
$ai = Mockery::mock(AgenticManager::class);
$ai->shouldReceive('provider')->andReturn($provider);
$processor = Mockery::mock('Mod\Content\Services\ContentProcessingService');
$entitlements = Mockery::mock('Core\Tenant\Services\EntitlementService');
$entitlements->shouldNotReceive('can');
$task->shouldReceive('markFailed')
->once()
->with(Mockery::pattern('/is not configured/'));
$job = new ProcessContentTask($task);
$job->handle($ai, $processor, $entitlements);
});
});
// =========================================================================
// Handle Early Exit: Provider Unavailable
// =========================================================================
describe('handle with unavailable provider', function () {
it('marks the task failed when provider is not configured', function () {
$task = mockContentTask();
$task->shouldReceive('markProcessing')->once();
$task->shouldReceive('markFailed')
->once()
->with("AI provider 'claude' is not configured");
$provider = Mockery::mock(AgenticProviderInterface::class);
$provider->shouldReceive('isAvailable')->andReturn(false);
$ai = Mockery::mock(AgenticManager::class);
$ai->shouldReceive('provider')->with('claude')->andReturn($provider);
$processor = Mockery::mock('Mod\Content\Services\ContentProcessingService');
$entitlements = Mockery::mock('Core\Tenant\Services\EntitlementService');
$job = new ProcessContentTask($task);
$job->handle($ai, $processor, $entitlements);
});
it('includes the provider name in the failure message', function () {
$task = mockContentTask(['prompt_model' => 'gemini']);
$task->shouldReceive('markProcessing')->once();
$task->shouldReceive('markFailed')
->once()
->with("AI provider 'gemini' is not configured");
$provider = Mockery::mock(AgenticProviderInterface::class);
$provider->shouldReceive('isAvailable')->andReturn(false);
$ai = Mockery::mock(AgenticManager::class);
$ai->shouldReceive('provider')->with('gemini')->andReturn($provider);
$processor = Mockery::mock('Mod\Content\Services\ContentProcessingService');
$entitlements = Mockery::mock('Core\Tenant\Services\EntitlementService');
$job = new ProcessContentTask($task);
$job->handle($ai, $processor, $entitlements);
});
});
// =========================================================================
// Handle Successful Execution (without workspace)
// =========================================================================
describe('handle with successful generation (no workspace)', function () {
it('marks the task as processing then completed', function () {
$task = mockContentTask([
'workspace' => null,
'input_data' => ['name' => 'World'],
]);
$task->shouldReceive('markProcessing')->once();
$task->shouldReceive('markCompleted')
->once()
->with('Generated content', Mockery::type('array'));
$response = mockAgenticResponse();
$provider = Mockery::mock(AgenticProviderInterface::class);
$provider->shouldReceive('isAvailable')->andReturn(true);
$provider->shouldReceive('generate')
->once()
->andReturn($response);
$ai = Mockery::mock(AgenticManager::class);
$ai->shouldReceive('provider')->with('claude')->andReturn($provider);
$processor = Mockery::mock('Mod\Content\Services\ContentProcessingService');
$entitlements = Mockery::mock('Core\Tenant\Services\EntitlementService');
$job = new ProcessContentTask($task);
$job->handle($ai, $processor, $entitlements);
});
it('passes interpolated user prompt to the provider', function () {
$task = mockContentTask([
'workspace' => null,
'user_template' => 'Hello {{name}}, your ID is {{id}}',
'input_data' => ['name' => 'Alice', 'id' => '42'],
]);
$task->shouldReceive('markProcessing')->once();
$task->shouldReceive('markCompleted')->once();
$response = mockAgenticResponse();
$provider = Mockery::mock(AgenticProviderInterface::class);
$provider->shouldReceive('isAvailable')->andReturn(true);
$provider->shouldReceive('generate')
->once()
->with(
Mockery::any(),
'Hello Alice, your ID is 42',
Mockery::any(),
)
->andReturn($response);
$ai = Mockery::mock(AgenticManager::class);
$ai->shouldReceive('provider')->andReturn($provider);
$processor = Mockery::mock('Mod\Content\Services\ContentProcessingService');
$entitlements = Mockery::mock('Core\Tenant\Services\EntitlementService');
$job = new ProcessContentTask($task);
$job->handle($ai, $processor, $entitlements);
});
it('passes system prompt to the provider', function () {
$task = mockContentTask([
'workspace' => null,
'system_prompt' => 'You are a content writer.',
]);
$task->shouldReceive('markProcessing')->once();
$task->shouldReceive('markCompleted')->once();
$response = mockAgenticResponse();
$provider = Mockery::mock(AgenticProviderInterface::class);
$provider->shouldReceive('isAvailable')->andReturn(true);
$provider->shouldReceive('generate')
->once()
->with('You are a content writer.', Mockery::any(), Mockery::any())
->andReturn($response);
$ai = Mockery::mock(AgenticManager::class);
$ai->shouldReceive('provider')->andReturn($provider);
$processor = Mockery::mock('Mod\Content\Services\ContentProcessingService');
$entitlements = Mockery::mock('Core\Tenant\Services\EntitlementService');
$job = new ProcessContentTask($task);
$job->handle($ai, $processor, $entitlements);
});
it('includes token and cost metadata when marking completed', function () {
$task = mockContentTask(['workspace' => null]);
$task->shouldReceive('markProcessing')->once();
$capturedMeta = null;
$task->shouldReceive('markCompleted')
->once()
->andReturnUsing(function (string $content, array $meta) use (&$capturedMeta) {
$capturedMeta = $meta;
});
$response = mockAgenticResponse([
'inputTokens' => 120,
'outputTokens' => 60,
'model' => 'claude-sonnet-4-20250514',
'durationMs' => 2500,
]);
$provider = Mockery::mock(AgenticProviderInterface::class);
$provider->shouldReceive('isAvailable')->andReturn(true);
$provider->shouldReceive('generate')->andReturn($response);
$ai = Mockery::mock(AgenticManager::class);
$ai->shouldReceive('provider')->andReturn($provider);
$processor = Mockery::mock('Mod\Content\Services\ContentProcessingService');
$entitlements = Mockery::mock('Core\Tenant\Services\EntitlementService');
$job = new ProcessContentTask($task);
$job->handle($ai, $processor, $entitlements);
expect($capturedMeta)
->toHaveKey('tokens_input', 120)
->toHaveKey('tokens_output', 60)
->toHaveKey('model', 'claude-sonnet-4-20250514')
->toHaveKey('duration_ms', 2500)
->toHaveKey('estimated_cost');
});
it('does not record usage when workspace is absent', function () {
$task = mockContentTask(['workspace' => null]);
$task->shouldReceive('markProcessing')->once();
$task->shouldReceive('markCompleted')->once();
$provider = Mockery::mock(AgenticProviderInterface::class);
$provider->shouldReceive('isAvailable')->andReturn(true);
$provider->shouldReceive('generate')->andReturn(mockAgenticResponse());
$ai = Mockery::mock(AgenticManager::class);
$ai->shouldReceive('provider')->andReturn($provider);
$processor = Mockery::mock('Mod\Content\Services\ContentProcessingService');
$entitlements = Mockery::mock('Core\Tenant\Services\EntitlementService');
$entitlements->shouldNotReceive('recordUsage');
$job = new ProcessContentTask($task);
$job->handle($ai, $processor, $entitlements);
});
});
// =========================================================================
// Handle Successful Execution (with workspace)
// =========================================================================
describe('handle with successful generation (with workspace)', function () {
it('records AI usage after successful generation', function () {
$workspace = Mockery::mock('Core\Tenant\Models\Workspace');
$task = mockContentTask(['workspace' => $workspace, 'task_id' => 7]);
$task->shouldReceive('markProcessing')->once();
$task->shouldReceive('markCompleted')->once();
$response = mockAgenticResponse(['inputTokens' => 80, 'outputTokens' => 40]);
$allowedResult = mockEntitlementResult(denied: false);
$provider = Mockery::mock(AgenticProviderInterface::class);
$provider->shouldReceive('isAvailable')->andReturn(true);
$provider->shouldReceive('generate')->andReturn($response);
$ai = Mockery::mock(AgenticManager::class);
$ai->shouldReceive('provider')->andReturn($provider);
$processor = Mockery::mock('Mod\Content\Services\ContentProcessingService');
$entitlements = Mockery::mock('Core\Tenant\Services\EntitlementService');
$entitlements->shouldReceive('can')
->once()
->with($workspace, 'ai.credits')
->andReturn($allowedResult);
$entitlements->shouldReceive('recordUsage')
->once()
->with(
$workspace,
'ai.credits',
quantity: 1,
metadata: Mockery::type('array'),
);
$job = new ProcessContentTask($task);
$job->handle($ai, $processor, $entitlements);
});
it('includes task and prompt metadata in usage recording', function () {
$workspace = Mockery::mock('Core\Tenant\Models\Workspace');
$task = mockContentTask([
'workspace' => $workspace,
'task_id' => 99,
'prompt_id' => 5,
]);
$task->shouldReceive('markProcessing')->once();
$task->shouldReceive('markCompleted')->once();
$response = mockAgenticResponse();
$allowedResult = mockEntitlementResult(denied: false);
$provider = Mockery::mock(AgenticProviderInterface::class);
$provider->shouldReceive('isAvailable')->andReturn(true);
$provider->shouldReceive('generate')->andReturn($response);
$ai = Mockery::mock(AgenticManager::class);
$ai->shouldReceive('provider')->andReturn($provider);
$processor = Mockery::mock('Mod\Content\Services\ContentProcessingService');
$capturedMeta = null;
$entitlements = Mockery::mock('Core\Tenant\Services\EntitlementService');
$entitlements->shouldReceive('can')->andReturn($allowedResult);
$entitlements->shouldReceive('recordUsage')
->once()
->andReturnUsing(function ($ws, $key, $quantity, $metadata) use (&$capturedMeta) {
$capturedMeta = $metadata;
});
$job = new ProcessContentTask($task);
$job->handle($ai, $processor, $entitlements);
expect($capturedMeta)
->toHaveKey('task_id', 99)
->toHaveKey('prompt_id', 5);
});
});
// =========================================================================
// Handle processOutput Stub Tests
// =========================================================================
describe('processOutput stub', function () {
it('completes without error when task has no target', function () {
$task = mockContentTask([
'workspace' => null,
'target_type' => null,
'target_id' => null,
]);
$task->shouldReceive('markProcessing')->once();
$task->shouldReceive('markCompleted')->once();
$provider = Mockery::mock(AgenticProviderInterface::class);
$provider->shouldReceive('isAvailable')->andReturn(true);
$provider->shouldReceive('generate')->andReturn(mockAgenticResponse());
$ai = Mockery::mock(AgenticManager::class);
$ai->shouldReceive('provider')->andReturn($provider);
$processor = Mockery::mock('Mod\Content\Services\ContentProcessingService');
$entitlements = Mockery::mock('Core\Tenant\Services\EntitlementService');
$job = new ProcessContentTask($task);
// Should complete without exception
expect(fn () => $job->handle($ai, $processor, $entitlements))->not->toThrow(\Exception::class);
});
it('completes without error when task has a target but no matching model (stub behaviour)', function () {
// processOutput() is currently a stub: it logs nothing and returns
// when the target is null. This test documents the stub behaviour.
$task = mockContentTask([
'workspace' => null,
'target_type' => 'App\\Models\\Article',
'target_id' => 1,
'target' => null, // target relationship not resolved
]);
$task->shouldReceive('markProcessing')->once();
$task->shouldReceive('markCompleted')->once();
$provider = Mockery::mock(AgenticProviderInterface::class);
$provider->shouldReceive('isAvailable')->andReturn(true);
$provider->shouldReceive('generate')->andReturn(mockAgenticResponse());
$ai = Mockery::mock(AgenticManager::class);
$ai->shouldReceive('provider')->andReturn($provider);
$processor = Mockery::mock('Mod\Content\Services\ContentProcessingService');
$entitlements = Mockery::mock('Core\Tenant\Services\EntitlementService');
$job = new ProcessContentTask($task);
expect(fn () => $job->handle($ai, $processor, $entitlements))->not->toThrow(\Exception::class);
});
it('calls processOutput when both target_type and target_id are set', function () {
$target = Mockery::mock('stdClass');
$task = mockContentTask([
'workspace' => null,
'target_type' => 'App\\Models\\Article',
'target_id' => 5,
'target' => $target,
]);
$task->shouldReceive('markProcessing')->once();
$task->shouldReceive('markCompleted')->once();
$provider = Mockery::mock(AgenticProviderInterface::class);
$provider->shouldReceive('isAvailable')->andReturn(true);
$provider->shouldReceive('generate')->andReturn(mockAgenticResponse());
$ai = Mockery::mock(AgenticManager::class);
$ai->shouldReceive('provider')->andReturn($provider);
// ContentProcessingService is passed but the stub does not call it
$processor = Mockery::mock('Mod\Content\Services\ContentProcessingService');
$entitlements = Mockery::mock('Core\Tenant\Services\EntitlementService');
$job = new ProcessContentTask($task);
expect(fn () => $job->handle($ai, $processor, $entitlements))->not->toThrow(\Exception::class);
});
});
// =========================================================================
// Variable Interpolation Tests (via handle())
// =========================================================================
describe('variable interpolation', function () {
it('replaces single string placeholder', function () {
$task = mockContentTask([
'workspace' => null,
'user_template' => 'Write about {{topic}}',
'input_data' => ['topic' => 'PHP testing'],
]);
$task->shouldReceive('markProcessing')->once();
$task->shouldReceive('markCompleted')->once();
$provider = Mockery::mock(AgenticProviderInterface::class);
$provider->shouldReceive('isAvailable')->andReturn(true);
$provider->shouldReceive('generate')
->with(Mockery::any(), 'Write about PHP testing', Mockery::any())
->once()
->andReturn(mockAgenticResponse());
$ai = Mockery::mock(AgenticManager::class);
$ai->shouldReceive('provider')->andReturn($provider);
$processor = Mockery::mock('Mod\Content\Services\ContentProcessingService');
$entitlements = Mockery::mock('Core\Tenant\Services\EntitlementService');
$job = new ProcessContentTask($task);
$job->handle($ai, $processor, $entitlements);
});
it('leaves unmatched placeholders unchanged', function () {
$task = mockContentTask([
'workspace' => null,
'user_template' => 'Hello {{name}}, your role is {{role}}',
'input_data' => ['name' => 'Bob'], // {{role}} has no value
]);
$task->shouldReceive('markProcessing')->once();
$task->shouldReceive('markCompleted')->once();
$provider = Mockery::mock(AgenticProviderInterface::class);
$provider->shouldReceive('isAvailable')->andReturn(true);
$provider->shouldReceive('generate')
->with(Mockery::any(), 'Hello Bob, your role is {{role}}', Mockery::any())
->once()
->andReturn(mockAgenticResponse());
$ai = Mockery::mock(AgenticManager::class);
$ai->shouldReceive('provider')->andReturn($provider);
$processor = Mockery::mock('Mod\Content\Services\ContentProcessingService');
$entitlements = Mockery::mock('Core\Tenant\Services\EntitlementService');
$job = new ProcessContentTask($task);
$job->handle($ai, $processor, $entitlements);
});
it('serialises array values as JSON in placeholders', function () {
$task = mockContentTask([
'workspace' => null,
'user_template' => 'Data: {{items}}',
'input_data' => ['items' => ['a', 'b', 'c']],
]);
$task->shouldReceive('markProcessing')->once();
$task->shouldReceive('markCompleted')->once();
$provider = Mockery::mock(AgenticProviderInterface::class);
$provider->shouldReceive('isAvailable')->andReturn(true);
$provider->shouldReceive('generate')
->with(Mockery::any(), 'Data: ["a","b","c"]', Mockery::any())
->once()
->andReturn(mockAgenticResponse());
$ai = Mockery::mock(AgenticManager::class);
$ai->shouldReceive('provider')->andReturn($provider);
$processor = Mockery::mock('Mod\Content\Services\ContentProcessingService');
$entitlements = Mockery::mock('Core\Tenant\Services\EntitlementService');
$job = new ProcessContentTask($task);
$job->handle($ai, $processor, $entitlements);
});
it('handles empty input_data without error', function () {
$task = mockContentTask([
'workspace' => null,
'user_template' => 'Static template with no variables',
'input_data' => [],
]);
$task->shouldReceive('markProcessing')->once();
$task->shouldReceive('markCompleted')->once();
$provider = Mockery::mock(AgenticProviderInterface::class);
$provider->shouldReceive('isAvailable')->andReturn(true);
$provider->shouldReceive('generate')
->with(Mockery::any(), 'Static template with no variables', Mockery::any())
->once()
->andReturn(mockAgenticResponse());
$ai = Mockery::mock(AgenticManager::class);
$ai->shouldReceive('provider')->andReturn($provider);
$processor = Mockery::mock('Mod\Content\Services\ContentProcessingService');
$entitlements = Mockery::mock('Core\Tenant\Services\EntitlementService');
$job = new ProcessContentTask($task);
$job->handle($ai, $processor, $entitlements);
});
});
// =========================================================================
// Retry Logic Tests
// =========================================================================
describe('retry logic', function () {
it('job can be re-dispatched after failure', function () {
Queue::fake();
$task = mockContentTask();
ProcessContentTask::dispatch($task);
ProcessContentTask::dispatch($task); // simulated retry
Queue::assertPushed(ProcessContentTask::class, 2);
});
it('failed() is called when an unhandled exception propagates', function () {
$task = mockContentTask(['workspace' => null]);
$task->shouldReceive('markProcessing')->once();
$provider = Mockery::mock(AgenticProviderInterface::class);
$provider->shouldReceive('isAvailable')->andReturn(true);
$provider->shouldReceive('generate')
->andThrow(new \RuntimeException('API timeout'));
$ai = Mockery::mock(AgenticManager::class);
$ai->shouldReceive('provider')->andReturn($provider);
$processor = Mockery::mock('Mod\Content\Services\ContentProcessingService');
$entitlements = Mockery::mock('Core\Tenant\Services\EntitlementService');
$task->shouldReceive('markFailed')
->once()
->with('API timeout');
$job = new ProcessContentTask($task);
try {
$job->handle($ai, $processor, $entitlements);
} catch (\Throwable $e) {
$job->failed($e);
}
});
});

View file

@ -1,140 +0,0 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Tests\Feature\Livewire;
use Core\Mod\Agentic\View\Modal\Admin\ApiKeyManager;
use Core\Tenant\Models\Workspace;
use Livewire\Livewire;
/**
* Tests for the ApiKeyManager Livewire component.
*
* Note: This component manages workspace API keys via Core\Api\Models\ApiKey
* (from host-uk/core). Tests for key creation require the full core package
* to be installed. Tests here focus on component state and validation.
*/
class ApiKeyManagerTest extends LivewireTestCase
{
private Workspace $workspace;
protected function setUp(): void
{
parent::setUp();
$this->workspace = Workspace::factory()->create();
}
public function test_renders_successfully_with_workspace(): void
{
$this->actingAsHades();
Livewire::test(ApiKeyManager::class, ['workspace' => $this->workspace])
->assertOk();
}
public function test_mount_loads_workspace(): void
{
$this->actingAsHades();
$component = Livewire::test(ApiKeyManager::class, ['workspace' => $this->workspace]);
$this->assertEquals($this->workspace->id, $component->instance()->workspace->id);
}
public function test_has_default_property_values(): void
{
$this->actingAsHades();
Livewire::test(ApiKeyManager::class, ['workspace' => $this->workspace])
->assertSet('showCreateModal', false)
->assertSet('newKeyName', '')
->assertSet('newKeyExpiry', 'never')
->assertSet('showNewKeyModal', false)
->assertSet('newPlainKey', null);
}
public function test_open_create_modal_shows_modal(): void
{
$this->actingAsHades();
Livewire::test(ApiKeyManager::class, ['workspace' => $this->workspace])
->call('openCreateModal')
->assertSet('showCreateModal', true);
}
public function test_open_create_modal_resets_form(): void
{
$this->actingAsHades();
Livewire::test(ApiKeyManager::class, ['workspace' => $this->workspace])
->set('newKeyName', 'Old Name')
->call('openCreateModal')
->assertSet('newKeyName', '')
->assertSet('newKeyExpiry', 'never');
}
public function test_close_create_modal_hides_modal(): void
{
$this->actingAsHades();
Livewire::test(ApiKeyManager::class, ['workspace' => $this->workspace])
->call('openCreateModal')
->call('closeCreateModal')
->assertSet('showCreateModal', false);
}
public function test_create_key_requires_name(): void
{
$this->actingAsHades();
Livewire::test(ApiKeyManager::class, ['workspace' => $this->workspace])
->call('openCreateModal')
->set('newKeyName', '')
->call('createKey')
->assertHasErrors(['newKeyName' => 'required']);
}
public function test_create_key_validates_name_max_length(): void
{
$this->actingAsHades();
Livewire::test(ApiKeyManager::class, ['workspace' => $this->workspace])
->call('openCreateModal')
->set('newKeyName', str_repeat('x', 101))
->call('createKey')
->assertHasErrors(['newKeyName' => 'max']);
}
public function test_toggle_scope_adds_scope_if_not_present(): void
{
$this->actingAsHades();
Livewire::test(ApiKeyManager::class, ['workspace' => $this->workspace])
->set('newKeyScopes', [])
->call('toggleScope', 'read')
->assertSet('newKeyScopes', ['read']);
}
public function test_toggle_scope_removes_scope_if_already_present(): void
{
$this->actingAsHades();
Livewire::test(ApiKeyManager::class, ['workspace' => $this->workspace])
->set('newKeyScopes', ['read', 'write'])
->call('toggleScope', 'read')
->assertSet('newKeyScopes', ['write']);
}
public function test_close_new_key_modal_clears_plain_key(): void
{
$this->actingAsHades();
Livewire::test(ApiKeyManager::class, ['workspace' => $this->workspace])
->set('newPlainKey', 'secret-key-value')
->set('showNewKeyModal', true)
->call('closeNewKeyModal')
->assertSet('newPlainKey', null)
->assertSet('showNewKeyModal', false);
}
}

View file

@ -1,238 +0,0 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Tests\Feature\Livewire;
use Core\Mod\Agentic\Models\AgentApiKey;
use Core\Mod\Agentic\View\Modal\Admin\ApiKeys;
use Core\Tenant\Models\Workspace;
use Livewire\Livewire;
use Symfony\Component\HttpKernel\Exception\HttpException;
class ApiKeysTest extends LivewireTestCase
{
private Workspace $workspace;
protected function setUp(): void
{
parent::setUp();
$this->workspace = Workspace::factory()->create();
}
public function test_requires_hades_access(): void
{
$this->expectException(HttpException::class);
Livewire::test(ApiKeys::class);
}
public function test_renders_successfully_with_hades_user(): void
{
$this->actingAsHades();
Livewire::test(ApiKeys::class)
->assertOk();
}
public function test_has_default_property_values(): void
{
$this->actingAsHades();
Livewire::test(ApiKeys::class)
->assertSet('workspace', '')
->assertSet('status', '')
->assertSet('perPage', 25)
->assertSet('showCreateModal', false)
->assertSet('showEditModal', false);
}
public function test_open_create_modal_shows_modal(): void
{
$this->actingAsHades();
Livewire::test(ApiKeys::class)
->call('openCreateModal')
->assertSet('showCreateModal', true);
}
public function test_close_create_modal_hides_modal(): void
{
$this->actingAsHades();
Livewire::test(ApiKeys::class)
->call('openCreateModal')
->call('closeCreateModal')
->assertSet('showCreateModal', false);
}
public function test_open_create_modal_resets_form_fields(): void
{
$this->actingAsHades();
Livewire::test(ApiKeys::class)
->set('newKeyName', 'Old Name')
->call('openCreateModal')
->assertSet('newKeyName', '')
->assertSet('newKeyPermissions', [])
->assertSet('newKeyRateLimit', 100);
}
public function test_create_key_requires_name(): void
{
$this->actingAsHades();
Livewire::test(ApiKeys::class)
->call('openCreateModal')
->set('newKeyName', '')
->set('newKeyWorkspace', $this->workspace->id)
->set('newKeyPermissions', [AgentApiKey::PERM_PLANS_READ])
->call('createKey')
->assertHasErrors(['newKeyName' => 'required']);
}
public function test_create_key_requires_at_least_one_permission(): void
{
$this->actingAsHades();
Livewire::test(ApiKeys::class)
->call('openCreateModal')
->set('newKeyName', 'Test Key')
->set('newKeyWorkspace', $this->workspace->id)
->set('newKeyPermissions', [])
->call('createKey')
->assertHasErrors(['newKeyPermissions']);
}
public function test_create_key_requires_valid_workspace(): void
{
$this->actingAsHades();
Livewire::test(ApiKeys::class)
->call('openCreateModal')
->set('newKeyName', 'Test Key')
->set('newKeyWorkspace', 99999)
->set('newKeyPermissions', [AgentApiKey::PERM_PLANS_READ])
->call('createKey')
->assertHasErrors(['newKeyWorkspace' => 'exists']);
}
public function test_create_key_validates_rate_limit_minimum(): void
{
$this->actingAsHades();
Livewire::test(ApiKeys::class)
->call('openCreateModal')
->set('newKeyName', 'Test Key')
->set('newKeyWorkspace', $this->workspace->id)
->set('newKeyPermissions', [AgentApiKey::PERM_PLANS_READ])
->set('newKeyRateLimit', 0)
->call('createKey')
->assertHasErrors(['newKeyRateLimit' => 'min']);
}
public function test_revoke_key_marks_key_as_revoked(): void
{
$this->actingAsHades();
$key = AgentApiKey::generate($this->workspace, 'Test Key', [AgentApiKey::PERM_PLANS_READ]);
Livewire::test(ApiKeys::class)
->call('revokeKey', $key->id)
->assertOk();
$this->assertNotNull($key->fresh()->revoked_at);
}
public function test_clear_filters_resets_workspace_and_status(): void
{
$this->actingAsHades();
Livewire::test(ApiKeys::class)
->set('workspace', '1')
->set('status', 'active')
->call('clearFilters')
->assertSet('workspace', '')
->assertSet('status', '');
}
public function test_open_edit_modal_populates_fields(): void
{
$this->actingAsHades();
$key = AgentApiKey::generate(
$this->workspace,
'Edit Me',
[AgentApiKey::PERM_PLANS_READ],
200
);
Livewire::test(ApiKeys::class)
->call('openEditModal', $key->id)
->assertSet('showEditModal', true)
->assertSet('editingKeyId', $key->id)
->assertSet('editingRateLimit', 200);
}
public function test_close_edit_modal_clears_editing_state(): void
{
$this->actingAsHades();
$key = AgentApiKey::generate($this->workspace, 'Test Key', [AgentApiKey::PERM_PLANS_READ]);
Livewire::test(ApiKeys::class)
->call('openEditModal', $key->id)
->call('closeEditModal')
->assertSet('showEditModal', false)
->assertSet('editingKeyId', null);
}
public function test_get_status_badge_class_returns_green_for_active_key(): void
{
$this->actingAsHades();
$key = AgentApiKey::generate($this->workspace, 'Active Key', [AgentApiKey::PERM_PLANS_READ]);
$component = Livewire::test(ApiKeys::class);
$class = $component->instance()->getStatusBadgeClass($key->fresh());
$this->assertStringContainsString('green', $class);
}
public function test_get_status_badge_class_returns_red_for_revoked_key(): void
{
$this->actingAsHades();
$key = AgentApiKey::generate($this->workspace, 'Revoked Key', [AgentApiKey::PERM_PLANS_READ]);
$key->update(['revoked_at' => now()]);
$component = Livewire::test(ApiKeys::class);
$class = $component->instance()->getStatusBadgeClass($key->fresh());
$this->assertStringContainsString('red', $class);
}
public function test_stats_returns_array_with_expected_keys(): void
{
$this->actingAsHades();
$component = Livewire::test(ApiKeys::class);
$stats = $component->instance()->stats;
$this->assertArrayHasKey('total', $stats);
$this->assertArrayHasKey('active', $stats);
$this->assertArrayHasKey('revoked', $stats);
$this->assertArrayHasKey('total_calls', $stats);
}
public function test_available_permissions_returns_all_permissions(): void
{
$this->actingAsHades();
$component = Livewire::test(ApiKeys::class);
$permissions = $component->instance()->availablePermissions;
$this->assertIsArray($permissions);
$this->assertNotEmpty($permissions);
}
}

View file

@ -1,102 +0,0 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Tests\Feature\Livewire;
use Core\Mod\Agentic\View\Modal\Admin\Dashboard;
use Livewire\Livewire;
use Symfony\Component\HttpKernel\Exception\HttpException;
class DashboardTest extends LivewireTestCase
{
public function test_requires_hades_access(): void
{
$this->expectException(HttpException::class);
Livewire::test(Dashboard::class);
}
public function test_unauthenticated_user_cannot_access(): void
{
$this->expectException(HttpException::class);
Livewire::test(Dashboard::class);
}
public function test_renders_successfully_with_hades_user(): void
{
$this->actingAsHades();
Livewire::test(Dashboard::class)
->assertOk();
}
public function test_refresh_dispatches_notify_event(): void
{
$this->actingAsHades();
Livewire::test(Dashboard::class)
->call('refresh')
->assertDispatched('notify');
}
public function test_has_correct_initial_properties(): void
{
$this->actingAsHades();
$component = Livewire::test(Dashboard::class);
$component->assertOk();
}
public function test_stats_returns_array_with_expected_keys(): void
{
$this->actingAsHades();
$component = Livewire::test(Dashboard::class);
$stats = $component->instance()->stats;
$this->assertIsArray($stats);
$this->assertArrayHasKey('active_plans', $stats);
$this->assertArrayHasKey('total_plans', $stats);
$this->assertArrayHasKey('active_sessions', $stats);
$this->assertArrayHasKey('today_sessions', $stats);
$this->assertArrayHasKey('tool_calls_7d', $stats);
$this->assertArrayHasKey('success_rate', $stats);
}
public function test_stat_cards_returns_four_items(): void
{
$this->actingAsHades();
$component = Livewire::test(Dashboard::class);
$cards = $component->instance()->statCards;
$this->assertIsArray($cards);
$this->assertCount(4, $cards);
}
public function test_blocked_alert_is_null_when_no_blocked_plans(): void
{
$this->actingAsHades();
$component = Livewire::test(Dashboard::class);
$this->assertNull($component->instance()->blockedAlert);
}
public function test_quick_links_returns_four_items(): void
{
$this->actingAsHades();
$component = Livewire::test(Dashboard::class);
$links = $component->instance()->quickLinks;
$this->assertIsArray($links);
$this->assertCount(4, $links);
}
}

View file

@ -1,50 +0,0 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Tests\Feature\Livewire;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\Fixtures\HadesUser;
use Tests\TestCase;
/**
* Base test case for Livewire component tests.
*
* Registers stub view namespaces so components can render during tests
* without requiring the full hub/mcp Blade component library.
*/
abstract class LivewireTestCase extends TestCase
{
use RefreshDatabase;
protected HadesUser $hadesUser;
protected function setUp(): void
{
parent::setUp();
// Register stub view namespaces so Livewire can render components
// without the full Blade component library from host-uk/core.
// Stubs live in tests/views/{namespace}/ and use minimal HTML.
$viewsBase = realpath(__DIR__.'/../../views');
$this->app['view']->addNamespace('agentic', $viewsBase);
$this->app['view']->addNamespace('mcp', $viewsBase.'/mcp');
// Create a Hades-privileged user for component tests
$this->hadesUser = new HadesUser([
'id' => 1,
'name' => 'Hades Test User',
'email' => 'hades@test.example',
]);
}
/**
* Act as the Hades user (admin with full access).
*/
protected function actingAsHades(): static
{
return $this->actingAs($this->hadesUser);
}
}

View file

@ -1,229 +0,0 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Tests\Feature\Livewire;
use Core\Mod\Agentic\Models\AgentPhase;
use Core\Mod\Agentic\Models\AgentPlan;
use Core\Mod\Agentic\View\Modal\Admin\PlanDetail;
use Core\Tenant\Models\Workspace;
use Livewire\Livewire;
use Symfony\Component\HttpKernel\Exception\HttpException;
class PlanDetailTest extends LivewireTestCase
{
private Workspace $workspace;
private AgentPlan $plan;
protected function setUp(): void
{
parent::setUp();
$this->workspace = Workspace::factory()->create();
$this->plan = AgentPlan::factory()->draft()->create([
'workspace_id' => $this->workspace->id,
'slug' => 'test-plan',
'title' => 'Test Plan',
]);
}
public function test_requires_hades_access(): void
{
$this->expectException(HttpException::class);
Livewire::test(PlanDetail::class, ['slug' => $this->plan->slug]);
}
public function test_renders_successfully_with_hades_user(): void
{
$this->actingAsHades();
Livewire::test(PlanDetail::class, ['slug' => $this->plan->slug])
->assertOk();
}
public function test_mount_loads_plan_by_slug(): void
{
$this->actingAsHades();
$component = Livewire::test(PlanDetail::class, ['slug' => $this->plan->slug]);
$this->assertEquals($this->plan->id, $component->instance()->plan->id);
$this->assertEquals('Test Plan', $component->instance()->plan->title);
}
public function test_has_default_modal_states(): void
{
$this->actingAsHades();
Livewire::test(PlanDetail::class, ['slug' => $this->plan->slug])
->assertSet('showAddTaskModal', false)
->assertSet('selectedPhaseId', 0)
->assertSet('newTaskName', '')
->assertSet('newTaskNotes', '');
}
public function test_activate_plan_changes_status(): void
{
$this->actingAsHades();
Livewire::test(PlanDetail::class, ['slug' => $this->plan->slug])
->call('activatePlan')
->assertDispatched('notify');
$this->assertEquals(AgentPlan::STATUS_ACTIVE, $this->plan->fresh()->status);
}
public function test_complete_plan_changes_status(): void
{
$this->actingAsHades();
$activePlan = AgentPlan::factory()->active()->create([
'workspace_id' => $this->workspace->id,
'slug' => 'active-plan',
]);
Livewire::test(PlanDetail::class, ['slug' => $activePlan->slug])
->call('completePlan')
->assertDispatched('notify');
$this->assertEquals(AgentPlan::STATUS_COMPLETED, $activePlan->fresh()->status);
}
public function test_archive_plan_changes_status(): void
{
$this->actingAsHades();
Livewire::test(PlanDetail::class, ['slug' => $this->plan->slug])
->call('archivePlan')
->assertDispatched('notify');
$this->assertEquals(AgentPlan::STATUS_ARCHIVED, $this->plan->fresh()->status);
}
public function test_complete_phase_updates_status(): void
{
$this->actingAsHades();
$phase = AgentPhase::factory()->inProgress()->create([
'agent_plan_id' => $this->plan->id,
'order' => 1,
]);
Livewire::test(PlanDetail::class, ['slug' => $this->plan->slug])
->call('completePhase', $phase->id)
->assertDispatched('notify');
$this->assertEquals(AgentPhase::STATUS_COMPLETED, $phase->fresh()->status);
}
public function test_block_phase_updates_status(): void
{
$this->actingAsHades();
$phase = AgentPhase::factory()->inProgress()->create([
'agent_plan_id' => $this->plan->id,
'order' => 1,
]);
Livewire::test(PlanDetail::class, ['slug' => $this->plan->slug])
->call('blockPhase', $phase->id)
->assertDispatched('notify');
$this->assertEquals(AgentPhase::STATUS_BLOCKED, $phase->fresh()->status);
}
public function test_skip_phase_updates_status(): void
{
$this->actingAsHades();
$phase = AgentPhase::factory()->pending()->create([
'agent_plan_id' => $this->plan->id,
'order' => 1,
]);
Livewire::test(PlanDetail::class, ['slug' => $this->plan->slug])
->call('skipPhase', $phase->id)
->assertDispatched('notify');
$this->assertEquals(AgentPhase::STATUS_SKIPPED, $phase->fresh()->status);
}
public function test_reset_phase_restores_to_pending(): void
{
$this->actingAsHades();
$phase = AgentPhase::factory()->completed()->create([
'agent_plan_id' => $this->plan->id,
'order' => 1,
]);
Livewire::test(PlanDetail::class, ['slug' => $this->plan->slug])
->call('resetPhase', $phase->id)
->assertDispatched('notify');
$this->assertEquals(AgentPhase::STATUS_PENDING, $phase->fresh()->status);
}
public function test_open_add_task_modal_sets_phase_and_shows_modal(): void
{
$this->actingAsHades();
$phase = AgentPhase::factory()->pending()->create([
'agent_plan_id' => $this->plan->id,
'order' => 1,
]);
Livewire::test(PlanDetail::class, ['slug' => $this->plan->slug])
->call('openAddTaskModal', $phase->id)
->assertSet('showAddTaskModal', true)
->assertSet('selectedPhaseId', $phase->id)
->assertSet('newTaskName', '')
->assertSet('newTaskNotes', '');
}
public function test_add_task_requires_task_name(): void
{
$this->actingAsHades();
$phase = AgentPhase::factory()->pending()->create([
'agent_plan_id' => $this->plan->id,
'order' => 1,
]);
Livewire::test(PlanDetail::class, ['slug' => $this->plan->slug])
->call('openAddTaskModal', $phase->id)
->set('newTaskName', '')
->call('addTask')
->assertHasErrors(['newTaskName' => 'required']);
}
public function test_add_task_validates_name_max_length(): void
{
$this->actingAsHades();
$phase = AgentPhase::factory()->pending()->create([
'agent_plan_id' => $this->plan->id,
'order' => 1,
]);
Livewire::test(PlanDetail::class, ['slug' => $this->plan->slug])
->call('openAddTaskModal', $phase->id)
->set('newTaskName', str_repeat('x', 256))
->call('addTask')
->assertHasErrors(['newTaskName' => 'max']);
}
public function test_get_status_color_class_returns_correct_class(): void
{
$this->actingAsHades();
$component = Livewire::test(PlanDetail::class, ['slug' => $this->plan->slug]);
$instance = $component->instance();
$this->assertStringContainsString('blue', $instance->getStatusColorClass(AgentPlan::STATUS_ACTIVE));
$this->assertStringContainsString('green', $instance->getStatusColorClass(AgentPlan::STATUS_COMPLETED));
$this->assertStringContainsString('red', $instance->getStatusColorClass(AgentPhase::STATUS_BLOCKED));
}
}

View file

@ -1,165 +0,0 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Tests\Feature\Livewire;
use Core\Mod\Agentic\Models\AgentPlan;
use Core\Mod\Agentic\View\Modal\Admin\Plans;
use Core\Tenant\Models\Workspace;
use Livewire\Livewire;
use Symfony\Component\HttpKernel\Exception\HttpException;
class PlansTest extends LivewireTestCase
{
private Workspace $workspace;
protected function setUp(): void
{
parent::setUp();
$this->workspace = Workspace::factory()->create();
}
public function test_requires_hades_access(): void
{
$this->expectException(HttpException::class);
Livewire::test(Plans::class);
}
public function test_renders_successfully_with_hades_user(): void
{
$this->actingAsHades();
Livewire::test(Plans::class)
->assertOk();
}
public function test_has_default_property_values(): void
{
$this->actingAsHades();
Livewire::test(Plans::class)
->assertSet('search', '')
->assertSet('status', '')
->assertSet('workspace', '')
->assertSet('perPage', 15);
}
public function test_search_filter_updates(): void
{
$this->actingAsHades();
Livewire::test(Plans::class)
->set('search', 'my plan')
->assertSet('search', 'my plan');
}
public function test_status_filter_updates(): void
{
$this->actingAsHades();
Livewire::test(Plans::class)
->set('status', AgentPlan::STATUS_ACTIVE)
->assertSet('status', AgentPlan::STATUS_ACTIVE);
}
public function test_workspace_filter_updates(): void
{
$this->actingAsHades();
Livewire::test(Plans::class)
->set('workspace', (string) $this->workspace->id)
->assertSet('workspace', (string) $this->workspace->id);
}
public function test_clear_filters_resets_all_filters(): void
{
$this->actingAsHades();
Livewire::test(Plans::class)
->set('search', 'test')
->set('status', AgentPlan::STATUS_ACTIVE)
->set('workspace', (string) $this->workspace->id)
->call('clearFilters')
->assertSet('search', '')
->assertSet('status', '')
->assertSet('workspace', '');
}
public function test_activate_plan_changes_status_to_active(): void
{
$this->actingAsHades();
$plan = AgentPlan::factory()->draft()->create([
'workspace_id' => $this->workspace->id,
]);
Livewire::test(Plans::class)
->call('activate', $plan->id)
->assertDispatched('notify');
$this->assertEquals(AgentPlan::STATUS_ACTIVE, $plan->fresh()->status);
}
public function test_complete_plan_changes_status_to_completed(): void
{
$this->actingAsHades();
$plan = AgentPlan::factory()->active()->create([
'workspace_id' => $this->workspace->id,
]);
Livewire::test(Plans::class)
->call('complete', $plan->id)
->assertDispatched('notify');
$this->assertEquals(AgentPlan::STATUS_COMPLETED, $plan->fresh()->status);
}
public function test_archive_plan_changes_status_to_archived(): void
{
$this->actingAsHades();
$plan = AgentPlan::factory()->active()->create([
'workspace_id' => $this->workspace->id,
]);
Livewire::test(Plans::class)
->call('archive', $plan->id)
->assertDispatched('notify');
$this->assertEquals(AgentPlan::STATUS_ARCHIVED, $plan->fresh()->status);
}
public function test_delete_plan_removes_from_database(): void
{
$this->actingAsHades();
$plan = AgentPlan::factory()->create([
'workspace_id' => $this->workspace->id,
]);
$planId = $plan->id;
Livewire::test(Plans::class)
->call('delete', $planId)
->assertDispatched('notify');
$this->assertDatabaseMissing('agent_plans', ['id' => $planId]);
}
public function test_status_options_returns_all_statuses(): void
{
$this->actingAsHades();
$component = Livewire::test(Plans::class);
$options = $component->instance()->statusOptions;
$this->assertArrayHasKey(AgentPlan::STATUS_DRAFT, $options);
$this->assertArrayHasKey(AgentPlan::STATUS_ACTIVE, $options);
$this->assertArrayHasKey(AgentPlan::STATUS_COMPLETED, $options);
$this->assertArrayHasKey(AgentPlan::STATUS_ARCHIVED, $options);
}
}

View file

@ -1,160 +0,0 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Tests\Feature\Livewire;
use Core\Mod\Agentic\View\Modal\Admin\Playground;
use Livewire\Livewire;
/**
* Tests for the Playground Livewire component.
*
* Note: This component loads MCP server YAML files and uses Core\Api\Models\ApiKey.
* Tests focus on component state and interactions. Server loading gracefully
* handles missing registry files by setting an empty servers array.
*/
class PlaygroundTest extends LivewireTestCase
{
public function test_renders_successfully(): void
{
$this->actingAsHades();
Livewire::test(Playground::class)
->assertOk();
}
public function test_has_default_property_values(): void
{
$this->actingAsHades();
Livewire::test(Playground::class)
->assertSet('selectedServer', '')
->assertSet('selectedTool', '')
->assertSet('arguments', [])
->assertSet('response', '')
->assertSet('loading', false)
->assertSet('apiKey', '')
->assertSet('error', null)
->assertSet('keyStatus', null)
->assertSet('keyInfo', null)
->assertSet('tools', []);
}
public function test_mount_loads_servers_gracefully_when_registry_missing(): void
{
$this->actingAsHades();
$component = Livewire::test(Playground::class);
// When registry.yaml does not exist, servers defaults to empty array
$this->assertIsArray($component->instance()->servers);
}
public function test_updated_api_key_clears_validation_state(): void
{
$this->actingAsHades();
Livewire::test(Playground::class)
->set('keyStatus', 'valid')
->set('keyInfo', ['name' => 'Test Key'])
->set('apiKey', 'new-key-value')
->assertSet('keyStatus', null)
->assertSet('keyInfo', null);
}
public function test_validate_key_sets_empty_status_when_key_is_blank(): void
{
$this->actingAsHades();
Livewire::test(Playground::class)
->set('apiKey', '')
->call('validateKey')
->assertSet('keyStatus', 'empty');
}
public function test_validate_key_sets_invalid_for_unknown_key(): void
{
$this->actingAsHades();
Livewire::test(Playground::class)
->set('apiKey', 'not-a-real-key-abc123')
->call('validateKey')
->assertSet('keyStatus', 'invalid');
}
public function test_is_authenticated_returns_true_when_logged_in(): void
{
$this->actingAsHades();
$component = Livewire::test(Playground::class);
$this->assertTrue($component->instance()->isAuthenticated());
}
public function test_is_authenticated_returns_false_when_not_logged_in(): void
{
// No actingAs - unauthenticated request
$component = Livewire::test(Playground::class);
$this->assertFalse($component->instance()->isAuthenticated());
}
public function test_updated_selected_server_clears_tool_selection(): void
{
$this->actingAsHades();
Livewire::test(Playground::class)
->set('selectedTool', 'some_tool')
->set('toolSchema', ['name' => 'some_tool'])
->set('selectedServer', 'agent-server')
->assertSet('selectedTool', '')
->assertSet('toolSchema', null);
}
public function test_updated_selected_tool_clears_arguments_and_response(): void
{
$this->actingAsHades();
Livewire::test(Playground::class)
->set('arguments', ['key' => 'value'])
->set('response', 'previous response')
->set('selectedTool', '')
->assertSet('toolSchema', null);
}
public function test_execute_does_nothing_when_no_server_selected(): void
{
$this->actingAsHades();
Livewire::test(Playground::class)
->set('selectedServer', '')
->set('selectedTool', '')
->call('execute')
->assertSet('loading', false)
->assertSet('response', '');
}
public function test_execute_generates_curl_example_without_api_key(): void
{
$this->actingAsHades();
Livewire::test(Playground::class)
->set('selectedServer', 'agent-server')
->set('selectedTool', 'plan_create')
->call('execute')
->assertSet('loading', false);
// Without a valid API key, response should show the request format
$component = Livewire::test(Playground::class);
$component->set('selectedServer', 'agent-server');
$component->set('selectedTool', 'plan_create');
$component->call('execute');
$response = $component->instance()->response;
if ($response) {
$decoded = json_decode($response, true);
$this->assertIsArray($decoded);
}
}
}

View file

@ -1,87 +0,0 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Tests\Feature\Livewire;
use Core\Mod\Agentic\View\Modal\Admin\RequestLog;
use Livewire\Livewire;
/**
* Tests for the RequestLog Livewire component.
*
* Note: This component queries McpApiRequest from host-uk/core.
* Tests focus on component state and interactions that do not
* require the mcp_api_requests table to be present.
*/
class RequestLogTest extends LivewireTestCase
{
public function test_renders_successfully(): void
{
$this->actingAsHades();
Livewire::test(RequestLog::class)
->assertOk();
}
public function test_has_default_property_values(): void
{
$this->actingAsHades();
Livewire::test(RequestLog::class)
->assertSet('serverFilter', '')
->assertSet('statusFilter', '')
->assertSet('selectedRequestId', null)
->assertSet('selectedRequest', null);
}
public function test_server_filter_updates(): void
{
$this->actingAsHades();
Livewire::test(RequestLog::class)
->set('serverFilter', 'agent-server')
->assertSet('serverFilter', 'agent-server');
}
public function test_status_filter_updates(): void
{
$this->actingAsHades();
Livewire::test(RequestLog::class)
->set('statusFilter', 'success')
->assertSet('statusFilter', 'success');
}
public function test_close_detail_clears_selection(): void
{
$this->actingAsHades();
Livewire::test(RequestLog::class)
->set('selectedRequestId', 5)
->call('closeDetail')
->assertSet('selectedRequestId', null)
->assertSet('selectedRequest', null);
}
public function test_updated_server_filter_triggers_re_render(): void
{
$this->actingAsHades();
// Setting filter should update the property (pagination resets internally)
Livewire::test(RequestLog::class)
->set('serverFilter', 'my-server')
->assertSet('serverFilter', 'my-server')
->assertOk();
}
public function test_updated_status_filter_triggers_re_render(): void
{
$this->actingAsHades();
Livewire::test(RequestLog::class)
->set('statusFilter', 'failed')
->assertSet('statusFilter', 'failed')
->assertOk();
}
}

View file

@ -1,167 +0,0 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Tests\Feature\Livewire;
use Core\Mod\Agentic\Models\AgentSession;
use Core\Mod\Agentic\View\Modal\Admin\SessionDetail;
use Core\Tenant\Models\Workspace;
use Livewire\Livewire;
use Symfony\Component\HttpKernel\Exception\HttpException;
class SessionDetailTest extends LivewireTestCase
{
private Workspace $workspace;
private AgentSession $session;
protected function setUp(): void
{
parent::setUp();
$this->workspace = Workspace::factory()->create();
$this->session = AgentSession::factory()->active()->create([
'workspace_id' => $this->workspace->id,
]);
}
public function test_requires_hades_access(): void
{
$this->expectException(HttpException::class);
Livewire::test(SessionDetail::class, ['id' => $this->session->id]);
}
public function test_renders_successfully_with_hades_user(): void
{
$this->actingAsHades();
Livewire::test(SessionDetail::class, ['id' => $this->session->id])
->assertOk();
}
public function test_mount_loads_session_by_id(): void
{
$this->actingAsHades();
$component = Livewire::test(SessionDetail::class, ['id' => $this->session->id]);
$this->assertEquals($this->session->id, $component->instance()->session->id);
}
public function test_active_session_has_polling_enabled(): void
{
$this->actingAsHades();
$component = Livewire::test(SessionDetail::class, ['id' => $this->session->id]);
$this->assertGreaterThan(0, $component->instance()->pollingInterval);
}
public function test_completed_session_disables_polling(): void
{
$this->actingAsHades();
$completedSession = AgentSession::factory()->completed()->create([
'workspace_id' => $this->workspace->id,
]);
$component = Livewire::test(SessionDetail::class, ['id' => $completedSession->id]);
$this->assertEquals(0, $component->instance()->pollingInterval);
}
public function test_has_default_modal_states(): void
{
$this->actingAsHades();
Livewire::test(SessionDetail::class, ['id' => $this->session->id])
->assertSet('showCompleteModal', false)
->assertSet('showFailModal', false)
->assertSet('showReplayModal', false)
->assertSet('completeSummary', '')
->assertSet('failReason', '');
}
public function test_pause_session_changes_status(): void
{
$this->actingAsHades();
Livewire::test(SessionDetail::class, ['id' => $this->session->id])
->call('pauseSession')
->assertOk();
$this->assertEquals(AgentSession::STATUS_PAUSED, $this->session->fresh()->status);
}
public function test_resume_session_changes_status_from_paused(): void
{
$this->actingAsHades();
$pausedSession = AgentSession::factory()->paused()->create([
'workspace_id' => $this->workspace->id,
]);
Livewire::test(SessionDetail::class, ['id' => $pausedSession->id])
->call('resumeSession')
->assertOk();
$this->assertEquals(AgentSession::STATUS_ACTIVE, $pausedSession->fresh()->status);
}
public function test_open_complete_modal_shows_modal(): void
{
$this->actingAsHades();
Livewire::test(SessionDetail::class, ['id' => $this->session->id])
->call('openCompleteModal')
->assertSet('showCompleteModal', true);
}
public function test_open_fail_modal_shows_modal(): void
{
$this->actingAsHades();
Livewire::test(SessionDetail::class, ['id' => $this->session->id])
->call('openFailModal')
->assertSet('showFailModal', true);
}
public function test_open_replay_modal_shows_modal(): void
{
$this->actingAsHades();
Livewire::test(SessionDetail::class, ['id' => $this->session->id])
->call('openReplayModal')
->assertSet('showReplayModal', true);
}
public function test_work_log_returns_array(): void
{
$this->actingAsHades();
$component = Livewire::test(SessionDetail::class, ['id' => $this->session->id]);
$this->assertIsArray($component->instance()->workLog);
}
public function test_artifacts_returns_array(): void
{
$this->actingAsHades();
$component = Livewire::test(SessionDetail::class, ['id' => $this->session->id]);
$this->assertIsArray($component->instance()->artifacts);
}
public function test_get_status_color_class_returns_string(): void
{
$this->actingAsHades();
$component = Livewire::test(SessionDetail::class, ['id' => $this->session->id]);
$class = $component->instance()->getStatusColorClass(AgentSession::STATUS_ACTIVE);
$this->assertNotEmpty($class);
}
}

View file

@ -1,202 +0,0 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Tests\Feature\Livewire;
use Core\Mod\Agentic\Models\AgentSession;
use Core\Mod\Agentic\View\Modal\Admin\Sessions;
use Core\Tenant\Models\Workspace;
use Livewire\Livewire;
use Symfony\Component\HttpKernel\Exception\HttpException;
class SessionsTest extends LivewireTestCase
{
private Workspace $workspace;
protected function setUp(): void
{
parent::setUp();
$this->workspace = Workspace::factory()->create();
}
public function test_requires_hades_access(): void
{
$this->expectException(HttpException::class);
Livewire::test(Sessions::class);
}
public function test_renders_successfully_with_hades_user(): void
{
$this->actingAsHades();
Livewire::test(Sessions::class)
->assertOk();
}
public function test_has_default_property_values(): void
{
$this->actingAsHades();
Livewire::test(Sessions::class)
->assertSet('search', '')
->assertSet('status', '')
->assertSet('agentType', '')
->assertSet('workspace', '')
->assertSet('planSlug', '')
->assertSet('perPage', 20);
}
public function test_search_filter_updates(): void
{
$this->actingAsHades();
Livewire::test(Sessions::class)
->set('search', 'session-abc')
->assertSet('search', 'session-abc');
}
public function test_status_filter_updates(): void
{
$this->actingAsHades();
Livewire::test(Sessions::class)
->set('status', AgentSession::STATUS_ACTIVE)
->assertSet('status', AgentSession::STATUS_ACTIVE);
}
public function test_agent_type_filter_updates(): void
{
$this->actingAsHades();
Livewire::test(Sessions::class)
->set('agentType', AgentSession::AGENT_SONNET)
->assertSet('agentType', AgentSession::AGENT_SONNET);
}
public function test_clear_filters_resets_all_filters(): void
{
$this->actingAsHades();
Livewire::test(Sessions::class)
->set('search', 'test')
->set('status', AgentSession::STATUS_ACTIVE)
->set('agentType', AgentSession::AGENT_OPUS)
->set('workspace', '1')
->set('planSlug', 'some-plan')
->call('clearFilters')
->assertSet('search', '')
->assertSet('status', '')
->assertSet('agentType', '')
->assertSet('workspace', '')
->assertSet('planSlug', '');
}
public function test_pause_session_changes_status(): void
{
$this->actingAsHades();
$session = AgentSession::factory()->active()->create([
'workspace_id' => $this->workspace->id,
]);
Livewire::test(Sessions::class)
->call('pause', $session->id)
->assertDispatched('notify');
$this->assertEquals(AgentSession::STATUS_PAUSED, $session->fresh()->status);
}
public function test_resume_session_changes_status(): void
{
$this->actingAsHades();
$session = AgentSession::factory()->paused()->create([
'workspace_id' => $this->workspace->id,
]);
Livewire::test(Sessions::class)
->call('resume', $session->id)
->assertDispatched('notify');
$this->assertEquals(AgentSession::STATUS_ACTIVE, $session->fresh()->status);
}
public function test_complete_session_changes_status(): void
{
$this->actingAsHades();
$session = AgentSession::factory()->active()->create([
'workspace_id' => $this->workspace->id,
]);
Livewire::test(Sessions::class)
->call('complete', $session->id)
->assertDispatched('notify');
$this->assertEquals(AgentSession::STATUS_COMPLETED, $session->fresh()->status);
}
public function test_fail_session_changes_status(): void
{
$this->actingAsHades();
$session = AgentSession::factory()->active()->create([
'workspace_id' => $this->workspace->id,
]);
Livewire::test(Sessions::class)
->call('fail', $session->id)
->assertDispatched('notify');
$this->assertEquals(AgentSession::STATUS_FAILED, $session->fresh()->status);
}
public function test_get_status_color_class_returns_green_for_active(): void
{
$this->actingAsHades();
$component = Livewire::test(Sessions::class);
$class = $component->instance()->getStatusColorClass(AgentSession::STATUS_ACTIVE);
$this->assertStringContainsString('green', $class);
}
public function test_get_status_color_class_returns_red_for_failed(): void
{
$this->actingAsHades();
$component = Livewire::test(Sessions::class);
$class = $component->instance()->getStatusColorClass(AgentSession::STATUS_FAILED);
$this->assertStringContainsString('red', $class);
}
public function test_get_agent_badge_class_returns_class_for_opus(): void
{
$this->actingAsHades();
$component = Livewire::test(Sessions::class);
$class = $component->instance()->getAgentBadgeClass(AgentSession::AGENT_OPUS);
$this->assertNotEmpty($class);
$this->assertStringContainsString('violet', $class);
}
public function test_status_options_contains_all_statuses(): void
{
$this->actingAsHades();
$component = Livewire::test(Sessions::class);
$options = $component->instance()->statusOptions;
$this->assertArrayHasKey(AgentSession::STATUS_ACTIVE, $options);
$this->assertArrayHasKey(AgentSession::STATUS_PAUSED, $options);
$this->assertArrayHasKey(AgentSession::STATUS_COMPLETED, $options);
$this->assertArrayHasKey(AgentSession::STATUS_FAILED, $options);
}
}

View file

@ -1,173 +0,0 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Tests\Feature\Livewire;
use Core\Mod\Agentic\View\Modal\Admin\Templates;
use Livewire\Livewire;
use Symfony\Component\HttpKernel\Exception\HttpException;
class TemplatesTest extends LivewireTestCase
{
public function test_requires_hades_access(): void
{
$this->expectException(HttpException::class);
Livewire::test(Templates::class);
}
public function test_renders_successfully_with_hades_user(): void
{
$this->actingAsHades();
Livewire::test(Templates::class)
->assertOk();
}
public function test_has_default_property_values(): void
{
$this->actingAsHades();
Livewire::test(Templates::class)
->assertSet('category', '')
->assertSet('search', '')
->assertSet('showPreviewModal', false)
->assertSet('showCreateModal', false)
->assertSet('showImportModal', false)
->assertSet('previewSlug', null)
->assertSet('importError', null);
}
public function test_open_preview_sets_slug_and_shows_modal(): void
{
$this->actingAsHades();
Livewire::test(Templates::class)
->call('openPreview', 'my-template')
->assertSet('showPreviewModal', true)
->assertSet('previewSlug', 'my-template');
}
public function test_close_preview_hides_modal_and_clears_slug(): void
{
$this->actingAsHades();
Livewire::test(Templates::class)
->call('openPreview', 'my-template')
->call('closePreview')
->assertSet('showPreviewModal', false)
->assertSet('previewSlug', null);
}
public function test_open_import_modal_shows_modal_with_clean_state(): void
{
$this->actingAsHades();
Livewire::test(Templates::class)
->call('openImportModal')
->assertSet('showImportModal', true)
->assertSet('importFileName', '')
->assertSet('importPreview', null)
->assertSet('importError', null);
}
public function test_close_import_modal_hides_modal_and_clears_state(): void
{
$this->actingAsHades();
Livewire::test(Templates::class)
->call('openImportModal')
->call('closeImportModal')
->assertSet('showImportModal', false)
->assertSet('importError', null)
->assertSet('importPreview', null);
}
public function test_search_filter_updates(): void
{
$this->actingAsHades();
Livewire::test(Templates::class)
->set('search', 'feature')
->assertSet('search', 'feature');
}
public function test_category_filter_updates(): void
{
$this->actingAsHades();
Livewire::test(Templates::class)
->set('category', 'development')
->assertSet('category', 'development');
}
public function test_clear_filters_resets_search_and_category(): void
{
$this->actingAsHades();
Livewire::test(Templates::class)
->set('search', 'test')
->set('category', 'development')
->call('clearFilters')
->assertSet('search', '')
->assertSet('category', '');
}
public function test_get_category_color_returns_correct_class_for_development(): void
{
$this->actingAsHades();
$component = Livewire::test(Templates::class);
$class = $component->instance()->getCategoryColor('development');
$this->assertStringContainsString('blue', $class);
}
public function test_get_category_color_returns_correct_class_for_maintenance(): void
{
$this->actingAsHades();
$component = Livewire::test(Templates::class);
$class = $component->instance()->getCategoryColor('maintenance');
$this->assertStringContainsString('green', $class);
}
public function test_get_category_color_returns_correct_class_for_custom(): void
{
$this->actingAsHades();
$component = Livewire::test(Templates::class);
$class = $component->instance()->getCategoryColor('custom');
$this->assertStringContainsString('zinc', $class);
}
public function test_get_category_color_returns_default_for_unknown(): void
{
$this->actingAsHades();
$component = Livewire::test(Templates::class);
$class = $component->instance()->getCategoryColor('unknown-category');
$this->assertNotEmpty($class);
}
public function test_close_create_modal_hides_modal_and_clears_state(): void
{
$this->actingAsHades();
Livewire::test(Templates::class)
->set('showCreateModal', true)
->set('createTemplateSlug', 'some-template')
->call('closeCreateModal')
->assertSet('showCreateModal', false)
->assertSet('createTemplateSlug', null)
->assertSet('createVariables', []);
}
}

View file

@ -1,119 +0,0 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Tests\Feature\Livewire;
use Core\Mod\Agentic\View\Modal\Admin\ToolAnalytics;
use Livewire\Livewire;
use Symfony\Component\HttpKernel\Exception\HttpException;
class ToolAnalyticsTest extends LivewireTestCase
{
public function test_requires_hades_access(): void
{
$this->expectException(HttpException::class);
Livewire::test(ToolAnalytics::class);
}
public function test_renders_successfully_with_hades_user(): void
{
$this->actingAsHades();
Livewire::test(ToolAnalytics::class)
->assertOk();
}
public function test_has_default_property_values(): void
{
$this->actingAsHades();
Livewire::test(ToolAnalytics::class)
->assertSet('days', 7)
->assertSet('workspace', '')
->assertSet('server', '');
}
public function test_set_days_updates_days_property(): void
{
$this->actingAsHades();
Livewire::test(ToolAnalytics::class)
->call('setDays', 30)
->assertSet('days', 30);
}
public function test_set_days_to_seven(): void
{
$this->actingAsHades();
Livewire::test(ToolAnalytics::class)
->call('setDays', 30)
->call('setDays', 7)
->assertSet('days', 7);
}
public function test_workspace_filter_updates(): void
{
$this->actingAsHades();
Livewire::test(ToolAnalytics::class)
->set('workspace', '1')
->assertSet('workspace', '1');
}
public function test_server_filter_updates(): void
{
$this->actingAsHades();
Livewire::test(ToolAnalytics::class)
->set('server', 'agent-server')
->assertSet('server', 'agent-server');
}
public function test_clear_filters_resets_all(): void
{
$this->actingAsHades();
Livewire::test(ToolAnalytics::class)
->set('workspace', '1')
->set('server', 'agent-server')
->call('clearFilters')
->assertSet('workspace', '')
->assertSet('server', '');
}
public function test_get_success_rate_color_class_green_above_95(): void
{
$this->actingAsHades();
$component = Livewire::test(ToolAnalytics::class);
$class = $component->instance()->getSuccessRateColorClass(96.0);
$this->assertStringContainsString('green', $class);
}
public function test_get_success_rate_color_class_amber_between_80_and_95(): void
{
$this->actingAsHades();
$component = Livewire::test(ToolAnalytics::class);
$class = $component->instance()->getSuccessRateColorClass(85.0);
$this->assertStringContainsString('amber', $class);
}
public function test_get_success_rate_color_class_red_below_80(): void
{
$this->actingAsHades();
$component = Livewire::test(ToolAnalytics::class);
$class = $component->instance()->getSuccessRateColorClass(70.0);
$this->assertStringContainsString('red', $class);
}
}

View file

@ -1,148 +0,0 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Tests\Feature\Livewire;
use Core\Mod\Agentic\View\Modal\Admin\ToolCalls;
use Livewire\Livewire;
use Symfony\Component\HttpKernel\Exception\HttpException;
/**
* Tests for the ToolCalls Livewire component.
*
* Note: This component queries McpToolCall from host-uk/core.
* Tests focus on component state, filters, and actions that do not
* depend on the mcp_tool_calls table being present.
*/
class ToolCallsTest extends LivewireTestCase
{
public function test_requires_hades_access(): void
{
$this->expectException(HttpException::class);
Livewire::test(ToolCalls::class);
}
public function test_renders_successfully_with_hades_user(): void
{
$this->actingAsHades();
Livewire::test(ToolCalls::class)
->assertOk();
}
public function test_has_default_property_values(): void
{
$this->actingAsHades();
Livewire::test(ToolCalls::class)
->assertSet('search', '')
->assertSet('server', '')
->assertSet('tool', '')
->assertSet('status', '')
->assertSet('workspace', '')
->assertSet('agentType', '')
->assertSet('perPage', 25)
->assertSet('selectedCallId', null);
}
public function test_search_filter_updates(): void
{
$this->actingAsHades();
Livewire::test(ToolCalls::class)
->set('search', 'plan_create')
->assertSet('search', 'plan_create');
}
public function test_server_filter_updates(): void
{
$this->actingAsHades();
Livewire::test(ToolCalls::class)
->set('server', 'agent-server')
->assertSet('server', 'agent-server');
}
public function test_status_filter_updates(): void
{
$this->actingAsHades();
Livewire::test(ToolCalls::class)
->set('status', 'success')
->assertSet('status', 'success');
}
public function test_view_call_sets_selected_call_id(): void
{
$this->actingAsHades();
Livewire::test(ToolCalls::class)
->call('viewCall', 42)
->assertSet('selectedCallId', 42);
}
public function test_close_call_detail_clears_selection(): void
{
$this->actingAsHades();
Livewire::test(ToolCalls::class)
->call('viewCall', 42)
->call('closeCallDetail')
->assertSet('selectedCallId', null);
}
public function test_clear_filters_resets_all(): void
{
$this->actingAsHades();
Livewire::test(ToolCalls::class)
->set('search', 'test')
->set('server', 'server-1')
->set('tool', 'plan_create')
->set('status', 'success')
->set('workspace', '1')
->set('agentType', 'opus')
->call('clearFilters')
->assertSet('search', '')
->assertSet('server', '')
->assertSet('tool', '')
->assertSet('status', '')
->assertSet('workspace', '')
->assertSet('agentType', '');
}
public function test_get_status_badge_class_returns_green_for_success(): void
{
$this->actingAsHades();
$component = Livewire::test(ToolCalls::class);
$class = $component->instance()->getStatusBadgeClass(true);
$this->assertStringContainsString('green', $class);
}
public function test_get_status_badge_class_returns_red_for_failure(): void
{
$this->actingAsHades();
$component = Livewire::test(ToolCalls::class);
$class = $component->instance()->getStatusBadgeClass(false);
$this->assertStringContainsString('red', $class);
}
public function test_get_agent_badge_class_returns_string(): void
{
$this->actingAsHades();
$component = Livewire::test(ToolCalls::class);
$this->assertNotEmpty($component->instance()->getAgentBadgeClass('opus'));
$this->assertNotEmpty($component->instance()->getAgentBadgeClass('sonnet'));
$this->assertNotEmpty($component->instance()->getAgentBadgeClass('unknown'));
}
}

View file

@ -794,7 +794,7 @@ describe('edge cases', function () {
}); });
it('handles malformed YAML gracefully', function () { it('handles malformed YAML gracefully', function () {
File::put($this->testTemplatesPath.'/malformed.yaml', 'invalid: yaml: content: ['); File::put($this->testTemplatesPath.'/malformed.yaml', "invalid: yaml: content: [");
// Should not throw when listing // Should not throw when listing
$result = $this->service->list(); $result = $this->service->list();

View file

@ -1,279 +0,0 @@
<?php
declare(strict_types=1);
use Core\Mod\Agentic\Models\Prompt;
use Core\Mod\Agentic\Models\PromptVersion;
use Core\Tenant\Models\User;
// =========================================================================
// Version Creation Tests
// =========================================================================
describe('version creation', function () {
it('can be created with required attributes', function () {
$prompt = Prompt::create([
'name' => 'Test Prompt',
'system_prompt' => 'You are a helpful assistant.',
'user_template' => 'Answer this: {{{question}}}',
'variables' => ['question'],
]);
$version = PromptVersion::create([
'prompt_id' => $prompt->id,
'version' => 1,
'system_prompt' => 'You are a helpful assistant.',
'user_template' => 'Answer this: {{{question}}}',
'variables' => ['question'],
]);
expect($version->id)->not->toBeNull()
->and($version->version)->toBe(1)
->and($version->prompt_id)->toBe($prompt->id);
});
it('casts variables as array', function () {
$prompt = Prompt::create(['name' => 'Test Prompt']);
$version = PromptVersion::create([
'prompt_id' => $prompt->id,
'version' => 1,
'variables' => ['topic', 'tone'],
]);
expect($version->variables)
->toBeArray()
->toBe(['topic', 'tone']);
});
it('casts version as integer', function () {
$prompt = Prompt::create(['name' => 'Test Prompt']);
$version = PromptVersion::create([
'prompt_id' => $prompt->id,
'version' => 3,
]);
expect($version->version)->toBeInt()->toBe(3);
});
it('can be created without optional fields', function () {
$prompt = Prompt::create(['name' => 'Minimal Prompt']);
$version = PromptVersion::create([
'prompt_id' => $prompt->id,
'version' => 1,
]);
expect($version->id)->not->toBeNull()
->and($version->system_prompt)->toBeNull()
->and($version->user_template)->toBeNull()
->and($version->created_by)->toBeNull();
});
});
// =========================================================================
// Relationship Tests
// =========================================================================
describe('relationships', function () {
it('belongs to a prompt', function () {
$prompt = Prompt::create([
'name' => 'Parent Prompt',
'system_prompt' => 'System text.',
]);
$version = PromptVersion::create([
'prompt_id' => $prompt->id,
'version' => 1,
]);
expect($version->prompt)->toBeInstanceOf(Prompt::class)
->and($version->prompt->id)->toBe($prompt->id)
->and($version->prompt->name)->toBe('Parent Prompt');
});
it('belongs to a creator user', function () {
$user = User::factory()->create();
$prompt = Prompt::create(['name' => 'Test Prompt']);
$version = PromptVersion::create([
'prompt_id' => $prompt->id,
'version' => 1,
'created_by' => $user->id,
]);
expect($version->creator)->toBeInstanceOf(User::class)
->and($version->creator->id)->toBe($user->id);
});
it('has null creator when created_by is null', function () {
$prompt = Prompt::create(['name' => 'Test Prompt']);
$version = PromptVersion::create([
'prompt_id' => $prompt->id,
'version' => 1,
]);
expect($version->creator)->toBeNull();
});
});
// =========================================================================
// Restore Method Tests
// =========================================================================
describe('restore', function () {
it('restores system_prompt and user_template to the parent prompt', function () {
$prompt = Prompt::create([
'name' => 'Test Prompt',
'system_prompt' => 'Original system prompt.',
'user_template' => 'Original template.',
]);
$version = PromptVersion::create([
'prompt_id' => $prompt->id,
'version' => 1,
'system_prompt' => 'Versioned system prompt.',
'user_template' => 'Versioned template.',
]);
$prompt->update([
'system_prompt' => 'Newer system prompt.',
'user_template' => 'Newer template.',
]);
$version->restore();
$fresh = $prompt->fresh();
expect($fresh->system_prompt)->toBe('Versioned system prompt.')
->and($fresh->user_template)->toBe('Versioned template.');
});
it('restores variables to the parent prompt', function () {
$prompt = Prompt::create([
'name' => 'Test Prompt',
'variables' => ['topic'],
]);
$version = PromptVersion::create([
'prompt_id' => $prompt->id,
'version' => 1,
'variables' => ['topic', 'tone'],
]);
$prompt->update(['variables' => ['topic', 'tone', 'length']]);
$version->restore();
expect($prompt->fresh()->variables)->toBe(['topic', 'tone']);
});
it('returns the parent prompt instance after restore', function () {
$prompt = Prompt::create([
'name' => 'Test Prompt',
'system_prompt' => 'Old.',
]);
$version = PromptVersion::create([
'prompt_id' => $prompt->id,
'version' => 1,
'system_prompt' => 'Versioned.',
]);
$result = $version->restore();
expect($result)->toBeInstanceOf(Prompt::class)
->and($result->id)->toBe($prompt->id);
});
});
// =========================================================================
// Version History Tests
// =========================================================================
describe('version history', function () {
it('prompt tracks multiple versions in descending order', function () {
$prompt = Prompt::create([
'name' => 'Evolving Prompt',
'system_prompt' => 'v1.',
]);
PromptVersion::create(['prompt_id' => $prompt->id, 'version' => 1, 'system_prompt' => 'v1.']);
PromptVersion::create(['prompt_id' => $prompt->id, 'version' => 2, 'system_prompt' => 'v2.']);
PromptVersion::create(['prompt_id' => $prompt->id, 'version' => 3, 'system_prompt' => 'v3.']);
$versions = $prompt->versions()->get();
expect($versions)->toHaveCount(3)
->and($versions->first()->version)->toBe(3)
->and($versions->last()->version)->toBe(1);
});
it('createVersion snapshots current prompt state', function () {
$prompt = Prompt::create([
'name' => 'Test Prompt',
'system_prompt' => 'Original system prompt.',
'user_template' => 'Original template.',
'variables' => ['topic'],
]);
$version = $prompt->createVersion();
expect($version)->toBeInstanceOf(PromptVersion::class)
->and($version->version)->toBe(1)
->and($version->system_prompt)->toBe('Original system prompt.')
->and($version->user_template)->toBe('Original template.')
->and($version->variables)->toBe(['topic']);
});
it('createVersion increments version number', function () {
$prompt = Prompt::create([
'name' => 'Test Prompt',
'system_prompt' => 'v1.',
]);
$v1 = $prompt->createVersion();
$prompt->update(['system_prompt' => 'v2.']);
$v2 = $prompt->createVersion();
expect($v1->version)->toBe(1)
->and($v2->version)->toBe(2);
});
it('createVersion records the creator user id', function () {
$user = User::factory()->create();
$prompt = Prompt::create([
'name' => 'Test Prompt',
'system_prompt' => 'System text.',
]);
$version = $prompt->createVersion($user->id);
expect($version->created_by)->toBe($user->id);
});
it('versions are scoped to their parent prompt', function () {
$promptA = Prompt::create(['name' => 'Prompt A']);
$promptB = Prompt::create(['name' => 'Prompt B']);
PromptVersion::create(['prompt_id' => $promptA->id, 'version' => 1]);
PromptVersion::create(['prompt_id' => $promptA->id, 'version' => 2]);
PromptVersion::create(['prompt_id' => $promptB->id, 'version' => 1]);
expect($promptA->versions()->count())->toBe(2)
->and($promptB->versions()->count())->toBe(1);
});
it('deleting prompt cascades to versions', function () {
$prompt = Prompt::create(['name' => 'Test Prompt']);
PromptVersion::create(['prompt_id' => $prompt->id, 'version' => 1]);
PromptVersion::create(['prompt_id' => $prompt->id, 'version' => 2]);
$promptId = $prompt->id;
$prompt->delete();
expect(PromptVersion::where('prompt_id', $promptId)->count())->toBe(0);
});
});

View file

@ -4,16 +4,16 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Tests\Feature; namespace Core\Mod\Agentic\Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Core\Mod\Agentic\Models\AgentPlan;
use Core\Mod\Agentic\Models\AgentWorkspaceState;
use Core\Mod\Agentic\Models\Task;
use Core\Mod\Agentic\Mcp\Tools\Agent\Plan\PlanGet; use Core\Mod\Agentic\Mcp\Tools\Agent\Plan\PlanGet;
use Core\Mod\Agentic\Mcp\Tools\Agent\Plan\PlanList; use Core\Mod\Agentic\Mcp\Tools\Agent\Plan\PlanList;
use Core\Mod\Agentic\Mcp\Tools\Agent\State\StateGet; use Core\Mod\Agentic\Mcp\Tools\Agent\State\StateGet;
use Core\Mod\Agentic\Mcp\Tools\Agent\State\StateList; use Core\Mod\Agentic\Mcp\Tools\Agent\State\StateList;
use Core\Mod\Agentic\Mcp\Tools\Agent\State\StateSet; use Core\Mod\Agentic\Mcp\Tools\Agent\State\StateSet;
use Core\Mod\Agentic\Models\AgentPlan;
use Core\Mod\Agentic\Models\AgentWorkspaceState;
use Core\Mod\Agentic\Models\Task;
use Core\Tenant\Models\Workspace; use Core\Tenant\Models\Workspace;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase; use Tests\TestCase;
/** /**
@ -24,7 +24,6 @@ class SecurityTest extends TestCase
use RefreshDatabase; use RefreshDatabase;
private Workspace $workspace; private Workspace $workspace;
private Workspace $otherWorkspace; private Workspace $otherWorkspace;
protected function setUp(): void protected function setUp(): void
@ -44,7 +43,7 @@ class SecurityTest extends TestCase
'workspace_id' => $this->workspace->id, 'workspace_id' => $this->workspace->id,
]); ]);
$tool = new StateSet; $tool = new StateSet();
$result = $tool->handle([ $result = $tool->handle([
'plan_slug' => $plan->slug, 'plan_slug' => $plan->slug,
'key' => 'test_key', 'key' => 'test_key',
@ -61,7 +60,7 @@ class SecurityTest extends TestCase
'workspace_id' => $this->otherWorkspace->id, 'workspace_id' => $this->otherWorkspace->id,
]); ]);
$tool = new StateSet; $tool = new StateSet();
$result = $tool->handle([ $result = $tool->handle([
'plan_slug' => $otherPlan->slug, 'plan_slug' => $otherPlan->slug,
'key' => 'test_key', 'key' => 'test_key',
@ -78,7 +77,7 @@ class SecurityTest extends TestCase
'workspace_id' => $this->workspace->id, 'workspace_id' => $this->workspace->id,
]); ]);
$tool = new StateSet; $tool = new StateSet();
$result = $tool->handle([ $result = $tool->handle([
'plan_slug' => $plan->slug, 'plan_slug' => $plan->slug,
'key' => 'test_key', 'key' => 'test_key',
@ -106,7 +105,7 @@ class SecurityTest extends TestCase
'value' => ['data' => 'secret'], 'value' => ['data' => 'secret'],
]); ]);
$tool = new StateGet; $tool = new StateGet();
$result = $tool->handle([ $result = $tool->handle([
'plan_slug' => $plan->slug, 'plan_slug' => $plan->slug,
'key' => 'test_key', 'key' => 'test_key',
@ -128,7 +127,7 @@ class SecurityTest extends TestCase
'value' => ['data' => 'sensitive'], 'value' => ['data' => 'sensitive'],
]); ]);
$tool = new StateGet; $tool = new StateGet();
$result = $tool->handle([ $result = $tool->handle([
'plan_slug' => $otherPlan->slug, 'plan_slug' => $otherPlan->slug,
'key' => 'secret_key', 'key' => 'secret_key',
@ -150,7 +149,7 @@ class SecurityTest extends TestCase
'value' => ['data' => 'allowed'], 'value' => ['data' => 'allowed'],
]); ]);
$tool = new StateGet; $tool = new StateGet();
$result = $tool->handle([ $result = $tool->handle([
'plan_slug' => $plan->slug, 'plan_slug' => $plan->slug,
'key' => 'test_key', 'key' => 'test_key',
@ -171,7 +170,7 @@ class SecurityTest extends TestCase
'workspace_id' => $this->workspace->id, 'workspace_id' => $this->workspace->id,
]); ]);
$tool = new StateList; $tool = new StateList();
$result = $tool->handle([ $result = $tool->handle([
'plan_slug' => $plan->slug, 'plan_slug' => $plan->slug,
], []); // No workspace_id in context ], []); // No workspace_id in context
@ -192,7 +191,7 @@ class SecurityTest extends TestCase
'value' => ['data' => 'sensitive'], 'value' => ['data' => 'sensitive'],
]); ]);
$tool = new StateList; $tool = new StateList();
$result = $tool->handle([ $result = $tool->handle([
'plan_slug' => $otherPlan->slug, 'plan_slug' => $otherPlan->slug,
], ['workspace_id' => $this->workspace->id]); // Different workspace ], ['workspace_id' => $this->workspace->id]); // Different workspace
@ -211,7 +210,7 @@ class SecurityTest extends TestCase
'workspace_id' => $this->workspace->id, 'workspace_id' => $this->workspace->id,
]); ]);
$tool = new PlanGet; $tool = new PlanGet();
$result = $tool->handle([ $result = $tool->handle([
'slug' => $plan->slug, 'slug' => $plan->slug,
], []); // No workspace_id in context ], []); // No workspace_id in context
@ -227,7 +226,7 @@ class SecurityTest extends TestCase
'title' => 'Secret Plan', 'title' => 'Secret Plan',
]); ]);
$tool = new PlanGet; $tool = new PlanGet();
$result = $tool->handle([ $result = $tool->handle([
'slug' => $otherPlan->slug, 'slug' => $otherPlan->slug,
], ['workspace_id' => $this->workspace->id]); // Different workspace ], ['workspace_id' => $this->workspace->id]); // Different workspace
@ -243,7 +242,7 @@ class SecurityTest extends TestCase
'title' => 'My Plan', 'title' => 'My Plan',
]); ]);
$tool = new PlanGet; $tool = new PlanGet();
$result = $tool->handle([ $result = $tool->handle([
'slug' => $plan->slug, 'slug' => $plan->slug,
], ['workspace_id' => $this->workspace->id]); ], ['workspace_id' => $this->workspace->id]);
@ -259,7 +258,7 @@ class SecurityTest extends TestCase
public function test_plan_list_requires_workspace_context(): void public function test_plan_list_requires_workspace_context(): void
{ {
$tool = new PlanList; $tool = new PlanList();
$result = $tool->handle([], []); // No workspace_id in context $result = $tool->handle([], []); // No workspace_id in context
$this->assertArrayHasKey('error', $result); $this->assertArrayHasKey('error', $result);
@ -278,7 +277,7 @@ class SecurityTest extends TestCase
'title' => 'Other Plan', 'title' => 'Other Plan',
]); ]);
$tool = new PlanList; $tool = new PlanList();
$result = $tool->handle([], ['workspace_id' => $this->workspace->id]); $result = $tool->handle([], ['workspace_id' => $this->workspace->id]);
$this->assertArrayHasKey('success', $result); $this->assertArrayHasKey('success', $result);
@ -388,7 +387,7 @@ class SecurityTest extends TestCase
public function test_state_set_has_workspace_dependency(): void public function test_state_set_has_workspace_dependency(): void
{ {
$tool = new StateSet; $tool = new StateSet();
$dependencies = $tool->dependencies(); $dependencies = $tool->dependencies();
$this->assertNotEmpty($dependencies); $this->assertNotEmpty($dependencies);
@ -397,7 +396,7 @@ class SecurityTest extends TestCase
public function test_state_get_has_workspace_dependency(): void public function test_state_get_has_workspace_dependency(): void
{ {
$tool = new StateGet; $tool = new StateGet();
$dependencies = $tool->dependencies(); $dependencies = $tool->dependencies();
$this->assertNotEmpty($dependencies); $this->assertNotEmpty($dependencies);
@ -406,7 +405,7 @@ class SecurityTest extends TestCase
public function test_state_list_has_workspace_dependency(): void public function test_state_list_has_workspace_dependency(): void
{ {
$tool = new StateList; $tool = new StateList();
$dependencies = $tool->dependencies(); $dependencies = $tool->dependencies();
$this->assertNotEmpty($dependencies); $this->assertNotEmpty($dependencies);
@ -415,7 +414,7 @@ class SecurityTest extends TestCase
public function test_plan_get_has_workspace_dependency(): void public function test_plan_get_has_workspace_dependency(): void
{ {
$tool = new PlanGet; $tool = new PlanGet();
$dependencies = $tool->dependencies(); $dependencies = $tool->dependencies();
$this->assertNotEmpty($dependencies); $this->assertNotEmpty($dependencies);
@ -424,7 +423,7 @@ class SecurityTest extends TestCase
public function test_plan_list_has_workspace_dependency(): void public function test_plan_list_has_workspace_dependency(): void
{ {
$tool = new PlanList; $tool = new PlanList();
$dependencies = $tool->dependencies(); $dependencies = $tool->dependencies();
$this->assertNotEmpty($dependencies); $this->assertNotEmpty($dependencies);

View file

@ -1,36 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Fixtures;
use Illuminate\Foundation\Auth\User as Authenticatable;
/**
* Fake user fixture for Livewire component tests.
*
* Satisfies the isHades() check used by all admin components.
*/
class HadesUser extends Authenticatable
{
protected $fillable = ['id', 'name', 'email'];
protected $table = 'users';
public $timestamps = false;
public function isHades(): bool
{
return true;
}
public function defaultHostWorkspace(): ?object
{
return null;
}
public function getAuthIdentifier(): mixed
{
return $this->attributes['id'] ?? 1;
}
}

View file

@ -1,17 +1,10 @@
<?php <?php
declare(strict_types=1);
namespace Tests; namespace Tests;
use Orchestra\Testbench\TestCase as BaseTestCase; use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase abstract class TestCase extends BaseTestCase
{ {
protected function getPackageProviders($app): array //
{
return [
\Core\Mod\Agentic\Boot::class,
];
}
} }

View file

@ -1,785 +0,0 @@
<?php
declare(strict_types=1);
/**
* Tests for the AgentDetection service.
*
* Covers User-Agent pattern matching for known AI providers, browser and
* non-agent bot detection, MCP token identification, and edge cases.
* Documents the UA patterns used to identify each agent type.
*
* Resolves: #13
*/
use Core\Mod\Agentic\Services\AgentDetection;
use Core\Mod\Agentic\Support\AgentIdentity;
use Illuminate\Http\Request;
// =========================================================================
// Edge Cases
// =========================================================================
describe('edge cases', function () {
it('returns unknownAgent for null User-Agent', function () {
$service = new AgentDetection;
$identity = $service->identifyFromUserAgent(null);
expect($identity->provider)->toBe('unknown')
->and($identity->isAgent())->toBeTrue()
->and($identity->isKnown())->toBeFalse()
->and($identity->confidence)->toBe(AgentIdentity::CONFIDENCE_LOW);
});
it('returns unknownAgent for empty string User-Agent', function () {
$service = new AgentDetection;
$identity = $service->identifyFromUserAgent('');
expect($identity->provider)->toBe('unknown')
->and($identity->isAgent())->toBeTrue();
});
it('returns unknownAgent for whitespace-only User-Agent', function () {
$service = new AgentDetection;
$identity = $service->identifyFromUserAgent(' ');
expect($identity->provider)->toBe('unknown')
->and($identity->isAgent())->toBeTrue();
});
it('returns unknownAgent for generic programmatic client with no known indicators', function () {
$service = new AgentDetection;
// A plain HTTP client string without browser or bot keywords
$identity = $service->identifyFromUserAgent('my-custom-client/1.0');
expect($identity->provider)->toBe('unknown')
->and($identity->isAgent())->toBeTrue()
->and($identity->isKnown())->toBeFalse();
});
it('returns unknownAgent for numeric-only User-Agent', function () {
$service = new AgentDetection;
$identity = $service->identifyFromUserAgent('1.0');
expect($identity->provider)->toBe('unknown');
});
});
// =========================================================================
// Anthropic / Claude Detection
// =========================================================================
describe('Anthropic/Claude detection', function () {
/**
* Pattern: /claude[\s\-_]?code/i
* Examples: "claude-code/1.2.3", "ClaudeCode/1.0", "claude_code"
*/
it('detects Claude Code User-Agent', function () {
$service = new AgentDetection;
$identity = $service->identifyFromUserAgent('claude-code/1.2.3');
expect($identity->provider)->toBe('anthropic')
->and($identity->isKnown())->toBeTrue()
->and($identity->confidence)->toBe(AgentIdentity::CONFIDENCE_HIGH);
});
/**
* Pattern: /\banthropic[\s\-_]?api\b/i
* Examples: "anthropic-api/1.0", "Anthropic API Client/2.0"
*/
it('detects Anthropic API User-Agent', function () {
$service = new AgentDetection;
$identity = $service->identifyFromUserAgent('anthropic-api/1.0 Python/3.11');
expect($identity->provider)->toBe('anthropic')
->and($identity->confidence)->toBe(AgentIdentity::CONFIDENCE_HIGH);
});
/**
* Pattern: /\bclaude\b.*\bai\b/i
* Examples: "Claude AI/2.0", "claude ai client"
*/
it('detects Claude AI User-Agent', function () {
$service = new AgentDetection;
$identity = $service->identifyFromUserAgent('Claude AI Agent/1.0');
expect($identity->provider)->toBe('anthropic');
});
/**
* Pattern: /\bclaude\b.*\bassistant\b/i
* Examples: "claude assistant/1.0", "Claude Assistant integration"
*/
it('detects Claude Assistant User-Agent', function () {
$service = new AgentDetection;
$identity = $service->identifyFromUserAgent('claude assistant integration/2.0');
expect($identity->provider)->toBe('anthropic');
});
/**
* Model pattern: /claude[\s\-_]?opus/i
* Examples: "claude-opus", "Claude Opus", "claude_opus"
*/
it('detects claude-opus model from User-Agent', function () {
$service = new AgentDetection;
$identity = $service->identifyFromUserAgent('claude-opus claude-code/1.0');
expect($identity->provider)->toBe('anthropic')
->and($identity->model)->toBe('claude-opus');
});
/**
* Model pattern: /claude[\s\-_]?sonnet/i
* Examples: "claude-sonnet", "Claude Sonnet", "claude_sonnet"
*/
it('detects claude-sonnet model from User-Agent', function () {
$service = new AgentDetection;
$identity = $service->identifyFromUserAgent('claude-sonnet claude-code/1.0');
expect($identity->provider)->toBe('anthropic')
->and($identity->model)->toBe('claude-sonnet');
});
/**
* Model pattern: /claude[\s\-_]?haiku/i
* Examples: "claude-haiku", "Claude Haiku", "claude_haiku"
*/
it('detects claude-haiku model from User-Agent', function () {
$service = new AgentDetection;
$identity = $service->identifyFromUserAgent('Claude Haiku claude-code/1.0');
expect($identity->provider)->toBe('anthropic')
->and($identity->model)->toBe('claude-haiku');
});
it('returns null model when no Anthropic model pattern matches', function () {
$service = new AgentDetection;
$identity = $service->identifyFromUserAgent('claude-code/1.0');
expect($identity->provider)->toBe('anthropic')
->and($identity->model)->toBeNull();
});
});
// =========================================================================
// OpenAI / ChatGPT Detection
// =========================================================================
describe('OpenAI/ChatGPT detection', function () {
/**
* Pattern: /\bChatGPT\b/i
* Examples: "ChatGPT/1.2", "chatgpt-plugin/1.0"
*/
it('detects ChatGPT User-Agent', function () {
$service = new AgentDetection;
$identity = $service->identifyFromUserAgent('ChatGPT/1.2 OpenAI');
expect($identity->provider)->toBe('openai')
->and($identity->confidence)->toBe(AgentIdentity::CONFIDENCE_HIGH);
});
/**
* Pattern: /\bOpenAI\b/i
* Examples: "OpenAI Python SDK/1.0", "openai-node/4.0"
*/
it('detects OpenAI User-Agent', function () {
$service = new AgentDetection;
$identity = $service->identifyFromUserAgent('OpenAI Python SDK/1.0');
expect($identity->provider)->toBe('openai');
});
/**
* Pattern: /\bGPT[\s\-_]?4\b/i
* Model pattern: /\bGPT[\s\-_]?4/i
* Examples: "GPT-4 Agent/1.0", "GPT4 client", "GPT 4"
*/
it('detects GPT-4 and sets gpt-4 model', function () {
$service = new AgentDetection;
$identity = $service->identifyFromUserAgent('GPT-4 Agent/1.0');
expect($identity->provider)->toBe('openai')
->and($identity->model)->toBe('gpt-4');
});
/**
* Pattern: /\bGPT[\s\-_]?3\.?5\b/i
* Model pattern: /\bGPT[\s\-_]?3\.?5/i
* Examples: "GPT-3.5 Turbo", "GPT35 client", "GPT 3.5"
*/
it('detects GPT-3.5 and sets gpt-3.5 model', function () {
$service = new AgentDetection;
$identity = $service->identifyFromUserAgent('GPT-3.5 Turbo client/1.0');
expect($identity->provider)->toBe('openai')
->and($identity->model)->toBe('gpt-3.5');
});
/**
* Pattern: /\bo1[\s\-_]?preview\b/i
* Examples: "o1-preview OpenAI client/1.0"
*/
it('detects o1-preview User-Agent', function () {
$service = new AgentDetection;
$identity = $service->identifyFromUserAgent('o1-preview OpenAI client/1.0');
expect($identity->provider)->toBe('openai');
});
/**
* Pattern: /\bo1[\s\-_]?mini\b/i
* Examples: "o1-mini OpenAI client/1.0"
*/
it('detects o1-mini User-Agent', function () {
$service = new AgentDetection;
$identity = $service->identifyFromUserAgent('o1-mini OpenAI client/1.0');
expect($identity->provider)->toBe('openai');
});
});
// =========================================================================
// Google / Gemini Detection
// =========================================================================
describe('Google/Gemini detection', function () {
/**
* Pattern: /\bGoogle[\s\-_]?AI\b/i
* Examples: "Google AI Studio/1.0", "GoogleAI/2.0"
*/
it('detects Google AI User-Agent', function () {
$service = new AgentDetection;
$identity = $service->identifyFromUserAgent('Google AI Studio/1.0');
expect($identity->provider)->toBe('google')
->and($identity->confidence)->toBe(AgentIdentity::CONFIDENCE_HIGH);
});
/**
* Pattern: /\bGemini\b/i
* Examples: "Gemini API Client/2.0", "gemini-client/1.0"
*/
it('detects Gemini User-Agent', function () {
$service = new AgentDetection;
$identity = $service->identifyFromUserAgent('Gemini API Client/2.0');
expect($identity->provider)->toBe('google');
});
/**
* Pattern: /\bBard\b/i
* Examples: "Bard/1.0", "Google Bard client"
*/
it('detects Bard User-Agent', function () {
$service = new AgentDetection;
$identity = $service->identifyFromUserAgent('Bard/1.0');
expect($identity->provider)->toBe('google');
});
/**
* Pattern: /\bPaLM\b/i
* Examples: "PaLM API/2.0", "Google PaLM"
*/
it('detects PaLM User-Agent', function () {
$service = new AgentDetection;
$identity = $service->identifyFromUserAgent('PaLM API/2.0');
expect($identity->provider)->toBe('google');
});
/**
* Model pattern: /gemini[\s\-_]?(1\.5[\s\-_]?)?pro/i
* Examples: "Gemini Pro client/1.0", "gemini-pro/1.0", "gemini-1.5-pro"
*/
it('detects gemini-pro model from User-Agent', function () {
$service = new AgentDetection;
$identity = $service->identifyFromUserAgent('Gemini Pro client/1.0');
expect($identity->provider)->toBe('google')
->and($identity->model)->toBe('gemini-pro');
});
/**
* Model pattern: /gemini[\s\-_]?(1\.5[\s\-_]?)?flash/i
* Examples: "gemini-flash/1.5", "Gemini Flash client", "gemini-1.5-flash"
*/
it('detects gemini-flash model from User-Agent', function () {
$service = new AgentDetection;
$identity = $service->identifyFromUserAgent('gemini-flash/1.5');
expect($identity->provider)->toBe('google')
->and($identity->model)->toBe('gemini-flash');
});
/**
* Model pattern: /gemini[\s\-_]?(1\.5[\s\-_]?)?ultra/i
* Examples: "Gemini Ultra/1.0", "gemini-1.5-ultra"
*/
it('detects gemini-ultra model from User-Agent', function () {
$service = new AgentDetection;
$identity = $service->identifyFromUserAgent('Gemini Ultra/1.0');
expect($identity->provider)->toBe('google')
->and($identity->model)->toBe('gemini-ultra');
});
});
// =========================================================================
// Meta / LLaMA Detection
// =========================================================================
describe('Meta/LLaMA detection', function () {
/**
* Pattern: /\bMeta[\s\-_]?AI\b/i
* Examples: "Meta AI assistant/1.0", "MetaAI/1.0"
*/
it('detects Meta AI User-Agent', function () {
$service = new AgentDetection;
$identity = $service->identifyFromUserAgent('Meta AI assistant/1.0');
expect($identity->provider)->toBe('meta')
->and($identity->confidence)->toBe(AgentIdentity::CONFIDENCE_HIGH);
});
/**
* Pattern: /\bLLaMA\b/i
* Examples: "LLaMA model client/1.0", "llama-inference"
*/
it('detects LLaMA User-Agent', function () {
$service = new AgentDetection;
$identity = $service->identifyFromUserAgent('LLaMA model client/1.0');
expect($identity->provider)->toBe('meta');
});
/**
* Pattern: /\bLlama[\s\-_]?[23]\b/i
* Model pattern: /llama[\s\-_]?3/i
* Examples: "Llama-3 inference client/1.0", "Llama3/1.0", "Llama 3"
*/
it('detects Llama 3 and sets llama-3 model', function () {
$service = new AgentDetection;
$identity = $service->identifyFromUserAgent('Llama-3 inference client/1.0');
expect($identity->provider)->toBe('meta')
->and($identity->model)->toBe('llama-3');
});
/**
* Pattern: /\bLlama[\s\-_]?[23]\b/i
* Model pattern: /llama[\s\-_]?2/i
* Examples: "Llama-2 inference client/1.0", "Llama2/1.0", "Llama 2"
*/
it('detects Llama 2 and sets llama-2 model', function () {
$service = new AgentDetection;
$identity = $service->identifyFromUserAgent('Llama-2 inference client/1.0');
expect($identity->provider)->toBe('meta')
->and($identity->model)->toBe('llama-2');
});
});
// =========================================================================
// Mistral Detection
// =========================================================================
describe('Mistral detection', function () {
/**
* Pattern: /\bMistral\b/i
* Examples: "Mistral AI client/1.0", "mistral-python/1.0"
*/
it('detects Mistral User-Agent', function () {
$service = new AgentDetection;
$identity = $service->identifyFromUserAgent('Mistral AI client/1.0');
expect($identity->provider)->toBe('mistral')
->and($identity->confidence)->toBe(AgentIdentity::CONFIDENCE_HIGH);
});
/**
* Pattern: /\bMixtral\b/i
* Model pattern: /mixtral/i
* Examples: "Mixtral-8x7B client/1.0", "mixtral inference"
*/
it('detects Mixtral User-Agent and sets mixtral model', function () {
$service = new AgentDetection;
$identity = $service->identifyFromUserAgent('Mixtral-8x7B client/1.0');
expect($identity->provider)->toBe('mistral')
->and($identity->model)->toBe('mixtral');
});
/**
* Model pattern: /mistral[\s\-_]?large/i
* Examples: "Mistral Large API/2.0", "mistral-large/1.0"
*/
it('detects mistral-large model from User-Agent', function () {
$service = new AgentDetection;
$identity = $service->identifyFromUserAgent('Mistral Large API/2.0');
expect($identity->provider)->toBe('mistral')
->and($identity->model)->toBe('mistral-large');
});
/**
* Model pattern: /mistral[\s\-_]?medium/i
* Examples: "Mistral Medium/1.0", "mistral-medium client"
*/
it('detects mistral-medium model from User-Agent', function () {
$service = new AgentDetection;
$identity = $service->identifyFromUserAgent('mistral-medium client/1.0');
expect($identity->provider)->toBe('mistral')
->and($identity->model)->toBe('mistral-medium');
});
});
// =========================================================================
// Browser Detection (not an agent)
// =========================================================================
describe('browser detection', function () {
/**
* Pattern: /\bMozilla\b/i
* All modern browsers include "Mozilla/5.0" in their UA string.
* Chrome example: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) ... Chrome/120..."
*/
it('detects Chrome browser as not an agent', function () {
$service = new AgentDetection;
$identity = $service->identifyFromUserAgent(
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
);
expect($identity->isNotAgent())->toBeTrue()
->and($identity->provider)->toBe('not_agent')
->and($identity->confidence)->toBe(AgentIdentity::CONFIDENCE_HIGH);
});
/**
* Firefox example: "Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0"
*/
it('detects Firefox browser as not an agent', function () {
$service = new AgentDetection;
$identity = $service->identifyFromUserAgent(
'Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0'
);
expect($identity->isNotAgent())->toBeTrue();
});
/**
* Safari example: "Mozilla/5.0 (Macintosh; ...) ... Version/17.0 Safari/605.1.15"
*/
it('detects Safari browser as not an agent', function () {
$service = new AgentDetection;
$identity = $service->identifyFromUserAgent(
'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15'
);
expect($identity->isNotAgent())->toBeTrue();
});
/**
* Edge example: "Mozilla/5.0 ... Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0"
*/
it('detects Edge browser as not an agent', function () {
$service = new AgentDetection;
$identity = $service->identifyFromUserAgent(
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0'
);
expect($identity->isNotAgent())->toBeTrue();
});
});
// =========================================================================
// Non-Agent Bot Detection
// =========================================================================
describe('non-agent bot detection', function () {
/**
* Pattern: /\bGooglebot\b/i
* Example: "Googlebot/2.1 (+http://www.google.com/bot.html)"
*/
it('detects Googlebot as not an agent', function () {
$service = new AgentDetection;
$identity = $service->identifyFromUserAgent(
'Googlebot/2.1 (+http://www.google.com/bot.html)'
);
expect($identity->isNotAgent())->toBeTrue()
->and($identity->provider)->toBe('not_agent');
});
/**
* Pattern: /\bBingbot\b/i
* Example: "Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)"
*/
it('detects Bingbot as not an agent', function () {
$service = new AgentDetection;
$identity = $service->identifyFromUserAgent(
'Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)'
);
expect($identity->isNotAgent())->toBeTrue();
});
/**
* Pattern: /\bcurl\b/i
* Example: "curl/7.68.0"
*/
it('detects curl as not an agent', function () {
$service = new AgentDetection;
$identity = $service->identifyFromUserAgent('curl/7.68.0');
expect($identity->isNotAgent())->toBeTrue();
});
/**
* Pattern: /\bpython-requests\b/i
* Example: "python-requests/2.28.0"
*/
it('detects python-requests as not an agent', function () {
$service = new AgentDetection;
$identity = $service->identifyFromUserAgent('python-requests/2.28.0');
expect($identity->isNotAgent())->toBeTrue();
});
/**
* Pattern: /\bPostman\b/i
* Example: "PostmanRuntime/7.32.0"
*/
it('detects Postman as not an agent', function () {
$service = new AgentDetection;
$identity = $service->identifyFromUserAgent('PostmanRuntime/7.32.0');
expect($identity->isNotAgent())->toBeTrue();
});
/**
* Pattern: /\bSlackbot\b/i
* Example: "Slackbot-LinkExpanding 1.0 (+https://api.slack.com/robots)"
*/
it('detects Slackbot as not an agent', function () {
$service = new AgentDetection;
$identity = $service->identifyFromUserAgent(
'Slackbot-LinkExpanding 1.0 (+https://api.slack.com/robots)'
);
expect($identity->isNotAgent())->toBeTrue();
});
/**
* Pattern: /\bgo-http-client\b/i
* Example: "Go-http-client/1.1"
*/
it('detects Go-http-client as not an agent', function () {
$service = new AgentDetection;
$identity = $service->identifyFromUserAgent('Go-http-client/1.1');
expect($identity->isNotAgent())->toBeTrue();
});
/**
* Pattern: /\baxios\b/i
* Example: "axios/1.4.0"
*/
it('detects axios as not an agent', function () {
$service = new AgentDetection;
$identity = $service->identifyFromUserAgent('axios/1.4.0');
expect($identity->isNotAgent())->toBeTrue();
});
/**
* Pattern: /\bnode-fetch\b/i
* Example: "node-fetch/2.6.9"
*/
it('detects node-fetch as not an agent', function () {
$service = new AgentDetection;
$identity = $service->identifyFromUserAgent('node-fetch/2.6.9');
expect($identity->isNotAgent())->toBeTrue();
});
});
// =========================================================================
// MCP Token Detection
// =========================================================================
describe('MCP token detection', function () {
/**
* Structured token format: "provider:model:secret"
* Example: "anthropic:claude-opus:abc123"
*/
it('identifies Anthropic from structured MCP token', function () {
$service = new AgentDetection;
$identity = $service->identifyFromMcpToken('anthropic:claude-opus:secret123');
expect($identity->provider)->toBe('anthropic')
->and($identity->model)->toBe('claude-opus')
->and($identity->confidence)->toBe(AgentIdentity::CONFIDENCE_HIGH);
});
/**
* Structured token format: "provider:model:secret"
* Example: "openai:gpt-4:xyz789"
*/
it('identifies OpenAI from structured MCP token', function () {
$service = new AgentDetection;
$identity = $service->identifyFromMcpToken('openai:gpt-4:secret456');
expect($identity->provider)->toBe('openai')
->and($identity->model)->toBe('gpt-4')
->and($identity->confidence)->toBe(AgentIdentity::CONFIDENCE_HIGH);
});
/**
* Structured token format: "provider:model:secret"
* Example: "google:gemini-pro:zyx321"
*/
it('identifies Google from structured MCP token', function () {
$service = new AgentDetection;
$identity = $service->identifyFromMcpToken('google:gemini-pro:secret789');
expect($identity->provider)->toBe('google')
->and($identity->model)->toBe('gemini-pro')
->and($identity->confidence)->toBe(AgentIdentity::CONFIDENCE_HIGH);
});
it('accepts meta and mistral providers in structured tokens', function () {
$service = new AgentDetection;
expect($service->identifyFromMcpToken('meta:llama-3:secret')->provider)->toBe('meta');
expect($service->identifyFromMcpToken('mistral:mistral-large:secret')->provider)->toBe('mistral');
});
it('returns medium-confidence unknown for unrecognised token string', function () {
$service = new AgentDetection;
// No colon separator — cannot parse as structured token
$identity = $service->identifyFromMcpToken('some-random-opaque-token');
expect($identity->provider)->toBe('unknown')
->and($identity->confidence)->toBe(AgentIdentity::CONFIDENCE_MEDIUM);
});
it('returns medium-confidence unknown for structured token with invalid provider', function () {
$service = new AgentDetection;
$identity = $service->identifyFromMcpToken('facebook:llama:secret');
expect($identity->provider)->toBe('unknown')
->and($identity->confidence)->toBe(AgentIdentity::CONFIDENCE_MEDIUM);
});
it('prioritises MCP token header over User-Agent in HTTP request', function () {
$service = new AgentDetection;
$request = Request::create('/test', 'GET', [], [], [], [
'HTTP_X_MCP_TOKEN' => 'anthropic:claude-sonnet:token123',
'HTTP_USER_AGENT' => 'python-requests/2.28.0',
]);
// MCP token takes precedence; UA would indicate notAnAgent otherwise
$identity = $service->identify($request);
expect($identity->provider)->toBe('anthropic')
->and($identity->model)->toBe('claude-sonnet');
});
it('falls back to User-Agent when no MCP token header is present', function () {
$service = new AgentDetection;
$request = Request::create('/test', 'GET', [], [], [], [
'HTTP_USER_AGENT' => 'claude-code/1.0',
]);
$identity = $service->identify($request);
expect($identity->provider)->toBe('anthropic');
});
});
// =========================================================================
// Provider Validation
// =========================================================================
describe('provider validation', function () {
it('accepts all known valid providers', function () {
$service = new AgentDetection;
$validProviders = ['anthropic', 'openai', 'google', 'meta', 'mistral', 'local', 'unknown'];
foreach ($validProviders as $provider) {
expect($service->isValidProvider($provider))
->toBeTrue("Expected '{$provider}' to be a valid provider");
}
});
it('rejects unknown provider names', function () {
$service = new AgentDetection;
expect($service->isValidProvider('facebook'))->toBeFalse()
->and($service->isValidProvider('huggingface'))->toBeFalse()
->and($service->isValidProvider(''))->toBeFalse();
});
it('rejects not_agent as a provider (it is a sentinel value, not a provider)', function () {
$service = new AgentDetection;
expect($service->isValidProvider('not_agent'))->toBeFalse();
});
it('returns all valid providers as an array', function () {
$service = new AgentDetection;
$providers = $service->getValidProviders();
expect($providers)
->toContain('anthropic')
->toContain('openai')
->toContain('google')
->toContain('meta')
->toContain('mistral')
->toContain('local')
->toContain('unknown');
});
});
// =========================================================================
// isAgentUserAgent Shorthand
// =========================================================================
describe('isAgentUserAgent shorthand', function () {
it('returns true for known AI agent User-Agents', function () {
$service = new AgentDetection;
expect($service->isAgentUserAgent('claude-code/1.0'))->toBeTrue()
->and($service->isAgentUserAgent('OpenAI Python/1.0'))->toBeTrue()
->and($service->isAgentUserAgent('Gemini API/2.0'))->toBeTrue();
});
it('returns false for browser User-Agents', function () {
$service = new AgentDetection;
$browserUA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0';
expect($service->isAgentUserAgent($browserUA))->toBeFalse();
});
it('returns false for crawler User-Agents', function () {
$service = new AgentDetection;
expect($service->isAgentUserAgent('Googlebot/2.1'))->toBeFalse()
->and($service->isAgentUserAgent('curl/7.68.0'))->toBeFalse();
});
it('returns true for null User-Agent (unknown programmatic access)', function () {
$service = new AgentDetection;
// Null UA returns unknownAgent; isAgent() is true because provider !== 'not_agent'
expect($service->isAgentUserAgent(null))->toBeTrue();
});
it('returns true for unrecognised non-browser User-Agent', function () {
$service = new AgentDetection;
// No browser indicators → unknownAgent → isAgent() true
expect($service->isAgentUserAgent('custom-agent/0.1'))->toBeTrue();
});
});

View file

@ -1,287 +0,0 @@
<?php
declare(strict_types=1);
/**
* Tests for AgentToolRegistry caching behaviour (PERF-002).
*
* Verifies that forApiKey() caches results, that the cache is invalidated
* when permissions change or a key is revoked, and that the TTL is honoured.
*/
use Core\Api\Models\ApiKey;
use Core\Mod\Agentic\Mcp\Tools\Agent\Contracts\AgentToolInterface;
use Core\Mod\Agentic\Services\AgentToolRegistry;
use Illuminate\Support\Facades\Cache;
// =========================================================================
// Helpers
// =========================================================================
/**
* Build a minimal AgentToolInterface stub.
*/
function makeTool(string $name, array $scopes = [], string $category = 'test'): AgentToolInterface
{
return new class($name, $scopes, $category) implements AgentToolInterface
{
public function __construct(
private readonly string $toolName,
private readonly array $toolScopes,
private readonly string $toolCategory,
) {}
public function name(): string
{
return $this->toolName;
}
public function description(): string
{
return 'Test tool';
}
public function inputSchema(): array
{
return [];
}
public function handle(array $args, array $context = []): array
{
return ['success' => true];
}
public function requiredScopes(): array
{
return $this->toolScopes;
}
public function category(): string
{
return $this->toolCategory;
}
};
}
/**
* Build a minimal ApiKey mock with controllable scopes and tool_scopes.
*
* Uses Mockery to avoid requiring the real ApiKey class at load time,
* since the php-api package is not available in this test environment.
*/
function makeApiKey(int $id, array $scopes = [], ?array $toolScopes = null): ApiKey
{
$key = Mockery::mock(ApiKey::class);
$key->shouldReceive('getKey')->andReturn($id);
$key->shouldReceive('hasScope')->andReturnUsing(
fn (string $scope) => in_array($scope, $scopes, true)
);
$key->tool_scopes = $toolScopes;
return $key;
}
// =========================================================================
// Caching basic behaviour
// =========================================================================
describe('forApiKey caching', function () {
beforeEach(function () {
Cache::flush();
});
it('returns the correct tools on first call (cache miss)', function () {
$registry = new AgentToolRegistry;
$registry->register(makeTool('plan.create', ['plans.write']));
$registry->register(makeTool('session.start', ['sessions.write']));
$apiKey = makeApiKey(1, ['plans.write', 'sessions.write']);
$tools = $registry->forApiKey($apiKey);
expect($tools->keys()->sort()->values()->all())
->toBe(['plan.create', 'session.start']);
});
it('stores permitted tool names in cache after first call', function () {
$registry = new AgentToolRegistry;
$registry->register(makeTool('plan.create', ['plans.write']));
$apiKey = makeApiKey(42, ['plans.write']);
$registry->forApiKey($apiKey);
$cached = Cache::get('agent_tool_registry:api_key:42');
expect($cached)->toBe(['plan.create']);
});
it('returns same result on second call (cache hit)', function () {
$registry = new AgentToolRegistry;
$registry->register(makeTool('plan.create', ['plans.write']));
$registry->register(makeTool('session.start', ['sessions.write']));
$apiKey = makeApiKey(1, ['plans.write']);
$first = $registry->forApiKey($apiKey)->keys()->all();
$second = $registry->forApiKey($apiKey)->keys()->all();
expect($second)->toBe($first);
});
it('filters tools whose required scopes the key lacks', function () {
$registry = new AgentToolRegistry;
$registry->register(makeTool('plan.create', ['plans.write']));
$registry->register(makeTool('session.start', ['sessions.write']));
$apiKey = makeApiKey(1, ['plans.write']); // only plans.write
$tools = $registry->forApiKey($apiKey);
expect($tools->has('plan.create'))->toBeTrue()
->and($tools->has('session.start'))->toBeFalse();
});
it('respects tool_scopes allowlist on the api key', function () {
$registry = new AgentToolRegistry;
$registry->register(makeTool('plan.create', []));
$registry->register(makeTool('session.start', []));
$apiKey = makeApiKey(5, [], ['plan.create']); // explicitly restricted
$tools = $registry->forApiKey($apiKey);
expect($tools->has('plan.create'))->toBeTrue()
->and($tools->has('session.start'))->toBeFalse();
});
it('allows all tools when tool_scopes is null', function () {
$registry = new AgentToolRegistry;
$registry->register(makeTool('plan.create', []));
$registry->register(makeTool('session.start', []));
$apiKey = makeApiKey(7, [], null); // null = unrestricted
$tools = $registry->forApiKey($apiKey);
expect($tools)->toHaveCount(2);
});
it('caches separately per api key id', function () {
$registry = new AgentToolRegistry;
$registry->register(makeTool('plan.create', ['plans.write']));
$registry->register(makeTool('session.start', ['sessions.write']));
$keyA = makeApiKey(100, ['plans.write']);
$keyB = makeApiKey(200, ['sessions.write']);
$toolsA = $registry->forApiKey($keyA)->keys()->all();
$toolsB = $registry->forApiKey($keyB)->keys()->all();
expect($toolsA)->toBe(['plan.create'])
->and($toolsB)->toBe(['session.start']);
expect(Cache::get('agent_tool_registry:api_key:100'))->toBe(['plan.create'])
->and(Cache::get('agent_tool_registry:api_key:200'))->toBe(['session.start']);
});
});
// =========================================================================
// Cache TTL
// =========================================================================
describe('cache TTL', function () {
it('declares CACHE_TTL constant as 3600 (1 hour)', function () {
expect(AgentToolRegistry::CACHE_TTL)->toBe(3600);
});
it('stores entries in cache after first call', function () {
Cache::flush();
$registry = new AgentToolRegistry;
$registry->register(makeTool('plan.create', []));
$apiKey = makeApiKey(99, []);
$registry->forApiKey($apiKey);
expect(Cache::has('agent_tool_registry:api_key:99'))->toBeTrue();
});
});
// =========================================================================
// Cache invalidation flushCacheForApiKey
// =========================================================================
describe('flushCacheForApiKey', function () {
beforeEach(function () {
Cache::flush();
});
it('removes the cached entry for the given key id', function () {
$registry = new AgentToolRegistry;
$registry->register(makeTool('plan.create', []));
$apiKey = makeApiKey(10, []);
$registry->forApiKey($apiKey);
expect(Cache::has('agent_tool_registry:api_key:10'))->toBeTrue();
$registry->flushCacheForApiKey(10);
expect(Cache::has('agent_tool_registry:api_key:10'))->toBeFalse();
});
it('re-fetches permitted tools after cache flush', function () {
$registry = new AgentToolRegistry;
$registry->register(makeTool('plan.create', []));
$apiKey = makeApiKey(11, []);
// Prime the cache (only plan.create at this point)
expect($registry->forApiKey($apiKey)->keys()->all())->toBe(['plan.create']);
$registry->flushCacheForApiKey(11);
// Register an additional tool should appear now that cache is gone
$registry->register(makeTool('session.start', []));
$after = $registry->forApiKey($apiKey)->keys()->sort()->values()->all();
expect($after)->toBe(['plan.create', 'session.start']);
});
it('does not affect cache entries for other key ids', function () {
$registry = new AgentToolRegistry;
$registry->register(makeTool('plan.create', []));
$key12 = makeApiKey(12, []);
$key13 = makeApiKey(13, []);
$registry->forApiKey($key12);
$registry->forApiKey($key13);
$registry->flushCacheForApiKey(12);
expect(Cache::has('agent_tool_registry:api_key:12'))->toBeFalse()
->and(Cache::has('agent_tool_registry:api_key:13'))->toBeTrue();
});
it('accepts a string key id', function () {
$registry = new AgentToolRegistry;
$registry->register(makeTool('plan.create', []));
$apiKey = makeApiKey(20, []);
$registry->forApiKey($apiKey);
$registry->flushCacheForApiKey('20');
expect(Cache::has('agent_tool_registry:api_key:20'))->toBeFalse();
});
it('is a no-op when cache entry does not exist', function () {
$registry = new AgentToolRegistry;
// Should not throw when nothing is cached
$registry->flushCacheForApiKey(999);
expect(Cache::has('agent_tool_registry:api_key:999'))->toBeFalse();
});
});

View file

@ -15,7 +15,6 @@ use Core\Mod\Agentic\Services\ClaudeService;
use Core\Mod\Agentic\Services\GeminiService; use Core\Mod\Agentic\Services\GeminiService;
use Core\Mod\Agentic\Services\OpenAIService; use Core\Mod\Agentic\Services\OpenAIService;
use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Log;
use InvalidArgumentException; use InvalidArgumentException;
// ========================================================================= // =========================================================================
@ -28,7 +27,7 @@ describe('provider registration', function () {
Config::set('services.google.ai_api_key', 'test-gemini-key'); Config::set('services.google.ai_api_key', 'test-gemini-key');
Config::set('services.openai.api_key', 'test-openai-key'); Config::set('services.openai.api_key', 'test-openai-key');
$manager = new AgenticManager; $manager = new AgenticManager();
expect($manager->claude())->toBeInstanceOf(ClaudeService::class) expect($manager->claude())->toBeInstanceOf(ClaudeService::class)
->and($manager->gemini())->toBeInstanceOf(GeminiService::class) ->and($manager->gemini())->toBeInstanceOf(GeminiService::class)
@ -39,7 +38,7 @@ describe('provider registration', function () {
Config::set('services.anthropic.api_key', 'test-key'); Config::set('services.anthropic.api_key', 'test-key');
Config::set('services.anthropic.model', 'claude-opus-4-20250514'); Config::set('services.anthropic.model', 'claude-opus-4-20250514');
$manager = new AgenticManager; $manager = new AgenticManager();
expect($manager->claude()->defaultModel())->toBe('claude-opus-4-20250514'); expect($manager->claude()->defaultModel())->toBe('claude-opus-4-20250514');
}); });
@ -48,7 +47,7 @@ describe('provider registration', function () {
Config::set('services.google.ai_api_key', 'test-key'); Config::set('services.google.ai_api_key', 'test-key');
Config::set('services.google.ai_model', 'gemini-1.5-pro'); Config::set('services.google.ai_model', 'gemini-1.5-pro');
$manager = new AgenticManager; $manager = new AgenticManager();
expect($manager->gemini()->defaultModel())->toBe('gemini-1.5-pro'); expect($manager->gemini()->defaultModel())->toBe('gemini-1.5-pro');
}); });
@ -57,7 +56,7 @@ describe('provider registration', function () {
Config::set('services.openai.api_key', 'test-key'); Config::set('services.openai.api_key', 'test-key');
Config::set('services.openai.model', 'gpt-4o'); Config::set('services.openai.model', 'gpt-4o');
$manager = new AgenticManager; $manager = new AgenticManager();
expect($manager->openai()->defaultModel())->toBe('gpt-4o'); expect($manager->openai()->defaultModel())->toBe('gpt-4o');
}); });
@ -66,7 +65,7 @@ describe('provider registration', function () {
Config::set('services.anthropic.api_key', 'test-key'); Config::set('services.anthropic.api_key', 'test-key');
Config::set('services.anthropic.model', null); Config::set('services.anthropic.model', null);
$manager = new AgenticManager; $manager = new AgenticManager();
expect($manager->claude()->defaultModel())->toBe('claude-sonnet-4-20250514'); expect($manager->claude()->defaultModel())->toBe('claude-sonnet-4-20250514');
}); });
@ -75,7 +74,7 @@ describe('provider registration', function () {
Config::set('services.google.ai_api_key', 'test-key'); Config::set('services.google.ai_api_key', 'test-key');
Config::set('services.google.ai_model', null); Config::set('services.google.ai_model', null);
$manager = new AgenticManager; $manager = new AgenticManager();
expect($manager->gemini()->defaultModel())->toBe('gemini-2.0-flash'); expect($manager->gemini()->defaultModel())->toBe('gemini-2.0-flash');
}); });
@ -84,7 +83,7 @@ describe('provider registration', function () {
Config::set('services.openai.api_key', 'test-key'); Config::set('services.openai.api_key', 'test-key');
Config::set('services.openai.model', null); Config::set('services.openai.model', null);
$manager = new AgenticManager; $manager = new AgenticManager();
expect($manager->openai()->defaultModel())->toBe('gpt-4o-mini'); expect($manager->openai()->defaultModel())->toBe('gpt-4o-mini');
}); });
@ -102,7 +101,7 @@ describe('provider retrieval', function () {
}); });
it('retrieves provider by name using provider() method', function () { it('retrieves provider by name using provider() method', function () {
$manager = new AgenticManager; $manager = new AgenticManager();
expect($manager->provider('claude'))->toBeInstanceOf(ClaudeService::class) expect($manager->provider('claude'))->toBeInstanceOf(ClaudeService::class)
->and($manager->provider('gemini'))->toBeInstanceOf(GeminiService::class) ->and($manager->provider('gemini'))->toBeInstanceOf(GeminiService::class)
@ -110,27 +109,27 @@ describe('provider retrieval', function () {
}); });
it('returns default provider when null passed to provider()', function () { it('returns default provider when null passed to provider()', function () {
$manager = new AgenticManager; $manager = new AgenticManager();
// Default is 'claude' // Default is 'claude'
expect($manager->provider(null))->toBeInstanceOf(ClaudeService::class); expect($manager->provider(null))->toBeInstanceOf(ClaudeService::class);
}); });
it('returns default provider when no argument passed to provider()', function () { it('returns default provider when no argument passed to provider()', function () {
$manager = new AgenticManager; $manager = new AgenticManager();
expect($manager->provider())->toBeInstanceOf(ClaudeService::class); expect($manager->provider())->toBeInstanceOf(ClaudeService::class);
}); });
it('throws exception for unknown provider name', function () { it('throws exception for unknown provider name', function () {
$manager = new AgenticManager; $manager = new AgenticManager();
expect(fn () => $manager->provider('unknown')) expect(fn () => $manager->provider('unknown'))
->toThrow(InvalidArgumentException::class, 'Unknown AI provider: unknown'); ->toThrow(InvalidArgumentException::class, 'Unknown AI provider: unknown');
}); });
it('returns provider implementing AgenticProviderInterface', function () { it('returns provider implementing AgenticProviderInterface', function () {
$manager = new AgenticManager; $manager = new AgenticManager();
expect($manager->provider('claude'))->toBeInstanceOf(AgenticProviderInterface::class); expect($manager->provider('claude'))->toBeInstanceOf(AgenticProviderInterface::class);
}); });
@ -148,13 +147,13 @@ describe('default provider', function () {
}); });
it('uses claude as default provider initially', function () { it('uses claude as default provider initially', function () {
$manager = new AgenticManager; $manager = new AgenticManager();
expect($manager->provider()->name())->toBe('claude'); expect($manager->provider()->name())->toBe('claude');
}); });
it('allows changing default provider to gemini', function () { it('allows changing default provider to gemini', function () {
$manager = new AgenticManager; $manager = new AgenticManager();
$manager->setDefault('gemini'); $manager->setDefault('gemini');
@ -162,7 +161,7 @@ describe('default provider', function () {
}); });
it('allows changing default provider to openai', function () { it('allows changing default provider to openai', function () {
$manager = new AgenticManager; $manager = new AgenticManager();
$manager->setDefault('openai'); $manager->setDefault('openai');
@ -170,14 +169,14 @@ describe('default provider', function () {
}); });
it('throws exception when setting unknown default provider', function () { it('throws exception when setting unknown default provider', function () {
$manager = new AgenticManager; $manager = new AgenticManager();
expect(fn () => $manager->setDefault('unknown')) expect(fn () => $manager->setDefault('unknown'))
->toThrow(InvalidArgumentException::class, 'Unknown AI provider: unknown'); ->toThrow(InvalidArgumentException::class, 'Unknown AI provider: unknown');
}); });
it('allows switching default provider multiple times', function () { it('allows switching default provider multiple times', function () {
$manager = new AgenticManager; $manager = new AgenticManager();
$manager->setDefault('gemini'); $manager->setDefault('gemini');
expect($manager->provider()->name())->toBe('gemini'); expect($manager->provider()->name())->toBe('gemini');
@ -198,7 +197,7 @@ describe('provider availability', function () {
it('reports provider as available when API key is set', function () { it('reports provider as available when API key is set', function () {
Config::set('services.anthropic.api_key', 'test-key'); Config::set('services.anthropic.api_key', 'test-key');
$manager = new AgenticManager; $manager = new AgenticManager();
expect($manager->isAvailable('claude'))->toBeTrue(); expect($manager->isAvailable('claude'))->toBeTrue();
}); });
@ -208,7 +207,7 @@ describe('provider availability', function () {
Config::set('services.google.ai_api_key', ''); Config::set('services.google.ai_api_key', '');
Config::set('services.openai.api_key', ''); Config::set('services.openai.api_key', '');
$manager = new AgenticManager; $manager = new AgenticManager();
expect($manager->isAvailable('claude'))->toBeFalse() expect($manager->isAvailable('claude'))->toBeFalse()
->and($manager->isAvailable('gemini'))->toBeFalse() ->and($manager->isAvailable('gemini'))->toBeFalse()
@ -218,13 +217,13 @@ describe('provider availability', function () {
it('reports provider as unavailable when API key is null', function () { it('reports provider as unavailable when API key is null', function () {
Config::set('services.anthropic.api_key', null); Config::set('services.anthropic.api_key', null);
$manager = new AgenticManager; $manager = new AgenticManager();
expect($manager->isAvailable('claude'))->toBeFalse(); expect($manager->isAvailable('claude'))->toBeFalse();
}); });
it('returns false for unknown provider name', function () { it('returns false for unknown provider name', function () {
$manager = new AgenticManager; $manager = new AgenticManager();
expect($manager->isAvailable('unknown'))->toBeFalse(); expect($manager->isAvailable('unknown'))->toBeFalse();
}); });
@ -234,7 +233,7 @@ describe('provider availability', function () {
Config::set('services.google.ai_api_key', ''); Config::set('services.google.ai_api_key', '');
Config::set('services.openai.api_key', 'test-key'); Config::set('services.openai.api_key', 'test-key');
$manager = new AgenticManager; $manager = new AgenticManager();
expect($manager->isAvailable('claude'))->toBeTrue() expect($manager->isAvailable('claude'))->toBeTrue()
->and($manager->isAvailable('gemini'))->toBeFalse() ->and($manager->isAvailable('gemini'))->toBeFalse()
@ -252,7 +251,7 @@ describe('available providers list', function () {
Config::set('services.google.ai_api_key', 'test-gemini-key'); Config::set('services.google.ai_api_key', 'test-gemini-key');
Config::set('services.openai.api_key', 'test-openai-key'); Config::set('services.openai.api_key', 'test-openai-key');
$manager = new AgenticManager; $manager = new AgenticManager();
$available = $manager->availableProviders(); $available = $manager->availableProviders();
expect($available)->toHaveCount(3) expect($available)->toHaveCount(3)
@ -264,7 +263,7 @@ describe('available providers list', function () {
Config::set('services.google.ai_api_key', ''); Config::set('services.google.ai_api_key', '');
Config::set('services.openai.api_key', ''); Config::set('services.openai.api_key', '');
$manager = new AgenticManager; $manager = new AgenticManager();
expect($manager->availableProviders())->toBeEmpty(); expect($manager->availableProviders())->toBeEmpty();
}); });
@ -274,7 +273,7 @@ describe('available providers list', function () {
Config::set('services.google.ai_api_key', ''); Config::set('services.google.ai_api_key', '');
Config::set('services.openai.api_key', 'test-key'); Config::set('services.openai.api_key', 'test-key');
$manager = new AgenticManager; $manager = new AgenticManager();
$available = $manager->availableProviders(); $available = $manager->availableProviders();
expect($available)->toHaveCount(2) expect($available)->toHaveCount(2)
@ -284,7 +283,7 @@ describe('available providers list', function () {
it('returns providers implementing AgenticProviderInterface', function () { it('returns providers implementing AgenticProviderInterface', function () {
Config::set('services.anthropic.api_key', 'test-key'); Config::set('services.anthropic.api_key', 'test-key');
$manager = new AgenticManager; $manager = new AgenticManager();
$available = $manager->availableProviders(); $available = $manager->availableProviders();
foreach ($available as $provider) { foreach ($available as $provider) {
@ -305,7 +304,7 @@ describe('direct provider access methods', function () {
}); });
it('returns ClaudeService from claude() method', function () { it('returns ClaudeService from claude() method', function () {
$manager = new AgenticManager; $manager = new AgenticManager();
expect($manager->claude()) expect($manager->claude())
->toBeInstanceOf(ClaudeService::class) ->toBeInstanceOf(ClaudeService::class)
@ -313,7 +312,7 @@ describe('direct provider access methods', function () {
}); });
it('returns GeminiService from gemini() method', function () { it('returns GeminiService from gemini() method', function () {
$manager = new AgenticManager; $manager = new AgenticManager();
expect($manager->gemini()) expect($manager->gemini())
->toBeInstanceOf(GeminiService::class) ->toBeInstanceOf(GeminiService::class)
@ -321,7 +320,7 @@ describe('direct provider access methods', function () {
}); });
it('returns OpenAIService from openai() method', function () { it('returns OpenAIService from openai() method', function () {
$manager = new AgenticManager; $manager = new AgenticManager();
expect($manager->openai()) expect($manager->openai())
->toBeInstanceOf(OpenAIService::class) ->toBeInstanceOf(OpenAIService::class)
@ -329,7 +328,7 @@ describe('direct provider access methods', function () {
}); });
it('returns same instance on repeated calls', function () { it('returns same instance on repeated calls', function () {
$manager = new AgenticManager; $manager = new AgenticManager();
$claude1 = $manager->claude(); $claude1 = $manager->claude();
$claude2 = $manager->claude(); $claude2 = $manager->claude();
@ -344,8 +343,6 @@ describe('direct provider access methods', function () {
describe('edge cases', function () { describe('edge cases', function () {
it('handles missing configuration gracefully', function () { it('handles missing configuration gracefully', function () {
Log::spy();
Config::set('services.anthropic.api_key', null); Config::set('services.anthropic.api_key', null);
Config::set('services.anthropic.model', null); Config::set('services.anthropic.model', null);
Config::set('services.google.ai_api_key', null); Config::set('services.google.ai_api_key', null);
@ -353,7 +350,7 @@ describe('edge cases', function () {
Config::set('services.openai.api_key', null); Config::set('services.openai.api_key', null);
Config::set('services.openai.model', null); Config::set('services.openai.model', null);
$manager = new AgenticManager; $manager = new AgenticManager();
// Should still construct without throwing // Should still construct without throwing
expect($manager->claude())->toBeInstanceOf(ClaudeService::class) expect($manager->claude())->toBeInstanceOf(ClaudeService::class)
@ -362,15 +359,12 @@ describe('edge cases', function () {
// But all should be unavailable // But all should be unavailable
expect($manager->availableProviders())->toBeEmpty(); expect($manager->availableProviders())->toBeEmpty();
// Warnings logged for all three unconfigured providers
Log::shouldHaveReceived('warning')->times(3);
}); });
it('provider retrieval is case-sensitive', function () { it('provider retrieval is case-sensitive', function () {
Config::set('services.anthropic.api_key', 'test-key'); Config::set('services.anthropic.api_key', 'test-key');
$manager = new AgenticManager; $manager = new AgenticManager();
expect(fn () => $manager->provider('Claude')) expect(fn () => $manager->provider('Claude'))
->toThrow(InvalidArgumentException::class); ->toThrow(InvalidArgumentException::class);
@ -379,7 +373,7 @@ describe('edge cases', function () {
it('isAvailable handles case sensitivity', function () { it('isAvailable handles case sensitivity', function () {
Config::set('services.anthropic.api_key', 'test-key'); Config::set('services.anthropic.api_key', 'test-key');
$manager = new AgenticManager; $manager = new AgenticManager();
expect($manager->isAvailable('claude'))->toBeTrue() expect($manager->isAvailable('claude'))->toBeTrue()
->and($manager->isAvailable('Claude'))->toBeFalse() ->and($manager->isAvailable('Claude'))->toBeFalse()
@ -387,101 +381,9 @@ describe('edge cases', function () {
}); });
it('setDefault handles case sensitivity', function () { it('setDefault handles case sensitivity', function () {
$manager = new AgenticManager; $manager = new AgenticManager();
expect(fn () => $manager->setDefault('Gemini')) expect(fn () => $manager->setDefault('Gemini'))
->toThrow(InvalidArgumentException::class); ->toThrow(InvalidArgumentException::class);
}); });
}); });
// =========================================================================
// API Key Validation Warning Tests
// =========================================================================
describe('API key validation warnings', function () {
it('logs a warning when Claude API key is not configured', function () {
Log::spy();
Config::set('services.anthropic.api_key', '');
Config::set('services.google.ai_api_key', 'test-gemini-key');
Config::set('services.openai.api_key', 'test-openai-key');
new AgenticManager;
Log::shouldHaveReceived('warning')
->once()
->withArgs(fn (string $message) => str_contains($message, 'claude') && str_contains($message, 'ANTHROPIC_API_KEY'));
});
it('logs a warning when Gemini API key is not configured', function () {
Log::spy();
Config::set('services.anthropic.api_key', 'test-claude-key');
Config::set('services.google.ai_api_key', '');
Config::set('services.openai.api_key', 'test-openai-key');
new AgenticManager;
Log::shouldHaveReceived('warning')
->once()
->withArgs(fn (string $message) => str_contains($message, 'gemini') && str_contains($message, 'GOOGLE_AI_API_KEY'));
});
it('logs a warning when OpenAI API key is not configured', function () {
Log::spy();
Config::set('services.anthropic.api_key', 'test-claude-key');
Config::set('services.google.ai_api_key', 'test-gemini-key');
Config::set('services.openai.api_key', '');
new AgenticManager;
Log::shouldHaveReceived('warning')
->once()
->withArgs(fn (string $message) => str_contains($message, 'openai') && str_contains($message, 'OPENAI_API_KEY'));
});
it('logs a warning when API key is null', function () {
Log::spy();
Config::set('services.anthropic.api_key', null);
Config::set('services.google.ai_api_key', 'test-gemini-key');
Config::set('services.openai.api_key', 'test-openai-key');
new AgenticManager;
Log::shouldHaveReceived('warning')
->once()
->withArgs(fn (string $message) => str_contains($message, 'claude'));
});
it('logs warnings for all three providers when no keys are configured', function () {
Log::spy();
Config::set('services.anthropic.api_key', '');
Config::set('services.google.ai_api_key', '');
Config::set('services.openai.api_key', '');
new AgenticManager;
Log::shouldHaveReceived('warning')->times(3);
});
it('does not log warnings when all API keys are configured', function () {
Log::spy();
Config::set('services.anthropic.api_key', 'test-claude-key');
Config::set('services.google.ai_api_key', 'test-gemini-key');
Config::set('services.openai.api_key', 'test-openai-key');
new AgenticManager;
Log::shouldNotHaveReceived('warning');
});
it('only warns for providers that have missing keys, not all providers', function () {
Log::spy();
Config::set('services.anthropic.api_key', 'test-key');
Config::set('services.google.ai_api_key', '');
Config::set('services.openai.api_key', '');
new AgenticManager;
// Only gemini and openai should warn not claude
Log::shouldHaveReceived('warning')->times(2);
});
});

View file

@ -11,9 +11,7 @@ declare(strict_types=1);
use Core\Mod\Agentic\Services\AgenticResponse; use Core\Mod\Agentic\Services\AgenticResponse;
use Core\Mod\Agentic\Services\ClaudeService; use Core\Mod\Agentic\Services\ClaudeService;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use RuntimeException; use RuntimeException;
const CLAUDE_API_URL = 'https://api.anthropic.com/v1/messages'; const CLAUDE_API_URL = 'https://api.anthropic.com/v1/messages';
@ -347,78 +345,3 @@ describe('error handling', function () {
->toThrow(RuntimeException::class); ->toThrow(RuntimeException::class);
}); });
}); });
// =========================================================================
// Stream Error Handling Tests
// =========================================================================
describe('stream error handling', function () {
it('yields error event when connection fails', function () {
Http::fake(function () {
throw new ConnectionException('Connection refused');
});
$service = new ClaudeService('test-api-key');
$results = iterator_to_array($service->stream('System', 'User'));
expect($results)->toHaveCount(1)
->and($results[0])->toBeArray()
->and($results[0]['type'])->toBe('error')
->and($results[0]['message'])->toContain('Connection refused');
});
it('yields error event when request throws a runtime exception', function () {
Http::fake(function () {
throw new RuntimeException('Unexpected failure');
});
$service = new ClaudeService('test-api-key');
$results = iterator_to_array($service->stream('System', 'User'));
expect($results)->toHaveCount(1)
->and($results[0]['type'])->toBe('error')
->and($results[0]['message'])->toBe('Unexpected failure');
});
it('error event contains type and message keys', function () {
Http::fake(function () {
throw new RuntimeException('Stream broke');
});
$service = new ClaudeService('test-api-key');
$event = iterator_to_array($service->stream('System', 'User'))[0];
expect($event)->toHaveKeys(['type', 'message'])
->and($event['type'])->toBe('error');
});
it('logs stream errors', function () {
Log::spy();
Http::fake(function () {
throw new RuntimeException('Logging test error');
});
$service = new ClaudeService('test-api-key');
iterator_to_array($service->stream('System', 'User'));
Log::shouldHaveReceived('error')
->with('Claude stream error', \Mockery::on(fn ($ctx) => str_contains($ctx['message'], 'Logging test error')))
->once();
});
it('yields text chunks normally when no error occurs', function () {
$stream = "data: {\"type\": \"content_block_delta\", \"delta\": {\"text\": \"Hello\"}}\n\n";
$stream .= "data: {\"type\": \"content_block_delta\", \"delta\": {\"text\": \" world\"}}\n\n";
$stream .= "data: [DONE]\n\n";
Http::fake([
CLAUDE_API_URL => Http::response($stream, 200, ['Content-Type' => 'text/event-stream']),
]);
$service = new ClaudeService('test-api-key');
$results = iterator_to_array($service->stream('System', 'User'));
expect($results)->toBe(['Hello', ' world']);
});
});

Some files were not shown because too many files have changed in this diff Show more