Compare commits

..

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

129 changed files with 664 additions and 9389 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

@ -8,7 +8,6 @@ use Core\Events\AdminPanelBooting;
use Core\Events\ConsoleBooting; use Core\Events\ConsoleBooting;
use Core\Events\McpToolsRegistering; use Core\Events\McpToolsRegistering;
use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
@ -33,18 +32,6 @@ class Boot extends ServiceProvider
$this->loadMigrationsFrom(__DIR__.'/Migrations'); $this->loadMigrationsFrom(__DIR__.'/Migrations');
$this->loadTranslationsFrom(__DIR__.'/Lang', 'agentic'); $this->loadTranslationsFrom(__DIR__.'/Lang', 'agentic');
$this->configureRateLimiting(); $this->configureRateLimiting();
$this->scheduleRetentionCleanup();
}
/**
* Register the daily retention cleanup schedule.
*/
protected function scheduleRetentionCleanup(): void
{
$this->app->booted(function (): void {
$schedule = $this->app->make(Schedule::class);
$schedule->command('agentic:plan-cleanup')->daily();
});
} }
/** /**
@ -66,11 +53,6 @@ class Boot extends ServiceProvider
'mcp' 'mcp'
); );
$this->mergeConfigFrom(
__DIR__.'/agentic.php',
'agentic'
);
$this->app->singleton(\Core\Mod\Agentic\Services\AgenticManager::class); $this->app->singleton(\Core\Mod\Agentic\Services\AgenticManager::class);
} }
@ -113,7 +95,6 @@ class Boot extends ServiceProvider
$event->command(Console\Commands\TaskCommand::class); $event->command(Console\Commands\TaskCommand::class);
$event->command(Console\Commands\PlanCommand::class); $event->command(Console\Commands\PlanCommand::class);
$event->command(Console\Commands\GenerateCommand::class); $event->command(Console\Commands\GenerateCommand::class);
$event->command(Console\Commands\PlanRetentionCommand::class);
} }
/** /**

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

@ -1,61 +0,0 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Console\Commands;
use Core\Mod\Agentic\Models\AgentPlan;
use Illuminate\Console\Command;
class PlanRetentionCommand extends Command
{
protected $signature = 'agentic:plan-cleanup
{--dry-run : Preview deletions without making changes}
{--days= : Override retention period (overrides agentic.plan_retention_days config)}';
protected $description = 'Permanently delete archived plans past the retention period';
public function handle(): int
{
$days = (int) ($this->option('days') ?? config('agentic.plan_retention_days', 90));
if ($days <= 0) {
$this->info('Retention cleanup is disabled (plan_retention_days is 0).');
return self::SUCCESS;
}
$cutoff = now()->subDays($days);
$query = AgentPlan::where('status', AgentPlan::STATUS_ARCHIVED)
->whereNotNull('archived_at')
->where('archived_at', '<', $cutoff);
$count = $query->count();
if ($count === 0) {
$this->info('No archived plans found past the retention period.');
return self::SUCCESS;
}
if ($this->option('dry-run')) {
$this->info("DRY RUN: {$count} archived plan(s) would be permanently deleted (archived before {$cutoff->toDateString()}).");
return self::SUCCESS;
}
$deleted = 0;
$query->chunkById(100, function ($plans) use (&$deleted): void {
foreach ($plans as $plan) {
$plan->forceDelete();
$deleted++;
}
});
$this->info("Permanently deleted {$deleted} archived plan(s) archived before {$cutoff->toDateString()}.");
return self::SUCCESS;
}
}

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

@ -54,7 +54,7 @@ class Marketing extends Server
#### Other Bio Tools #### Other Bio Tools
- `qr_tools` - Generate QR codes - `qr_tools` - Generate QR codes
- `pixel_tools` - Manage tracking pixels - `pixel_tools` - Manage tracking pixels
- `project_tools` - Organise into projects - `project_tools` - Organize into projects
- `notification_tools` - Manage notification handlers - `notification_tools` - Manage notification handlers
- `submission_tools` - Manage form submissions - `submission_tools` - Manage form submissions
- `pwa_tools` - Configure PWA - `pwa_tools` - Configure PWA

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

@ -1,279 +0,0 @@
# MCP Agent Tools
This directory contains MCP (Model Context Protocol) tool implementations for the agent orchestration system. All tools extend `AgentTool` and integrate with the `ToolDependency` system to declare and validate their execution prerequisites.
## Directory Structure
```
Mcp/Tools/Agent/
├── AgentTool.php # Base class — extend this for all new tools
├── Contracts/
│ └── AgentToolInterface.php # Tool contract
├── Content/ # Content generation tools
├── Phase/ # Plan phase management tools
├── Plan/ # Work plan CRUD tools
├── Session/ # Agent session lifecycle tools
├── State/ # Shared workspace state tools
├── Task/ # Task status and tracking tools
└── Template/ # Template listing and application tools
```
## ToolDependency System
`ToolDependency` (from `Core\Mcp\Dependencies\ToolDependency`) lets a tool declare what must be true in the execution context before it runs. The `AgentToolRegistry` validates these automatically — the tool's `handle()` method is never called if a dependency is unmet.
### How It Works
1. A tool declares its dependencies in a `dependencies()` method returning `ToolDependency[]`.
2. When the tool is registered, `AgentToolRegistry::register()` passes those dependencies to `ToolDependencyService`.
3. On each call, `AgentToolRegistry::execute()` calls `ToolDependencyService::validateDependencies()` before invoking `handle()`.
4. If any required dependency fails, a `MissingDependencyException` is thrown and the tool is never called.
5. After a successful call, `ToolDependencyService::recordToolCall()` logs the execution for audit purposes.
### Dependency Types
#### `contextExists` — Require a context field
Validates that a key is present in the `$context` array passed at execution time. Use this for multi-tenant isolation fields like `workspace_id` that come from API key authentication.
```php
ToolDependency::contextExists('workspace_id', 'Workspace context required')
```
Mark a dependency optional with `->asOptional()` when the tool can work without it (e.g. the value can be inferred from another argument):
```php
// SessionStart: workspace can be inferred from the plan if plan_slug is provided
ToolDependency::contextExists('workspace_id', 'Workspace context required (or provide plan_slug)')
->asOptional()
```
#### `sessionState` — Require an active session
Validates that a session is active. Use this for tools that must run within an established session context.
```php
ToolDependency::sessionState('session_id', 'Active session required. Call session_start first.')
```
#### `entityExists` — Require a database entity
Validates that an entity exists in the database before the tool runs. The `arg_key` maps to the tool argument that holds the entity identifier.
```php
ToolDependency::entityExists('plan', 'Plan must exist', ['arg_key' => 'plan_slug'])
```
## Context Requirements
The `$context` array is injected into every tool's `handle(array $args, array $context)` call. Context is set by API key authentication middleware — tools should never hardcode or fall back to default values.
| Key | Type | Set by | Used by |
|-----|------|--------|---------|
| `workspace_id` | `string\|int` | API key auth middleware | All workspace-scoped tools |
| `session_id` | `string` | Client (from `session_start` response) | Session-dependent tools |
**Multi-tenant safety:** Always validate `workspace_id` in `handle()` as a defence-in-depth measure, even when a `contextExists` dependency is declared. Use `forWorkspace($workspaceId)` scopes on all queries.
```php
$workspaceId = $context['workspace_id'] ?? null;
if ($workspaceId === null) {
return $this->error('workspace_id is required. Ensure you have authenticated with a valid API key. See: https://host.uk.com/ai');
}
$plan = AgentPlan::forWorkspace($workspaceId)->where('slug', $slug)->first();
```
## Creating a New Tool
### 1. Create the class
Place the file in the appropriate subdirectory and extend `AgentTool`:
```php
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Plan;
use Core\Mcp\Dependencies\ToolDependency;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
class PlanPublish extends AgentTool
{
protected string $category = 'plan';
protected array $scopes = ['write']; // 'read' or 'write'
public function dependencies(): array
{
return [
ToolDependency::contextExists('workspace_id', 'Workspace context required'),
];
}
public function name(): string
{
return 'plan_publish'; // snake_case; must be unique across all tools
}
public function description(): string
{
return 'Publish a draft plan, making it active';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => [
'plan_slug' => [
'type' => 'string',
'description' => 'Plan slug identifier',
],
],
'required' => ['plan_slug'],
];
}
public function handle(array $args, array $context = []): array
{
try {
$planSlug = $this->requireString($args, 'plan_slug', 255);
} catch (\InvalidArgumentException $e) {
return $this->error($e->getMessage());
}
$workspaceId = $context['workspace_id'] ?? null;
if ($workspaceId === null) {
return $this->error('workspace_id is required. See: https://host.uk.com/ai');
}
$plan = AgentPlan::forWorkspace($workspaceId)->where('slug', $planSlug)->first();
if (! $plan) {
return $this->error("Plan not found: {$planSlug}");
}
$plan->update(['status' => 'active']);
return $this->success(['plan' => ['slug' => $plan->slug, 'status' => $plan->status]]);
}
}
```
### 2. Register the tool
Add it to the tool registration list in the package boot sequence (see `Boot.php` and the `McpToolsRegistering` event handler).
### 3. Write tests
Add a Pest test file under `Tests/` covering success and failure paths, including missing dependency scenarios.
## AgentTool Base Class Reference
### Properties
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `$category` | `string` | `'general'` | Groups tools in the registry |
| `$scopes` | `string[]` | `['read']` | API key scopes required to call this tool |
| `$timeout` | `?int` | `null` | Per-tool timeout override in seconds (null uses config default of 30s) |
### Argument Helpers
All helpers throw `\InvalidArgumentException` on failure. Catch it in `handle()` and return `$this->error()`.
| Method | Description |
|--------|-------------|
| `requireString($args, $key, $maxLength, $label)` | Required string with optional max length |
| `requireInt($args, $key, $min, $max, $label)` | Required integer with optional bounds |
| `requireArray($args, $key, $label)` | Required array |
| `requireEnum($args, $key, $allowed, $label)` | Required string constrained to allowed values |
| `optionalString($args, $key, $default, $maxLength)` | Optional string |
| `optionalInt($args, $key, $default, $min, $max)` | Optional integer |
| `optionalEnum($args, $key, $allowed, $default)` | Optional enum string |
| `optional($args, $key, $default)` | Optional value of any type |
### Response Helpers
```php
return $this->success(['key' => 'value']); // merges ['success' => true]
return $this->error('Something went wrong');
return $this->error('Resource locked', 'resource_locked'); // with error code
```
### Circuit Breaker
Wrap calls to external services with `withCircuitBreaker()` for fault tolerance:
```php
return $this->withCircuitBreaker(
'agentic', // service name
fn () => $this->doWork(), // operation
fn () => $this->error('Service unavailable', 'service_unavailable') // fallback
);
```
If no fallback is provided and the circuit is open, `error()` is returned automatically.
### Timeout Override
For long-running tools (e.g. content generation), override the timeout:
```php
protected ?int $timeout = 300; // 5 minutes
```
## Dependency Resolution Order
Dependencies are validated in the order they are returned from `dependencies()`. All required dependencies must pass before the tool runs. Optional dependencies are checked but do not block execution.
Recommended declaration order:
1. `contextExists('workspace_id', ...)` — tenant isolation first
2. `sessionState('session_id', ...)` — session presence second
3. `entityExists(...)` — entity existence last (may query DB)
## Troubleshooting
### "Workspace context required"
The `workspace_id` key is missing from the execution context. This is injected by the API key authentication middleware. Causes:
- Request is unauthenticated or the API key is invalid.
- The API key has no workspace association.
- Dependency validation was bypassed but the tool checks it internally.
**Fix:** Authenticate with a valid API key. See https://host.uk.com/ai.
### "Active session required. Call session_start first."
The `session_id` context key is missing. The tool requires an active session.
**Fix:** Call `session_start` before calling session-dependent tools. Pass the returned `session_id` in the context of all subsequent calls.
### "Plan must exist" / "Plan not found"
The `plan_slug` argument does not match any plan. Either the plan was never created, the slug is misspelled, or the plan belongs to a different workspace.
**Fix:** Call `plan_list` to find valid slugs, then retry.
### "Permission denied: API key missing scope"
The API key does not have the required scope (`read` or `write`) for the tool.
**Fix:** Issue a new API key with the correct scopes, or use an existing key that has the required permissions.
### "Unknown tool: {name}"
The tool name does not match any registered tool.
**Fix:** Check `plan_list` / MCP tool discovery endpoint for the exact tool name. Names are snake_case.
### `MissingDependencyException` in logs
A required dependency was not met and the framework threw before calling `handle()`. The exception message will identify which dependency failed.
**Fix:** Inspect the `context` passed to `execute()`. Ensure required keys are present and the relevant entity exists.

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\WorkspaceState; 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
@ -93,7 +93,7 @@ class StateSet extends AgentTool
return $this->error("Plan not found: {$planSlug}"); return $this->error("Plan not found: {$planSlug}");
} }
$state = WorkspaceState::updateOrCreate( $state = AgentWorkspaceState::updateOrCreate(
[ [
'agent_plan_id' => $plan->id, 'agent_plan_id' => $plan->id,
'key' => $key, 'key' => $key,

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

@ -1,33 +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 soft delete support and archived_at timestamp to agent_plans.
*
* - archived_at: dedicated timestamp for when a plan was archived, used by
* the retention cleanup command to determine when to permanently delete.
* - deleted_at: standard Laravel soft-delete column.
*/
public function up(): void
{
Schema::table('agent_plans', function (Blueprint $table) {
$table->timestamp('archived_at')->nullable()->after('source_file');
$table->softDeletes();
});
}
public function down(): void
{
Schema::table('agent_plans', function (Blueprint $table) {
$table->dropColumn('archived_at');
$table->dropSoftDeletes();
});
}
};

View file

@ -1,69 +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 plan_template_versions table and add template_version_id to agent_plans.
*
* Template versions snapshot YAML template content at plan-creation time so
* existing plans are never affected when a template file is updated.
*
* Deduplication: identical content reuses the same version row (same content_hash).
*
* Guarded with hasTable()/hasColumn() so this migration is idempotent and
* can coexist with a consolidated app-level migration.
*/
public function up(): void
{
Schema::disableForeignKeyConstraints();
if (! Schema::hasTable('plan_template_versions')) {
Schema::create('plan_template_versions', function (Blueprint $table) {
$table->id();
$table->string('slug');
$table->unsignedInteger('version');
$table->string('name');
$table->json('content');
$table->char('content_hash', 64);
$table->timestamps();
$table->unique(['slug', 'version']);
$table->index(['slug', 'content_hash']);
});
}
if (Schema::hasTable('agent_plans') && ! Schema::hasColumn('agent_plans', 'template_version_id')) {
Schema::table('agent_plans', function (Blueprint $table) {
$table->foreignId('template_version_id')
->nullable()
->constrained('plan_template_versions')
->nullOnDelete()
->after('source_file');
});
}
Schema::enableForeignKeyConstraints();
}
public function down(): void
{
Schema::disableForeignKeyConstraints();
if (Schema::hasTable('agent_plans') && Schema::hasColumn('agent_plans', 'template_version_id')) {
Schema::table('agent_plans', function (Blueprint $table) {
$table->dropForeign(['template_version_id']);
$table->dropColumn('template_version_id');
});
}
Schema::dropIfExists('plan_template_versions');
Schema::enableForeignKeyConstraints();
}
};

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,16 +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\Database\Eloquent\SoftDeletes;
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;
@ -34,8 +32,6 @@ use Spatie\Activitylog\Traits\LogsActivity;
* @property string|null $current_phase * @property string|null $current_phase
* @property array|null $metadata * @property array|null $metadata
* @property string|null $source_file * @property string|null $source_file
* @property \Carbon\Carbon|null $archived_at
* @property \Carbon\Carbon|null $deleted_at
* @property \Carbon\Carbon|null $created_at * @property \Carbon\Carbon|null $created_at
* @property \Carbon\Carbon|null $updated_at * @property \Carbon\Carbon|null $updated_at
*/ */
@ -47,7 +43,6 @@ class AgentPlan extends Model
use HasFactory; use HasFactory;
use LogsActivity; use LogsActivity;
use SoftDeletes;
protected static function newFactory(): AgentPlanFactory protected static function newFactory(): AgentPlanFactory
{ {
@ -65,15 +60,12 @@ class AgentPlan extends Model
'current_phase', 'current_phase',
'metadata', 'metadata',
'source_file', 'source_file',
'archived_at',
'template_version_id',
]; ];
protected $casts = [ protected $casts = [
'context' => 'array', 'context' => 'array',
'phases' => 'array', 'phases' => 'array',
'metadata' => 'array', 'metadata' => 'array',
'archived_at' => 'datetime',
]; ];
// Status constants // Status constants
@ -103,26 +95,21 @@ class AgentPlan extends Model
public function states(): HasMany public function states(): HasMany
{ {
return $this->hasMany(WorkspaceState::class); return $this->hasMany(AgentWorkspaceState::class);
}
public function templateVersion(): BelongsTo
{
return $this->belongsTo(PlanTemplateVersion::class, 'template_version_id');
} }
// 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);
} }
@ -133,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
@ -141,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
@ -178,11 +165,11 @@ class AgentPlan extends Model
$metadata = $this->metadata ?? []; $metadata = $this->metadata ?? [];
if ($reason) { if ($reason) {
$metadata['archive_reason'] = $reason; $metadata['archive_reason'] = $reason;
$metadata['archived_at'] = now()->toIso8601String();
} }
$this->update([ $this->update([
'status' => self::STATUS_ARCHIVED, 'status' => self::STATUS_ARCHIVED,
'archived_at' => now(),
'metadata' => $metadata, 'metadata' => $metadata,
]); ]);
@ -240,7 +227,7 @@ class AgentPlan extends Model
return $state?->value; return $state?->value;
} }
public function setState(string $key, mixed $value, string $type = 'json', ?string $description = null): WorkspaceState public function setState(string $key, mixed $value, string $type = 'json', ?string $description = null): AgentWorkspaceState
{ {
return $this->states()->updateOrCreate( return $this->states()->updateOrCreate(
['key' => $key], ['key' => $key],

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

@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Agent Workspace State - shared context between sessions within a plan.
*
* Stores key-value data that persists across agent sessions,
* enabling context sharing and state recovery.
*
* @property int $id
* @property int $agent_plan_id
* @property string $key
* @property array $value
* @property string $type
* @property string|null $description
* @property \Carbon\Carbon|null $created_at
* @property \Carbon\Carbon|null $updated_at
*/
class AgentWorkspaceState extends Model
{
protected $table = 'agent_workspace_states';
protected $fillable = [
'agent_plan_id',
'key',
'value',
'type',
'description',
];
protected $casts = [
'value' => 'array',
];
// Type constants
public const TYPE_JSON = 'json';
public const TYPE_MARKDOWN = 'markdown';
public const TYPE_CODE = 'code';
public const TYPE_REFERENCE = 'reference';
// Relationships
public function plan(): BelongsTo
{
return $this->belongsTo(AgentPlan::class, 'agent_plan_id');
}
// Scopes
public function scopeForPlan($query, AgentPlan|int $plan)
{
$planId = $plan instanceof AgentPlan ? $plan->id : $plan;
return $query->where('agent_plan_id', $planId);
}
public function scopeOfType($query, string $type)
{
return $query->where('type', $type);
}
// Helpers
public function isJson(): bool
{
return $this->type === self::TYPE_JSON;
}
public function isMarkdown(): bool
{
return $this->type === self::TYPE_MARKDOWN;
}
public function isCode(): bool
{
return $this->type === self::TYPE_CODE;
}
public function isReference(): bool
{
return $this->type === self::TYPE_REFERENCE;
}
public function getValue(): mixed
{
return $this->value;
}
public function getFormattedValue(): string
{
if ($this->isMarkdown() || $this->isCode()) {
return is_string($this->value) ? $this->value : json_encode($this->value, JSON_PRETTY_PRINT);
}
return json_encode($this->value, JSON_PRETTY_PRINT);
}
// Output
public function toMcpContext(): array
{
return [
'key' => $this->key,
'type' => $this->type,
'description' => $this->description,
'value' => $this->value,
'updated_at' => $this->updated_at?->toIso8601String(),
];
}
}

View file

@ -1,92 +0,0 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Models;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* Plan Template Version - immutable snapshot of a YAML template's content.
*
* When a plan is created from a template, the template content is snapshotted
* here so future edits to the YAML file do not affect existing plans.
*
* Identical content is deduplicated via content_hash so no duplicate rows
* accumulate when the same (unchanged) template is used repeatedly.
*
* @property int $id
* @property string $slug Template file slug (filename without extension)
* @property int $version Sequential version number per slug
* @property string $name Template name at snapshot time
* @property array $content Full template content as JSON
* @property string $content_hash SHA-256 of json_encode($content)
* @property \Carbon\Carbon|null $created_at
* @property \Carbon\Carbon|null $updated_at
*/
class PlanTemplateVersion extends Model
{
protected $fillable = [
'slug',
'version',
'name',
'content',
'content_hash',
];
protected $casts = [
'content' => 'array',
'version' => 'integer',
];
/**
* Plans that were created from this template version.
*/
public function plans(): HasMany
{
return $this->hasMany(AgentPlan::class, 'template_version_id');
}
/**
* Find an existing version by content hash, or create a new one.
*
* Deduplicates identical template content so we don't store redundant rows
* when the same (unchanged) template is used multiple times.
*/
public static function findOrCreateFromTemplate(string $slug, array $content): self
{
$hash = hash('sha256', json_encode($content, JSON_UNESCAPED_UNICODE));
$existing = static::where('slug', $slug)
->where('content_hash', $hash)
->first();
if ($existing) {
return $existing;
}
$nextVersion = (static::where('slug', $slug)->max('version') ?? 0) + 1;
return static::create([
'slug' => $slug,
'version' => $nextVersion,
'name' => $content['name'] ?? $slug,
'content' => $content,
'content_hash' => $hash,
]);
}
/**
* Get all recorded versions for a template slug, newest first.
*
* @return Collection<int, static>
*/
public static function historyFor(string $slug): Collection
{
return static::where('slug', $slug)
->orderByDesc('version')
->get();
}
}

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;
@ -12,25 +11,12 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
/** /**
* Workspace State Model * Workspace State Model
* *
* Persistent key-value state storage for agent plans. * Key-value state storage for agent plans with typed content.
* Stores typed values shared across agent sessions within a plan,
* enabling context sharing and state recovery.
*
* @property int $id
* @property int $agent_plan_id
* @property string $key
* @property array $value
* @property string $type
* @property string|null $description
* @property \Carbon\Carbon|null $created_at
* @property \Carbon\Carbon|null $updated_at
*/ */
class WorkspaceState extends Model class WorkspaceState extends Model
{ {
use HasFactory; use HasFactory;
protected $table = 'agent_workspace_states';
public const TYPE_JSON = 'json'; public const TYPE_JSON = 'json';
public const TYPE_MARKDOWN = 'markdown'; public const TYPE_MARKDOWN = 'markdown';
@ -44,27 +30,34 @@ class WorkspaceState extends Model
'key', 'key',
'value', 'value',
'type', 'type',
'description', 'metadata',
]; ];
protected $casts = [ protected $casts = [
'value' => 'array', 'metadata' => 'array',
]; ];
// Relationships protected $attributes = [
'type' => self::TYPE_JSON,
'metadata' => '{}',
];
public function plan(): BelongsTo public function plan(): BelongsTo
{ {
return $this->belongsTo(AgentPlan::class, 'agent_plan_id'); return $this->belongsTo(AgentPlan::class, 'agent_plan_id');
} }
// Scopes /**
* Get typed value.
public function scopeForPlan($query, AgentPlan|int $plan): mixed */
public function getTypedValue(): mixed
{ {
$planId = $plan instanceof AgentPlan ? $plan->id : $plan; return match ($this->type) {
self::TYPE_JSON => json_decode($this->value, true),
default => $this->value,
};
}
<<<<<<< HEAD
/** /**
* Set typed value. * Set typed value.
*/ */
@ -139,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);
} }
@ -147,75 +140,8 @@ 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);
} }
// Type helpers
public function isJson(): bool
{
return $this->type === self::TYPE_JSON;
}
public function isMarkdown(): bool
{
return $this->type === self::TYPE_MARKDOWN;
}
public function isCode(): bool
{
return $this->type === self::TYPE_CODE;
}
public function isReference(): bool
{
return $this->type === self::TYPE_REFERENCE;
}
public function getFormattedValue(): string
{
if ($this->isMarkdown() || $this->isCode()) {
return is_string($this->value) ? $this->value : json_encode($this->value, JSON_PRETTY_PRINT);
}
return json_encode($this->value, JSON_PRETTY_PRINT);
}
// Static helpers
/**
* Get a state value for a plan, returning $default if not set.
*/
public static function getValue(AgentPlan $plan, string $key, mixed $default = null): mixed
{
$state = static::where('agent_plan_id', $plan->id)->where('key', $key)->first();
return $state !== null ? $state->value : $default;
}
/**
* Set (upsert) a state value for a plan.
*/
public static function setValue(AgentPlan $plan, string $key, mixed $value, string $type = self::TYPE_JSON): self
{
return static::updateOrCreate(
['agent_plan_id' => $plan->id, 'key' => $key],
['value' => $value, 'type' => $type]
);
}
// MCP output
public function toMcpContext(): array
{
return [
'key' => $this->key,
'type' => $this->type,
'description' => $this->description,
'value' => $this->value,
'updated_at' => $this->updated_at?->toIso8601String(),
];
}
} }

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
{ {

View file

@ -6,7 +6,6 @@ namespace Core\Mod\Agentic\Services;
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\Models\PlanTemplateVersion;
use Core\Tenant\Models\Workspace; use Core\Tenant\Models\Workspace;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\File;
@ -147,10 +146,6 @@ class PlanTemplateService
return null; return null;
} }
// Snapshot the raw template content before variable substitution so the
// version record captures the canonical template, not the instantiated copy.
$templateVersion = PlanTemplateVersion::findOrCreateFromTemplate($templateSlug, $template);
// Replace variables in template // Replace variables in template
$template = $this->substituteVariables($template, $variables); $template = $this->substituteVariables($template, $variables);
@ -169,12 +164,10 @@ class PlanTemplateService
'description' => $template['description'] ?? null, 'description' => $template['description'] ?? null,
'context' => $context, 'context' => $context,
'status' => ($options['activate'] ?? false) ? AgentPlan::STATUS_ACTIVE : AgentPlan::STATUS_DRAFT, 'status' => ($options['activate'] ?? false) ? AgentPlan::STATUS_ACTIVE : AgentPlan::STATUS_DRAFT,
'template_version_id' => $templateVersion->id,
'metadata' => array_merge($template['metadata'] ?? [], [ 'metadata' => array_merge($template['metadata'] ?? [], [
'source' => 'template', 'source' => 'template',
'template_slug' => $templateSlug, 'template_slug' => $templateSlug,
'template_name' => $template['name'], 'template_name' => $template['name'],
'template_version' => $templateVersion->version,
'variables' => $variables, 'variables' => $variables,
'created_at' => now()->toIso8601String(), 'created_at' => now()->toIso8601String(),
]), ]),
@ -336,18 +329,13 @@ class PlanTemplateService
/** /**
* Validate variables against template requirements. * Validate variables against template requirements.
*
* Returns a result array with:
* - valid: bool
* - errors: string[] actionable messages including description and examples
* - naming_convention: string reminder that variable names use snake_case
*/ */
public function validateVariables(string $templateSlug, array $variables): array public function validateVariables(string $templateSlug, array $variables): array
{ {
$template = $this->get($templateSlug); $template = $this->get($templateSlug);
if (! $template) { if (! $template) {
return ['valid' => false, 'errors' => ['Template not found'], 'naming_convention' => self::NAMING_CONVENTION]; return ['valid' => false, 'errors' => ['Template not found']];
} }
$errors = []; $errors = [];
@ -356,94 +344,16 @@ class PlanTemplateService
$required = $varDef['required'] ?? true; $required = $varDef['required'] ?? true;
if ($required && ! isset($variables[$name]) && ! isset($varDef['default'])) { if ($required && ! isset($variables[$name]) && ! isset($varDef['default'])) {
$errors[] = $this->buildVariableError($name, $varDef); $errors[] = "Required variable '{$name}' is missing";
} }
} }
return [ return [
'valid' => empty($errors), 'valid' => empty($errors),
'errors' => $errors, 'errors' => $errors,
'naming_convention' => self::NAMING_CONVENTION,
]; ];
} }
/**
<<<<<<< HEAD
* Naming convention reminder included in validation results.
*/
private const NAMING_CONVENTION = 'Variable names use snake_case (e.g. project_name, api_key)';
/**
* Build an actionable error message for a missing required variable.
*
* Incorporates the variable's description, example values, and expected
* format so the caller knows exactly what to provide.
*/
private function buildVariableError(string $name, array $varDef): string
{
$message = "Required variable '{$name}' is missing";
if (! empty($varDef['description'])) {
$message .= ": {$varDef['description']}";
}
$hints = [];
if (! empty($varDef['format'])) {
$hints[] = "expected format: {$varDef['format']}";
}
if (! empty($varDef['example'])) {
$hints[] = "example: '{$varDef['example']}'";
} elseif (! empty($varDef['examples'])) {
$exampleValues = is_array($varDef['examples'])
? array_slice($varDef['examples'], 0, 2)
: [$varDef['examples']];
$hints[] = "examples: '".implode("', '", $exampleValues)."'";
}
if (! empty($hints)) {
$message .= ' ('.implode('; ', $hints).')';
}
return $message;
}
/**
* Get the version history for a template slug, newest first.
*
* Returns an array of version summaries (without full content) for display.
*
* @return array<int, array{id: int, slug: string, version: int, name: string, content_hash: string, created_at: string}>
*/
public function getVersionHistory(string $slug): array
{
return PlanTemplateVersion::historyFor($slug)
->map(fn (PlanTemplateVersion $v) => [
'id' => $v->id,
'slug' => $v->slug,
'version' => $v->version,
'name' => $v->name,
'content_hash' => $v->content_hash,
'created_at' => $v->created_at?->toIso8601String(),
])
->toArray();
}
/**
* Get a specific stored version of a template by slug and version number.
*
* Returns the snapshotted content array, or null if not found.
*/
public function getVersion(string $slug, int $version): ?array
{
$record = PlanTemplateVersion::where('slug', $slug)
->where('version', $version)
->first();
return $record?->content;
}
/** /**
* Get templates by category. * Get templates by category.
*/ */

64
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,43 +115,36 @@ 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
- [x] **DX-003: Plan template variable errors not actionable** (FIXED 2026-02-23) - [ ] **DX-003: Plan template variable errors not actionable**
- Location: `Services/PlanTemplateService.php::validateVariables()` - Location: `Services/PlanTemplateService.php::validateVariables()`
- Fix: Error messages now include variable description, example/examples, and expected format - Fix: Include expected format, examples in error messages
- Added `naming_convention` field to result; extracted `buildVariableError()` helper
- New tests: description in error, example value, multiple examples, format hint, naming_convention field
### Code Quality ### Code Quality
- [x] **CQ-001: Duplicate state models (WorkspaceState vs AgentWorkspaceState)** (FIXED 2026-02-23) - [ ] **CQ-001: Duplicate state models (WorkspaceState vs AgentWorkspaceState)**
- Deleted `Models/AgentWorkspaceState.php` (unused legacy port) - Files: `Models/WorkspaceState.php`, `Models/AgentWorkspaceState.php`
- Consolidated into `Models/WorkspaceState.php` backed by `agent_workspace_states` table - Issue: Two similar models for same purpose
- Updated `AgentPlan`, `StateSet`, `SecurityTest` to use `WorkspaceState` - Fix: Consolidate into single model, or clarify distinct purposes
- Added `WorkspaceStateTest` covering model behaviour and static helpers
- [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
@ -172,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
@ -192,17 +184,16 @@ Production-quality task list for the AI agent orchestration package.
- Issue: Archived plans kept forever - Issue: Archived plans kept forever
- Fix: Add configurable retention period, cleanup job - Fix: Add configurable retention period, cleanup job
- [x] **FEAT-003: Template version management** - [ ] **FEAT-003: Template version management**
- Location: `Services/PlanTemplateService.php`, `Models/PlanTemplateVersion.php` - Location: `Services/PlanTemplateService.php`
- Issue: Template changes affect existing plan references - Issue: Template changes affect existing plan references
- Fix: Add version tracking to templates — implemented in #35 - Fix: Add version tracking to templates
### Consistency ### Consistency
- [x] **CON-001: Mixed UK/US spelling in code comments** (FIXED 2026-02-23) - [ ] **CON-001: Mixed UK/US spelling in code comments**
- Issue: Some comments use "organize" instead of "organise" - Issue: Some comments use "organize" instead of "organise"
- Fix: Audit and fix to UK English per CLAUDE.md - Fix: Audit and fix to UK English per CLAUDE.md
- Changed: `Mcp/Servers/Marketing.php` "Organize" → "Organise" in docstring
- [ ] **CON-002: Inconsistent error response format** - [ ] **CON-002: Inconsistent error response format**
- Issue: Some tools return `['error' => ...]`, others `['success' => false, ...]` - Issue: Some tools return `['error' => ...]`, others `['success' => false, ...]`
@ -292,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,21 +0,0 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Plan Retention Policy
|--------------------------------------------------------------------------
|
| Archived plans are permanently deleted after this many days. This frees
| up storage and keeps the database lean over time.
|
| Set to 0 or null to disable automatic cleanup entirely.
|
| Default: 90 days
|
*/
'plan_retention_days' => env('AGENTIC_PLAN_RETENTION_DAYS', 90),
];

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
@ -76,7 +76,7 @@ class AgentPlanTest extends TestCase
$fresh = $plan->fresh(); $fresh = $plan->fresh();
$this->assertEquals(AgentPlan::STATUS_ARCHIVED, $fresh->status); $this->assertEquals(AgentPlan::STATUS_ARCHIVED, $fresh->status);
$this->assertEquals('No longer needed', $fresh->metadata['archive_reason']); $this->assertEquals('No longer needed', $fresh->metadata['archive_reason']);
$this->assertNotNull($fresh->archived_at); $this->assertNotNull($fresh->metadata['archived_at']);
} }
public function test_it_generates_unique_slugs(): void public function test_it_generates_unique_slugs(): void

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

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