Compare commits
72 commits
feat/phase
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1bbc1336b7 | |||
|
|
7fadbcb96c | ||
| 80c778cb08 | |||
| 0e7b617551 | |||
| ffc441f22a | |||
| a9a6e258e1 | |||
| d26250fc12 | |||
| 6a1709fca9 | |||
|
|
7d6081bdd7 | ||
| cc1c4c1adc | |||
| b0e2be2633 | |||
| a5da40a202 | |||
|
|
be820fead8 | ||
|
|
5f016c6275 | ||
|
|
ae4188c063 | ||
|
|
eb6bc27a4e | ||
| b86714db6e | |||
|
|
6cd9ca09d7 | ||
|
|
e47998bc15 | ||
|
|
938081f2f5 | ||
|
|
91ee71b8a1 | ||
| 5fa46104f4 | |||
| 6b7a7ade15 | |||
| 968cbcdd63 | |||
| f528f94d68 | |||
| 8ade82587d | |||
|
|
c315fc43c6 | ||
| ff34ede167 | |||
|
|
6748e6cd84 | ||
|
|
78bdebcaaa | ||
|
|
77e4ae6bad | ||
|
|
a352f697a9 | ||
|
|
909c2da6df | ||
|
|
fcdeace290 | ||
|
|
d88095780e | ||
|
|
f2f27ec766 | ||
| db0cc0abad | |||
|
|
764728759d | ||
|
|
2f3314a418 | ||
|
|
769c888d74 | ||
| 00a7e2f4ef | |||
|
|
e0f9a87673 | ||
|
|
7fba0955e4 | ||
|
|
c6e52bd74c | ||
| b75b1b8191 | |||
| 48547dc214 | |||
| 411d7decac | |||
|
|
6ebd527204 | ||
| c4af06bc02 | |||
| f97d862f27 | |||
| 075aa05ee4 | |||
| 143aee7d42 | |||
| 3c65e99727 | |||
|
|
d58222cf81 | ||
|
|
ae2fdc39dc | ||
|
|
004fee2100 | ||
|
|
003c16c1cd | ||
|
|
2bc17efa47 | ||
|
|
9e142f79af | ||
|
|
52b4ee42d2 | ||
|
|
478e0b8009 | ||
|
|
1abc4af519 | ||
|
|
5fc54516bf | ||
|
|
d1d28d7bd6 | ||
|
|
3f905583a8 | ||
|
|
964d6cdeb3 | ||
|
|
9c50d29c19 | ||
|
|
1226ec0db0 | ||
|
|
33829927e1 | ||
|
|
2ba1751081 | ||
|
|
26b0f19f4c | ||
| 10b6260c4c |
129 changed files with 9394 additions and 669 deletions
63
.forgejo/workflows/ci.yml
Normal file
63
.forgejo/workflows/ci.yml
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
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
|
||||||
19
Boot.php
19
Boot.php
|
|
@ -8,6 +8,7 @@ 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;
|
||||||
|
|
@ -32,6 +33,18 @@ 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();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -53,6 +66,11 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -95,6 +113,7 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,8 @@ 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 Illuminate\Console\Command;
|
||||||
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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
61
Console/Commands/PlanRetentionCommand.php
Normal file
61
Console/Commands/PlanRetentionCommand.php
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,8 +4,8 @@ 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\Task;
|
use Core\Mod\Agentic\Models\Task;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
class TaskCommand extends Command
|
class TaskCommand extends Command
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -18,13 +18,22 @@ class ForAgentsController extends Controller
|
||||||
{
|
{
|
||||||
public function __invoke(): JsonResponse
|
public function __invoke(): JsonResponse
|
||||||
{
|
{
|
||||||
// Cache for 1 hour since this is static data
|
$ttl = (int) config('mcp.cache.for_agents_ttl', 3600);
|
||||||
$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=3600');
|
->header('Cache-Control', "public, max-age={$ttl}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -5,14 +5,13 @@ 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
|
||||||
|
|
@ -33,7 +32,6 @@ 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();
|
||||||
|
|
@ -103,11 +101,6 @@ 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
|
||||||
|
|
@ -115,35 +108,18 @@ 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("{{{$key}}}", $value, $template);
|
$template = str_replace($placeholder, $value, $template);
|
||||||
} elseif (is_array($value)) {
|
} elseif (is_array($value)) {
|
||||||
$template = str_replace("{{{$key}}}", json_encode($value), $template);
|
$template = str_replace($placeholder, 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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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` - Organize into projects
|
- `project_tools` - Organise 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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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 Illuminate\Support\Str;
|
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
|
||||||
use Core\Mod\Agentic\Models\AgentPlan;
|
use Core\Mod\Agentic\Models\AgentPlan;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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 Illuminate\Support\Str;
|
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
|
||||||
use Core\Mod\Agentic\Models\AgentPlan;
|
use Core\Mod\Agentic\Models\AgentPlan;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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 Mod\Content\Models\AIUsage;
|
|
||||||
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
|
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
|
||||||
|
use Mod\Content\Models\AIUsage;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get AI usage statistics for content generation.
|
* Get AI usage statistics for content generation.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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\Models\AgentPlan;
|
|
||||||
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
|
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
|
||||||
|
use Core\Mod\Agentic\Models\AgentPlan;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Archive a completed or abandoned plan.
|
* Archive a completed or abandoned plan.
|
||||||
|
|
|
||||||
|
|
@ -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 but could not be determined from context');
|
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');
|
||||||
}
|
}
|
||||||
|
|
||||||
$plan = AgentPlan::create([
|
$plan = AgentPlan::create([
|
||||||
|
|
|
||||||
|
|
@ -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\Models\AgentPlan;
|
|
||||||
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
|
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
|
||||||
|
use Core\Mod\Agentic\Models\AgentPlan;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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 for plan operations');
|
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');
|
||||||
}
|
}
|
||||||
|
|
||||||
$format = $this->optional($args, 'format', 'json');
|
$format = $this->optional($args, 'format', 'json');
|
||||||
|
|
|
||||||
|
|
@ -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\Models\AgentPlan;
|
|
||||||
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
|
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
|
||||||
|
use Core\Mod\Agentic\Models\AgentPlan;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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 for plan operations');
|
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');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Query plans with workspace scope to prevent cross-tenant access
|
// Query plans with workspace scope to prevent cross-tenant access
|
||||||
|
|
|
||||||
|
|
@ -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\Models\AgentPlan;
|
|
||||||
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
|
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
|
||||||
|
use Core\Mod\Agentic\Models\AgentPlan;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update the status of a plan.
|
* Update the status of a plan.
|
||||||
|
|
|
||||||
279
Mcp/Tools/Agent/README.md
Normal file
279
Mcp/Tools/Agent/README.md
Normal file
|
|
@ -0,0 +1,279 @@
|
||||||
|
# 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.
|
||||||
|
|
@ -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\Models\AgentSession;
|
|
||||||
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
|
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
|
||||||
|
use Core\Mod\Agentic\Models\AgentSession;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Record an artifact created/modified during the session.
|
* Record an artifact created/modified during the session.
|
||||||
|
|
|
||||||
|
|
@ -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\Services\AgentSessionService;
|
|
||||||
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
|
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
|
||||||
|
use Core\Mod\Agentic\Services\AgentSessionService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Continue from a previous session (multi-agent handoff).
|
* Continue from a previous session (multi-agent handoff).
|
||||||
|
|
|
||||||
|
|
@ -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\Models\AgentSession;
|
|
||||||
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
|
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
|
||||||
|
use Core\Mod\Agentic\Models\AgentSession;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* End the current session.
|
* End the current session.
|
||||||
|
|
|
||||||
|
|
@ -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\Models\AgentSession;
|
|
||||||
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
|
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
|
||||||
|
use Core\Mod\Agentic\Models\AgentSession;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prepare session for handoff to another agent.
|
* Prepare session for handoff to another agent.
|
||||||
|
|
|
||||||
|
|
@ -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\Services\AgentSessionService;
|
|
||||||
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
|
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
|
||||||
|
use Core\Mod\Agentic\Services\AgentSessionService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List sessions, optionally filtered by status.
|
* List sessions, optionally filtered by status.
|
||||||
|
|
|
||||||
|
|
@ -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\Services\AgentSessionService;
|
|
||||||
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
|
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
|
||||||
|
use Core\Mod\Agentic\Services\AgentSessionService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resume a paused or handed-off session.
|
* Resume a paused or handed-off session.
|
||||||
|
|
|
||||||
|
|
@ -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 but could not be determined from context or plan');
|
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');
|
||||||
}
|
}
|
||||||
|
|
||||||
$session = AgentSession::create([
|
$session = AgentSession::create([
|
||||||
|
|
|
||||||
|
|
@ -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\Models\AgentPlan;
|
|
||||||
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
|
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
|
||||||
|
use Core\Mod\Agentic\Models\AgentPlan;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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 for state operations');
|
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');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Query plan with workspace scope to prevent cross-tenant access
|
// Query plan with workspace scope to prevent cross-tenant access
|
||||||
|
|
|
||||||
|
|
@ -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\Models\AgentPlan;
|
|
||||||
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
|
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
|
||||||
|
use Core\Mod\Agentic\Models\AgentPlan;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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 for state operations');
|
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');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Query plan with workspace scope to prevent cross-tenant access
|
// Query plan with workspace scope to prevent cross-tenant access
|
||||||
|
|
|
||||||
|
|
@ -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\Models\AgentPlan;
|
|
||||||
use Core\Mod\Agentic\Models\AgentWorkspaceState;
|
|
||||||
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\WorkspaceState;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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 for state operations');
|
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');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 = AgentWorkspaceState::updateOrCreate(
|
$state = WorkspaceState::updateOrCreate(
|
||||||
[
|
[
|
||||||
'agent_plan_id' => $plan->id,
|
'agent_plan_id' => $plan->id,
|
||||||
'key' => $key,
|
'key' => $key,
|
||||||
|
|
|
||||||
|
|
@ -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\Services\PlanTemplateService;
|
|
||||||
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
|
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
|
||||||
|
use Core\Mod\Agentic\Services\PlanTemplateService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new plan from a template.
|
* Create a new plan from a template.
|
||||||
|
|
|
||||||
|
|
@ -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\Services\PlanTemplateService;
|
|
||||||
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
|
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
|
||||||
|
use Core\Mod\Agentic\Services\PlanTemplateService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List available plan templates.
|
* List available plan templates.
|
||||||
|
|
|
||||||
|
|
@ -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\Services\PlanTemplateService;
|
|
||||||
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
|
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
|
||||||
|
use Core\Mod\Agentic\Services\PlanTemplateService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Preview a template with variables.
|
* Preview a template with variables.
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
65
Migrations/0001_01_01_000004_create_prompt_tables.php
Normal file
65
Migrations/0001_01_01_000004_create_prompt_tables.php
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
<?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();
|
||||||
|
}
|
||||||
|
};
|
||||||
51
Migrations/0001_01_01_000005_add_performance_indexes.php
Normal file
51
Migrations/0001_01_01_000005_add_performance_indexes.php
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
<?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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
<?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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
69
Migrations/0001_01_01_000007_add_template_versions.php
Normal file
69
Migrations/0001_01_01_000007_add_template_versions.php
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
<?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();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -5,6 +5,7 @@ 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;
|
||||||
|
|
@ -120,7 +121,7 @@ class AgentApiKey extends Model
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scopes
|
// Scopes
|
||||||
public function scopeActive($query)
|
public function scopeActive(Builder $query): Builder
|
||||||
{
|
{
|
||||||
return $query->whereNull('revoked_at')
|
return $query->whereNull('revoked_at')
|
||||||
->where(function ($q) {
|
->where(function ($q) {
|
||||||
|
|
@ -136,12 +137,12 @@ class AgentApiKey extends Model
|
||||||
return $query->where('workspace_id', $workspaceId);
|
return $query->where('workspace_id', $workspaceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function scopeRevoked($query)
|
public function scopeRevoked(Builder $query): Builder
|
||||||
{
|
{
|
||||||
return $query->whereNotNull('revoked_at');
|
return $query->whereNotNull('revoked_at');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function scopeExpired($query)
|
public function scopeExpired(Builder $query): Builder
|
||||||
{
|
{
|
||||||
return $query->whereNotNull('expires_at')
|
return $query->whereNotNull('expires_at')
|
||||||
->where('expires_at', '<=', now());
|
->where('expires_at', '<=', now());
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,12 @@ 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.
|
||||||
|
|
@ -82,22 +83,22 @@ class AgentPhase extends Model
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scopes
|
// Scopes
|
||||||
public function scopePending($query)
|
public function scopePending(Builder $query): Builder
|
||||||
{
|
{
|
||||||
return $query->where('status', self::STATUS_PENDING);
|
return $query->where('status', self::STATUS_PENDING);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function scopeInProgress($query)
|
public function scopeInProgress(Builder $query): Builder
|
||||||
{
|
{
|
||||||
return $query->where('status', self::STATUS_IN_PROGRESS);
|
return $query->where('status', self::STATUS_IN_PROGRESS);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function scopeCompleted($query)
|
public function scopeCompleted(Builder $query): Builder
|
||||||
{
|
{
|
||||||
return $query->where('status', self::STATUS_COMPLETED);
|
return $query->where('status', self::STATUS_COMPLETED);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function scopeBlocked($query)
|
public function scopeBlocked(Builder $query): Builder
|
||||||
{
|
{
|
||||||
return $query->where('status', self::STATUS_BLOCKED);
|
return $query->where('status', self::STATUS_BLOCKED);
|
||||||
}
|
}
|
||||||
|
|
@ -316,11 +317,17 @@ class AgentPhase extends Model
|
||||||
public function checkDependencies(): array
|
public function checkDependencies(): array
|
||||||
{
|
{
|
||||||
$dependencies = $this->dependencies ?? [];
|
$dependencies = $this->dependencies ?? [];
|
||||||
|
|
||||||
|
if (empty($dependencies)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
$blockers = [];
|
$blockers = [];
|
||||||
|
|
||||||
foreach ($dependencies as $depId) {
|
$deps = AgentPhase::whereIn('id', $dependencies)->get();
|
||||||
$dep = AgentPhase::find($depId);
|
|
||||||
if ($dep && ! $dep->isCompleted() && ! $dep->isSkipped()) {
|
foreach ($deps as $dep) {
|
||||||
|
if (! $dep->isCompleted() && ! $dep->isSkipped()) {
|
||||||
$blockers[] = [
|
$blockers[] = [
|
||||||
'phase_id' => $dep->id,
|
'phase_id' => $dep->id,
|
||||||
'phase_order' => $dep->order,
|
'phase_order' => $dep->order,
|
||||||
|
|
|
||||||
|
|
@ -4,14 +4,16 @@ 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;
|
||||||
|
|
||||||
|
|
@ -32,6 +34,8 @@ 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
|
||||||
*/
|
*/
|
||||||
|
|
@ -43,6 +47,7 @@ class AgentPlan extends Model
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
use LogsActivity;
|
use LogsActivity;
|
||||||
|
use SoftDeletes;
|
||||||
|
|
||||||
protected static function newFactory(): AgentPlanFactory
|
protected static function newFactory(): AgentPlanFactory
|
||||||
{
|
{
|
||||||
|
|
@ -60,12 +65,15 @@ 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
|
||||||
|
|
@ -95,21 +103,26 @@ class AgentPlan extends Model
|
||||||
|
|
||||||
public function states(): HasMany
|
public function states(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(AgentWorkspaceState::class);
|
return $this->hasMany(WorkspaceState::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function templateVersion(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(PlanTemplateVersion::class, 'template_version_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scopes
|
// Scopes
|
||||||
public function scopeActive($query)
|
public function scopeActive(Builder $query): Builder
|
||||||
{
|
{
|
||||||
return $query->where('status', self::STATUS_ACTIVE);
|
return $query->where('status', self::STATUS_ACTIVE);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function scopeDraft($query)
|
public function scopeDraft(Builder $query): Builder
|
||||||
{
|
{
|
||||||
return $query->where('status', self::STATUS_DRAFT);
|
return $query->where('status', self::STATUS_DRAFT);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function scopeNotArchived($query)
|
public function scopeNotArchived(Builder $query): Builder
|
||||||
{
|
{
|
||||||
return $query->where('status', '!=', self::STATUS_ARCHIVED);
|
return $query->where('status', '!=', self::STATUS_ARCHIVED);
|
||||||
}
|
}
|
||||||
|
|
@ -120,7 +133,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($query, string $direction = 'asc')
|
public function scopeOrderByStatus(Builder $query, string $direction = 'asc'): Builder
|
||||||
{
|
{
|
||||||
return $query->orderByRaw('CASE status
|
return $query->orderByRaw('CASE status
|
||||||
WHEN ? THEN 1
|
WHEN ? THEN 1
|
||||||
|
|
@ -128,7 +141,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
|
||||||
|
|
@ -165,11 +178,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,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -227,7 +240,7 @@ class AgentPlan extends Model
|
||||||
return $state?->value;
|
return $state?->value;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setState(string $key, mixed $value, string $type = 'json', ?string $description = null): AgentWorkspaceState
|
public function setState(string $key, mixed $value, string $type = 'json', ?string $description = null): WorkspaceState
|
||||||
{
|
{
|
||||||
return $this->states()->updateOrCreate(
|
return $this->states()->updateOrCreate(
|
||||||
['key' => $key],
|
['key' => $key],
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,13 @@ 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;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -107,12 +108,12 @@ class AgentSession extends Model
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scopes
|
// Scopes
|
||||||
public function scopeActive($query)
|
public function scopeActive(Builder $query): Builder
|
||||||
{
|
{
|
||||||
return $query->where('status', self::STATUS_ACTIVE);
|
return $query->where('status', self::STATUS_ACTIVE);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function scopeForPlan($query, AgentPlan|int $plan)
|
public function scopeForPlan(Builder $query, AgentPlan|int $plan): Builder
|
||||||
{
|
{
|
||||||
$planId = $plan instanceof AgentPlan ? $plan->id : $plan;
|
$planId = $plan instanceof AgentPlan ? $plan->id : $plan;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,115 +0,0 @@
|
||||||
<?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(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
92
Models/PlanTemplateVersion.php
Normal file
92
Models/PlanTemplateVersion.php
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
<?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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,10 +4,11 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace Core\Mod\Agentic\Models;
|
namespace Core\Mod\Agentic\Models;
|
||||||
|
|
||||||
use Mod\Content\Models\ContentTask;
|
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\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Mod\Content\Models\ContentTask;
|
||||||
|
|
||||||
class Prompt extends Model
|
class Prompt extends Model
|
||||||
{
|
{
|
||||||
|
|
@ -82,7 +83,7 @@ class Prompt extends Model
|
||||||
/**
|
/**
|
||||||
* Scope to only active prompts.
|
* Scope to only active prompts.
|
||||||
*/
|
*/
|
||||||
public function scopeActive($query)
|
public function scopeActive(Builder $query): Builder
|
||||||
{
|
{
|
||||||
return $query->where('is_active', true);
|
return $query->where('is_active', true);
|
||||||
}
|
}
|
||||||
|
|
@ -90,7 +91,7 @@ class Prompt extends Model
|
||||||
/**
|
/**
|
||||||
* Scope by category.
|
* Scope by category.
|
||||||
*/
|
*/
|
||||||
public function scopeCategory($query, string $category)
|
public function scopeCategory(Builder $query, string $category): Builder
|
||||||
{
|
{
|
||||||
return $query->where('category', $category);
|
return $query->where('category', $category);
|
||||||
}
|
}
|
||||||
|
|
@ -98,7 +99,7 @@ class Prompt extends Model
|
||||||
/**
|
/**
|
||||||
* Scope by model provider.
|
* Scope by model provider.
|
||||||
*/
|
*/
|
||||||
public function scopeForModel($query, string $model)
|
public function scopeForModel(Builder $query, string $model): Builder
|
||||||
{
|
{
|
||||||
return $query->where('model', $model);
|
return $query->where('model', $model);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,9 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace Core\Mod\Agentic\Models;
|
namespace Core\Mod\Agentic\Models;
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Core\Tenant\Concerns\BelongsToWorkspace;
|
use Core\Tenant\Concerns\BelongsToWorkspace;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
class Task extends Model
|
class Task extends Model
|
||||||
{
|
{
|
||||||
|
|
@ -26,22 +27,22 @@ class Task extends Model
|
||||||
'line_ref' => 'integer',
|
'line_ref' => 'integer',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function scopePending($query)
|
public function scopePending(Builder $query): Builder
|
||||||
{
|
{
|
||||||
return $query->where('status', 'pending');
|
return $query->where('status', 'pending');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function scopeInProgress($query)
|
public function scopeInProgress(Builder $query): Builder
|
||||||
{
|
{
|
||||||
return $query->where('status', 'in_progress');
|
return $query->where('status', 'in_progress');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function scopeDone($query)
|
public function scopeDone(Builder $query): Builder
|
||||||
{
|
{
|
||||||
return $query->where('status', 'done');
|
return $query->where('status', 'done');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function scopeActive($query)
|
public function scopeActive(Builder $query): Builder
|
||||||
{
|
{
|
||||||
return $query->whereIn('status', ['pending', 'in_progress']);
|
return $query->whereIn('status', ['pending', 'in_progress']);
|
||||||
}
|
}
|
||||||
|
|
@ -60,7 +61,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']);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -76,7 +77,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
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ 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;
|
||||||
|
|
@ -11,12 +12,25 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
/**
|
/**
|
||||||
* Workspace State Model
|
* Workspace State Model
|
||||||
*
|
*
|
||||||
* Key-value state storage for agent plans with typed content.
|
* Persistent key-value state storage for agent plans.
|
||||||
|
* 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';
|
||||||
|
|
@ -30,34 +44,27 @@ class WorkspaceState extends Model
|
||||||
'key',
|
'key',
|
||||||
'value',
|
'value',
|
||||||
'type',
|
'type',
|
||||||
'metadata',
|
'description',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'metadata' => 'array',
|
'value' => 'array',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $attributes = [
|
// Relationships
|
||||||
'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 getTypedValue(): mixed
|
|
||||||
{
|
|
||||||
return match ($this->type) {
|
|
||||||
self::TYPE_JSON => json_decode($this->value, true),
|
|
||||||
default => $this->value,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
|
public function scopeForPlan($query, AgentPlan|int $plan): mixed
|
||||||
|
{
|
||||||
|
$planId = $plan instanceof AgentPlan ? $plan->id : $plan;
|
||||||
|
|
||||||
|
<<<<<<< HEAD
|
||||||
/**
|
/**
|
||||||
* Set typed value.
|
* Set typed value.
|
||||||
*/
|
*/
|
||||||
|
|
@ -132,7 +139,7 @@ class WorkspaceState extends Model
|
||||||
/**
|
/**
|
||||||
* Scope: for plan.
|
* Scope: for plan.
|
||||||
*/
|
*/
|
||||||
public function scopeForPlan($query, int $planId)
|
public function scopeForPlan(Builder $query, int $planId): Builder
|
||||||
{
|
{
|
||||||
return $query->where('agent_plan_id', $planId);
|
return $query->where('agent_plan_id', $planId);
|
||||||
}
|
}
|
||||||
|
|
@ -140,8 +147,75 @@ class WorkspaceState extends Model
|
||||||
/**
|
/**
|
||||||
* Scope: by type.
|
* Scope: by type.
|
||||||
*/
|
*/
|
||||||
public function scopeByType($query, string $type)
|
public function scopeByType(Builder $query, string $type): Builder
|
||||||
{
|
{
|
||||||
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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,6 +156,9 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -164,6 +167,9 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -17,106 +17,221 @@ 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.
|
||||||
*
|
*
|
||||||
* @var array<string, array{pattern: string, model_pattern: ?string}>
|
* Each entry maps a provider key to an array of detection patterns and optional
|
||||||
|
* 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',
|
'/claude[\s\-_]?code/i', // e.g. "claude-code/1.0", "claude code"
|
||||||
'/\banthopic\b/i',
|
'/\banthopic\b/i', // e.g. "Anthropic/1.0" (intentional typo tolerance)
|
||||||
'/\banthropic[\s\-_]?api\b/i',
|
'/\banthropic[\s\-_]?api\b/i', // e.g. "Anthropic-API/2.0"
|
||||||
'/\bclaude\b.*\bai\b/i',
|
'/\bclaude\b.*\bai\b/i', // e.g. "Claude AI Assistant/1.0"
|
||||||
'/\bclaude\b.*\bassistant\b/i',
|
'/\bclaude\b.*\bassistant\b/i', // e.g. "Claude-Assistant/2.1"
|
||||||
],
|
],
|
||||||
'model_patterns' => [
|
'model_patterns' => [
|
||||||
'claude-opus' => '/claude[\s\-_]?opus/i',
|
'claude-opus' => '/claude[\s\-_]?opus/i', // e.g. "claude-opus-4-5"
|
||||||
'claude-sonnet' => '/claude[\s\-_]?sonnet/i',
|
'claude-sonnet' => '/claude[\s\-_]?sonnet/i', // e.g. "claude-sonnet-4-6"
|
||||||
'claude-haiku' => '/claude[\s\-_]?haiku/i',
|
'claude-haiku' => '/claude[\s\-_]?haiku/i', // e.g. "claude-haiku-4-5"
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
'openai' => [
|
'openai' => [
|
||||||
'patterns' => [
|
'patterns' => [
|
||||||
'/\bChatGPT\b/i',
|
'/\bChatGPT\b/i', // e.g. "ChatGPT-User/1.0"
|
||||||
'/\bOpenAI\b/i',
|
'/\bOpenAI\b/i', // e.g. "OpenAI/1.0 python-httpx/0.26"
|
||||||
'/\bGPT[\s\-_]?4\b/i',
|
'/\bGPT[\s\-_]?4\b/i', // e.g. "GPT-4-turbo/2024-04"
|
||||||
'/\bGPT[\s\-_]?3\.?5\b/i',
|
'/\bGPT[\s\-_]?3\.?5\b/i', // e.g. "GPT-3.5-turbo/1.0"
|
||||||
'/\bo1[\s\-_]?preview\b/i',
|
'/\bo1[\s\-_]?preview\b/i', // e.g. "o1-preview/2024-09"
|
||||||
'/\bo1[\s\-_]?mini\b/i',
|
'/\bo1[\s\-_]?mini\b/i', // e.g. "o1-mini/1.0"
|
||||||
],
|
],
|
||||||
'model_patterns' => [
|
'model_patterns' => [
|
||||||
'gpt-4' => '/\bGPT[\s\-_]?4/i',
|
'gpt-4' => '/\bGPT[\s\-_]?4/i', // e.g. "GPT-4o", "GPT-4-turbo"
|
||||||
'gpt-3.5' => '/\bGPT[\s\-_]?3\.?5/i',
|
'gpt-3.5' => '/\bGPT[\s\-_]?3\.?5/i', // e.g. "GPT-3.5-turbo"
|
||||||
'o1' => '/\bo1[\s\-_]?(preview|mini)?\b/i',
|
'o1' => '/\bo1[\s\-_]?(preview|mini)?\b/i', // e.g. "o1", "o1-preview", "o1-mini"
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
'google' => [
|
'google' => [
|
||||||
'patterns' => [
|
'patterns' => [
|
||||||
'/\bGoogle[\s\-_]?AI\b/i',
|
'/\bGoogle[\s\-_]?AI\b/i', // e.g. "Google-AI/1.0"
|
||||||
'/\bGemini\b/i',
|
'/\bGemini\b/i', // e.g. "Gemini/1.5-pro", "gemini-flash"
|
||||||
'/\bBard\b/i',
|
'/\bBard\b/i', // e.g. "Google Bard/0.1" (legacy)
|
||||||
'/\bPaLM\b/i',
|
'/\bPaLM\b/i', // e.g. "PaLM API/1.0" (legacy)
|
||||||
],
|
],
|
||||||
'model_patterns' => [
|
'model_patterns' => [
|
||||||
'gemini-pro' => '/gemini[\s\-_]?(1\.5[\s\-_]?)?pro/i',
|
'gemini-pro' => '/gemini[\s\-_]?(1\.5[\s\-_]?)?pro/i', // e.g. "gemini-1.5-pro"
|
||||||
'gemini-ultra' => '/gemini[\s\-_]?(1\.5[\s\-_]?)?ultra/i',
|
'gemini-ultra' => '/gemini[\s\-_]?(1\.5[\s\-_]?)?ultra/i', // e.g. "gemini-ultra"
|
||||||
'gemini-flash' => '/gemini[\s\-_]?(1\.5[\s\-_]?)?flash/i',
|
'gemini-flash' => '/gemini[\s\-_]?(1\.5[\s\-_]?)?flash/i', // e.g. "gemini-1.5-flash"
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
'meta' => [
|
'meta' => [
|
||||||
'patterns' => [
|
'patterns' => [
|
||||||
'/\bMeta[\s\-_]?AI\b/i',
|
'/\bMeta[\s\-_]?AI\b/i', // e.g. "Meta AI/1.0"
|
||||||
'/\bLLaMA\b/i',
|
'/\bLLaMA\b/i', // e.g. "LLaMA/2.0 meta-ai"
|
||||||
'/\bLlama[\s\-_]?[23]\b/i',
|
'/\bLlama[\s\-_]?[23]\b/i', // e.g. "Llama-3/2024-04", "Llama-2-chat"
|
||||||
],
|
],
|
||||||
'model_patterns' => [
|
'model_patterns' => [
|
||||||
'llama-3' => '/llama[\s\-_]?3/i',
|
'llama-3' => '/llama[\s\-_]?3/i', // e.g. "Llama-3-8B", "llama3-70b"
|
||||||
'llama-2' => '/llama[\s\-_]?2/i',
|
'llama-2' => '/llama[\s\-_]?2/i', // e.g. "Llama-2-chat/70B"
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
'mistral' => [
|
'mistral' => [
|
||||||
'patterns' => [
|
'patterns' => [
|
||||||
'/\bMistral\b/i',
|
'/\bMistral\b/i', // e.g. "Mistral/0.1.0 mistralai-python/0.1"
|
||||||
'/\bMixtral\b/i',
|
'/\bMixtral\b/i', // e.g. "Mixtral-8x7B/1.0"
|
||||||
],
|
],
|
||||||
'model_patterns' => [
|
'model_patterns' => [
|
||||||
'mistral-large' => '/mistral[\s\-_]?large/i',
|
'mistral-large' => '/mistral[\s\-_]?large/i', // e.g. "mistral-large-latest"
|
||||||
'mistral-medium' => '/mistral[\s\-_]?medium/i',
|
'mistral-medium' => '/mistral[\s\-_]?medium/i', // e.g. "mistral-medium"
|
||||||
'mixtral' => '/mixtral/i',
|
'mixtral' => '/mixtral/i', // e.g. "Mixtral-8x7B-Instruct"
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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',
|
'/\bMozilla\b/i', // All Gecko/WebKit/Blink browsers include "Mozilla/5.0"
|
||||||
'/\bChrome\b/i',
|
'/\bChrome\b/i', // Chrome, Chromium, and most Chromium-based browsers
|
||||||
'/\bSafari\b/i',
|
'/\bSafari\b/i', // Safari and WebKit-based browsers
|
||||||
'/\bFirefox\b/i',
|
'/\bFirefox\b/i', // Mozilla Firefox
|
||||||
'/\bEdge\b/i',
|
'/\bEdge\b/i', // Microsoft Edge (legacy "Edge/" and Chromium "Edg/")
|
||||||
'/\bOpera\b/i',
|
'/\bOpera\b/i', // Opera ("Opera/" classic, "OPR/" modern)
|
||||||
'/\bMSIE\b/i',
|
'/\bMSIE\b/i', // Internet Explorer (e.g. "MSIE 11.0")
|
||||||
'/\bTrident\b/i',
|
'/\bTrident\b/i', // IE 11 Trident rendering engine token
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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',
|
||||||
|
|
@ -124,17 +239,22 @@ 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\b/i',
|
'/\bPostman/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',
|
||||||
|
|
@ -142,7 +262,19 @@ class AgentDetection
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The MCP token header name.
|
* The MCP token header used to identify registered AI agents.
|
||||||
|
*
|
||||||
|
* 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';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,10 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace Core\Mod\Agentic\Services;
|
namespace Core\Mod\Agentic\Services;
|
||||||
|
|
||||||
use Illuminate\Support\Collection;
|
|
||||||
use Illuminate\Support\Facades\Cache;
|
|
||||||
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\Collection;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Agent Session Service - manages session persistence for agent continuity.
|
* Agent Session Service - manages session persistence for agent continuity.
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,9 @@ 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 Illuminate\Support\Collection;
|
|
||||||
use Core\Mod\Agentic\Mcp\Tools\Agent\Contracts\AgentToolInterface;
|
use Core\Mod\Agentic\Mcp\Tools\Agent\Contracts\AgentToolInterface;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registry for MCP Agent Server tools.
|
* Registry for MCP Agent Server tools.
|
||||||
|
|
@ -98,24 +99,57 @@ 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
|
||||||
{
|
{
|
||||||
return $this->all()->filter(function (AgentToolInterface $tool) use ($apiKey) {
|
$cacheKey = $this->apiKeyCacheKey($apiKey->getKey());
|
||||||
// Check if API key has required scopes
|
|
||||||
foreach ($tool->requiredScopes() as $scope) {
|
|
||||||
if (! $apiKey->hasScope($scope)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if API key has tool-level permission
|
$permittedNames = Cache::remember($cacheKey, self::CACHE_TTL, function () use ($apiKey) {
|
||||||
return $this->apiKeyCanAccessTool($apiKey, $tool->name());
|
return $this->all()->filter(function (AgentToolInterface $tool) use ($apiKey) {
|
||||||
|
// Check if API key has required scopes
|
||||||
|
foreach ($tool->requiredScopes() as $scope) {
|
||||||
|
if (! $apiKey->hasScope($scope)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if API key has tool-level permission
|
||||||
|
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}";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ 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
|
||||||
|
|
@ -91,22 +92,46 @@ 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: config('services.anthropic.api_key') ?? '',
|
apiKey: $claudeKey,
|
||||||
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: config('services.google.ai_api_key') ?? '',
|
apiKey: $geminiKey,
|
||||||
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: config('services.openai.api_key') ?? '',
|
apiKey: $openaiKey,
|
||||||
model: config('services.openai.model') ?? 'gpt-4o-mini',
|
model: config('services.openai.model') ?? 'gpt-4o-mini',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,13 @@ 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 Illuminate\Support\Facades\Log;
|
||||||
use Core\Mod\Agentic\Services\Concerns\HasStreamParsing;
|
use Throwable;
|
||||||
use RuntimeException;
|
|
||||||
|
|
||||||
class ClaudeService implements AgenticProviderInterface
|
class ClaudeService implements AgenticProviderInterface
|
||||||
{
|
{
|
||||||
|
|
@ -59,28 +60,47 @@ 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 {
|
||||||
$response = $this->client()
|
try {
|
||||||
->withOptions(['stream' => true])
|
$response = $this->client()
|
||||||
->post(self::API_URL, [
|
->withOptions(['stream' => true])
|
||||||
'model' => $config['model'] ?? $this->model,
|
->post(self::API_URL, [
|
||||||
'max_tokens' => $config['max_tokens'] ?? 4096,
|
'model' => $config['model'] ?? $this->model,
|
||||||
'temperature' => $config['temperature'] ?? 1.0,
|
'max_tokens' => $config['max_tokens'] ?? 4096,
|
||||||
'stream' => true,
|
'temperature' => $config['temperature'] ?? 1.0,
|
||||||
'system' => $systemPrompt,
|
'stream' => true,
|
||||||
'messages' => [
|
'system' => $systemPrompt,
|
||||||
['role' => 'user', 'content' => $userPrompt],
|
'messages' => [
|
||||||
],
|
['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 from $this->parseSSEStream(
|
yield ['type' => 'error', 'message' => $e->getMessage()];
|
||||||
$response->getBody(),
|
}
|
||||||
fn (array $data) => $data['delta']['text'] ?? null
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function name(): string
|
public function name(): string
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,6 @@ 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
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -119,6 +119,7 @@ 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);
|
||||||
|
|
@ -129,9 +130,10 @@ trait HasStreamParsing
|
||||||
|
|
||||||
$buffer .= $chunk;
|
$buffer .= $chunk;
|
||||||
|
|
||||||
// Parse JSON objects from the buffer
|
// Parse JSON objects from the buffer, continuing from where
|
||||||
|
// the previous iteration left off to preserve parser state.
|
||||||
$length = strlen($buffer);
|
$length = strlen($buffer);
|
||||||
$i = 0;
|
$i = $scanPos;
|
||||||
|
|
||||||
while ($i < $length) {
|
while ($i < $length) {
|
||||||
$char = $buffer[$i];
|
$char = $buffer[$i];
|
||||||
|
|
@ -176,6 +178,7 @@ 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -183,6 +186,9 @@ trait HasStreamParsing
|
||||||
|
|
||||||
$i++;
|
$i++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Save scan position so we resume from here on the next chunk
|
||||||
|
$scanPos = $i;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,8 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace Core\Mod\Agentic\Services;
|
namespace Core\Mod\Agentic\Services;
|
||||||
|
|
||||||
use Mod\Content\Models\ContentItem;
|
|
||||||
use Illuminate\Support\Facades\File;
|
use Illuminate\Support\Facades\File;
|
||||||
|
use Mod\Content\Models\ContentItem;
|
||||||
use Symfony\Component\Yaml\Yaml;
|
use Symfony\Component\Yaml\Yaml;
|
||||||
|
|
||||||
class ContentService
|
class ContentService
|
||||||
|
|
@ -118,15 +118,21 @@ 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) {
|
||||||
|
|
@ -144,6 +150,13 @@ 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) {
|
||||||
|
|
@ -152,10 +165,13 @@ class ContentService
|
||||||
|
|
||||||
$draftPath = $this->getDraftPath($spec, $slug);
|
$draftPath = $this->getDraftPath($spec, $slug);
|
||||||
|
|
||||||
// Skip if already drafted
|
// Skip if draft file already exists on disk
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
@ -166,20 +182,168 @@ class ContentService
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// Skip articles successfully generated in a prior run
|
||||||
$content = $this->generateArticle($article, $spec, $promptTemplate, $provider);
|
if (($progress['articles'][$slug]['status'] ?? 'pending') === 'generated') {
|
||||||
$this->saveDraft($draftPath, $content, $article);
|
$results['articles'][$slug] = ['status' => 'skipped', 'reason' => 'previously generated'];
|
||||||
$results['articles'][$slug] = ['status' => 'generated', 'path' => $draftPath];
|
$results['skipped']++;
|
||||||
$results['generated']++;
|
|
||||||
} catch (\Exception $e) {
|
continue;
|
||||||
$results['articles'][$slug] = ['status' => 'failed', 'error' => $e->getMessage()];
|
|
||||||
$results['failed']++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$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;
|
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 {
|
||||||
|
$content = $this->generateArticle($article, $spec, $promptTemplate, $provider);
|
||||||
|
$this->saveDraft($draftPath, $content, $article);
|
||||||
|
|
||||||
|
return ['status' => 'generated', 'path' => $draftPath, 'attempts' => $attempt];
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$lastError = $e->getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['status' => 'failed', 'error' => $lastError, 'attempts' => $totalAttempts];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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");
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a single article.
|
* Generate a single article.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,11 @@ 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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,11 @@ 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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ 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;
|
||||||
|
|
@ -146,6 +147,10 @@ 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);
|
||||||
|
|
||||||
|
|
@ -164,10 +169,12 @@ 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(),
|
||||||
]),
|
]),
|
||||||
|
|
@ -329,13 +336,18 @@ 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']];
|
return ['valid' => false, 'errors' => ['Template not found'], 'naming_convention' => self::NAMING_CONVENTION];
|
||||||
}
|
}
|
||||||
|
|
||||||
$errors = [];
|
$errors = [];
|
||||||
|
|
@ -344,16 +356,94 @@ 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[] = "Required variable '{$name}' is missing";
|
$errors[] = $this->buildVariableError($name, $varDef);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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
64
TODO.md
|
|
@ -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
|
||||||
|
|
||||||
- [ ] **DB-002: Missing indexes on frequently queried columns**
|
- [x] **DB-002: Missing indexes on frequently queried columns** (FIXED 2026-02-23)
|
||||||
- `agent_sessions.session_id` - frequently looked up by string
|
- `agent_sessions.session_id` - unique() constraint creates implicit index; sufficient for lookups
|
||||||
- `agent_plans.slug` - used in URL routing
|
- `agent_plans.slug` - redundant plain index dropped; compound (workspace_id, slug) index added
|
||||||
- `workspace_states.key` - key lookup is common operation
|
- `workspace_states.key` - already indexed via ->index('key') in migration 000003
|
||||||
|
|
||||||
### Error Handling
|
### Error Handling
|
||||||
|
|
||||||
|
|
@ -104,10 +104,11 @@ 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
|
||||||
|
|
||||||
- [ ] **ERR-002: ContentService has no batch failure recovery**
|
- [x] **ERR-002: ContentService has no batch failure recovery** (FIXED 2026-02-23)
|
||||||
- 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: Add progress tracking, allow resuming from failed point
|
- Fix: Added progress state file, per-article retry (maxRetries param), `resumeBatch()` method
|
||||||
|
- Tests: 6 new tests in `tests/Feature/ContentServiceTest.php` covering state persistence, resume, retries
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -115,36 +116,43 @@ Production-quality task list for the AI agent orchestration package.
|
||||||
|
|
||||||
### Developer Experience
|
### Developer Experience
|
||||||
|
|
||||||
- [ ] **DX-001: Missing workspace context error messages unclear**
|
- [x] **DX-001: Missing workspace context error messages unclear** (FIXED 2026-02-23)
|
||||||
- Location: Multiple MCP tools
|
- Location: Multiple MCP tools
|
||||||
- Issue: "workspace_id is required" doesn't explain how to fix
|
- Issue: "workspace_id is required" didn't explain how to fix
|
||||||
- Fix: Include context about authentication/session setup
|
- Fix: Updated error messages in PlanCreate, PlanGet, PlanList, StateSet, StateGet, StateList, SessionStart to include actionable guidance and link to documentation
|
||||||
|
|
||||||
- [ ] **DX-002: AgenticManager doesn't validate API keys on init**
|
- [x] **DX-002: AgenticManager doesn't validate API keys on init** (FIXED 2026-02-23)
|
||||||
- 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 or throw if provider configured without key
|
- Fix: `Log::warning()` emitted for each provider registered without an API key; message names the env var to set
|
||||||
|
|
||||||
- [ ] **DX-003: Plan template variable errors not actionable**
|
- [x] **DX-003: Plan template variable errors not actionable** (FIXED 2026-02-23)
|
||||||
- Location: `Services/PlanTemplateService.php::validateVariables()`
|
- Location: `Services/PlanTemplateService.php::validateVariables()`
|
||||||
- Fix: Include expected format, examples in error messages
|
- Fix: Error messages now include variable description, example/examples, and expected format
|
||||||
|
- 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
|
||||||
|
|
||||||
- [ ] **CQ-001: Duplicate state models (WorkspaceState vs AgentWorkspaceState)**
|
- [x] **CQ-001: Duplicate state models (WorkspaceState vs AgentWorkspaceState)** (FIXED 2026-02-23)
|
||||||
- Files: `Models/WorkspaceState.php`, `Models/AgentWorkspaceState.php`
|
- Deleted `Models/AgentWorkspaceState.php` (unused legacy port)
|
||||||
- Issue: Two similar models for same purpose
|
- Consolidated into `Models/WorkspaceState.php` backed by `agent_workspace_states` table
|
||||||
- Fix: Consolidate into single model, or clarify distinct purposes
|
- Updated `AgentPlan`, `StateSet`, `SecurityTest` to use `WorkspaceState`
|
||||||
|
- Added `WorkspaceStateTest` covering model behaviour and static helpers
|
||||||
|
|
||||||
- [ ] **CQ-002: ApiKeyManager uses Core\Api\ApiKey, not AgentApiKey**
|
- [x] **CQ-002: ApiKeyManager uses Core\Api\ApiKey, not AgentApiKey** (FIXED 2026-02-23)
|
||||||
- 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: Unify on AgentApiKey or document distinction
|
- Fix: Switched to `AgentApiKey` model and `AgentApiKeyService` throughout
|
||||||
|
- Updated blade template to use `permissions`, `getMaskedKey()`, `togglePermission()`
|
||||||
|
- Added integration tests in `tests/Feature/ApiKeyManagerTest.php`
|
||||||
|
|
||||||
- [ ] **CQ-003: ForAgentsController cache key not namespaced**
|
- [x] **CQ-003: ForAgentsController cache key not namespaced** (FIXED 2026-02-23)
|
||||||
- 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: Add workspace prefix or use config-based key
|
- Fix: Cache key and TTL now driven by `mcp.cache.for_agents_key` / `mcp.cache.for_agents_ttl` config
|
||||||
|
- Added `cacheKey()` public method and config entries in `config.php`
|
||||||
|
- Tests added in `tests/Feature/ForAgentsControllerTest.php`
|
||||||
|
|
||||||
### Performance
|
### Performance
|
||||||
|
|
||||||
|
|
@ -164,10 +172,10 @@ Production-quality task list for the AI agent orchestration package.
|
||||||
|
|
||||||
### Documentation Gaps
|
### Documentation Gaps
|
||||||
|
|
||||||
- [ ] **DOC-001: Add PHPDoc to AgentDetection patterns**
|
- [x] **DOC-001: Add PHPDoc to AgentDetection patterns** (FIXED 2026-02-23)
|
||||||
- Location: `Services/AgentDetection.php`
|
- Location: `Services/AgentDetection.php`
|
||||||
- Issue: User-Agent patterns undocumented
|
- Issue: User-Agent patterns undocumented
|
||||||
- Fix: Document each pattern with agent examples
|
- Fix: Added PHPDoc with real UA examples to all pattern constants, class-level usage examples, and MCP_TOKEN_HEADER docs
|
||||||
|
|
||||||
- [ ] **DOC-002: Document MCP tool dependency system**
|
- [ ] **DOC-002: Document MCP tool dependency system**
|
||||||
- Location: `Mcp/Tools/Agent/` directory
|
- Location: `Mcp/Tools/Agent/` directory
|
||||||
|
|
@ -184,16 +192,17 @@ 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
|
||||||
|
|
||||||
- [ ] **FEAT-003: Template version management**
|
- [x] **FEAT-003: Template version management**
|
||||||
- Location: `Services/PlanTemplateService.php`
|
- Location: `Services/PlanTemplateService.php`, `Models/PlanTemplateVersion.php`
|
||||||
- Issue: Template changes affect existing plan references
|
- Issue: Template changes affect existing plan references
|
||||||
- Fix: Add version tracking to templates
|
- Fix: Add version tracking to templates — implemented in #35
|
||||||
|
|
||||||
### Consistency
|
### Consistency
|
||||||
|
|
||||||
- [ ] **CON-001: Mixed UK/US spelling in code comments**
|
- [x] **CON-001: Mixed UK/US spelling in code comments** (FIXED 2026-02-23)
|
||||||
- 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, ...]`
|
||||||
|
|
@ -283,6 +292,7 @@ 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)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -68,18 +68,17 @@
|
||||||
</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->prefix }}_****
|
{{ $key->getMaskedKey() }}
|
||||||
</code>
|
</code>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
<td class="px-6 py-4">
|
||||||
<div class="flex gap-1">
|
<div class="flex flex-wrap gap-1">
|
||||||
@foreach($key->scopes ?? [] as $scope)
|
@foreach($key->permissions ?? [] as $permission)
|
||||||
<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
|
||||||
{{ $scope === 'read' ? 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300' : '' }}
|
{{ str_ends_with($permission, '.read') ? 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300' : '' }}
|
||||||
{{ $scope === 'write' ? 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-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 === 'delete' ? 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300' : '' }}
|
|
||||||
">
|
">
|
||||||
{{ $scope }}
|
{{ $permission }}
|
||||||
</span>
|
</span>
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -131,11 +130,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 hk_abc123_****</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 ak_****</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: hk_abc123_****</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: ak_****</code></pre>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -179,37 +178,24 @@
|
||||||
@enderror
|
@enderror
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Scopes -->
|
<!-- Permissions -->
|
||||||
<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">
|
||||||
<label class="flex items-center gap-2">
|
@foreach($this->availablePermissions() as $permission => $description)
|
||||||
<input
|
<label class="flex items-start gap-2">
|
||||||
type="checkbox"
|
<input
|
||||||
wire:click="toggleScope('read')"
|
type="checkbox"
|
||||||
{{ in_array('read', $newKeyScopes) ? 'checked' : '' }}
|
wire:click="togglePermission('{{ $permission }}')"
|
||||||
class="rounded border-zinc-300 text-cyan-600 focus:ring-cyan-500"
|
{{ in_array($permission, $newKeyPermissions) ? 'checked' : '' }}
|
||||||
>
|
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>
|
>
|
||||||
</label>
|
<span class="text-sm text-zinc-700 dark:text-zinc-300">
|
||||||
<label class="flex items-center gap-2">
|
<span class="font-mono text-xs text-zinc-500">{{ $permission }}</span>
|
||||||
<input
|
<span class="block text-xs text-zinc-500 dark:text-zinc-400">{{ $description }}</span>
|
||||||
type="checkbox"
|
</span>
|
||||||
wire:click="toggleScope('write')"
|
</label>
|
||||||
{{ in_array('write', $newKeyScopes) ? 'checked' : '' }}
|
@endforeach
|
||||||
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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,8 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace Core\Mod\Agentic\View\Modal\Admin;
|
namespace Core\Mod\Agentic\View\Modal\Admin;
|
||||||
|
|
||||||
use Core\Api\Models\ApiKey;
|
use Core\Mod\Agentic\Models\AgentApiKey;
|
||||||
|
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;
|
||||||
|
|
@ -25,7 +26,7 @@ class ApiKeyManager extends Component
|
||||||
|
|
||||||
public string $newKeyName = '';
|
public string $newKeyName = '';
|
||||||
|
|
||||||
public array $newKeyScopes = ['read', 'write'];
|
public array $newKeyPermissions = [];
|
||||||
|
|
||||||
public string $newKeyExpiry = 'never';
|
public string $newKeyExpiry = 'never';
|
||||||
|
|
||||||
|
|
@ -43,7 +44,7 @@ class ApiKeyManager extends Component
|
||||||
{
|
{
|
||||||
$this->showCreateModal = true;
|
$this->showCreateModal = true;
|
||||||
$this->newKeyName = '';
|
$this->newKeyName = '';
|
||||||
$this->newKeyScopes = ['read', 'write'];
|
$this->newKeyPermissions = [];
|
||||||
$this->newKeyExpiry = 'never';
|
$this->newKeyExpiry = 'never';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -52,6 +53,11 @@ 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([
|
||||||
|
|
@ -65,15 +71,14 @@ class ApiKeyManager extends Component
|
||||||
default => null,
|
default => null,
|
||||||
};
|
};
|
||||||
|
|
||||||
$result = ApiKey::generate(
|
$key = app(AgentApiKeyService::class)->create(
|
||||||
workspaceId: $this->workspace->id,
|
workspace: $this->workspace,
|
||||||
userId: auth()->id(),
|
|
||||||
name: $this->newKeyName,
|
name: $this->newKeyName,
|
||||||
scopes: $this->newKeyScopes,
|
permissions: $this->newKeyPermissions,
|
||||||
expiresAt: $expiresAt,
|
expiresAt: $expiresAt,
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->newPlainKey = $result['plain_key'];
|
$this->newPlainKey = $key->plainTextKey;
|
||||||
$this->showCreateModal = false;
|
$this->showCreateModal = false;
|
||||||
$this->showNewKeyModal = true;
|
$this->showNewKeyModal = true;
|
||||||
|
|
||||||
|
|
@ -88,25 +93,25 @@ class ApiKeyManager extends Component
|
||||||
|
|
||||||
public function revokeKey(int $keyId): void
|
public function revokeKey(int $keyId): void
|
||||||
{
|
{
|
||||||
$key = $this->workspace->apiKeys()->findOrFail($keyId);
|
$key = AgentApiKey::forWorkspace($this->workspace)->findOrFail($keyId);
|
||||||
$key->revoke();
|
$key->revoke();
|
||||||
|
|
||||||
session()->flash('message', 'API key revoked.');
|
session()->flash('message', 'API key revoked.');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function toggleScope(string $scope): void
|
public function togglePermission(string $permission): void
|
||||||
{
|
{
|
||||||
if (in_array($scope, $this->newKeyScopes)) {
|
if (in_array($permission, $this->newKeyPermissions)) {
|
||||||
$this->newKeyScopes = array_values(array_diff($this->newKeyScopes, [$scope]));
|
$this->newKeyPermissions = array_values(array_diff($this->newKeyPermissions, [$permission]));
|
||||||
} else {
|
} else {
|
||||||
$this->newKeyScopes[] = $scope;
|
$this->newKeyPermissions[] = $permission;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
{
|
{
|
||||||
return view('mcp::admin.api-key-manager', [
|
return view('agentic::admin.api-key-manager', [
|
||||||
'keys' => $this->workspace->apiKeys()->orderByDesc('created_at')->get(),
|
'keys' => AgentApiKey::forWorkspace($this->workspace)->orderByDesc('created_at')->get(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ 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;
|
||||||
|
|
@ -13,8 +15,6 @@ 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')]
|
||||||
|
|
|
||||||
|
|
@ -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 Core\Mcp\Models\McpToolCallStat;
|
use Illuminate\Cache\Lock;
|
||||||
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;
|
||||||
|
|
|
||||||
|
|
@ -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')]
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
21
agentic.php
Normal file
21
agentic.php
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
<?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),
|
||||||
|
|
||||||
|
];
|
||||||
|
|
@ -1,50 +1,53 @@
|
||||||
{
|
{
|
||||||
"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",
|
||||||
"orchestra/testbench": "^9.0|^10.0",
|
"livewire/livewire": "^3.0",
|
||||||
"pestphp/pest": "^3.0"
|
"orchestra/testbench": "^9.0|^10.0",
|
||||||
},
|
"pestphp/pest": "^3.0",
|
||||||
"autoload": {
|
"pestphp/pest-plugin-livewire": "^3.0"
|
||||||
"psr-4": {
|
},
|
||||||
"Core\\Mod\\Agentic\\": "",
|
"autoload": {
|
||||||
"Core\\Service\\Agentic\\": "Service/"
|
"psr-4": {
|
||||||
}
|
"Core\\Mod\\Agentic\\": "",
|
||||||
},
|
"Core\\Service\\Agentic\\": "Service/"
|
||||||
"autoload-dev": {
|
}
|
||||||
"psr-4": {
|
},
|
||||||
"Core\\Mod\\Agentic\\Tests\\": "Tests/"
|
"autoload-dev": {
|
||||||
}
|
"psr-4": {
|
||||||
},
|
"Core\\Mod\\Agentic\\Tests\\": "tests/",
|
||||||
"extra": {
|
"Tests\\": "tests/"
|
||||||
"laravel": {
|
}
|
||||||
"providers": [
|
},
|
||||||
"Core\\Mod\\Agentic\\Boot"
|
"extra": {
|
||||||
]
|
"laravel": {
|
||||||
}
|
"providers": [
|
||||||
},
|
"Core\\Mod\\Agentic\\Boot"
|
||||||
"scripts": {
|
]
|
||||||
"lint": "pint",
|
}
|
||||||
"test": "pest"
|
},
|
||||||
},
|
"scripts": {
|
||||||
"config": {
|
"lint": "pint",
|
||||||
"sort-packages": true,
|
"test": "pest"
|
||||||
"allow-plugins": {
|
},
|
||||||
"pestphp/pest-plugin": true
|
"config": {
|
||||||
}
|
"sort-packages": true,
|
||||||
},
|
"allow-plugins": {
|
||||||
"minimum-stability": "dev",
|
"pestphp/pest-plugin": true
|
||||||
"prefer-stable": true
|
}
|
||||||
|
},
|
||||||
|
"minimum-stability": "dev",
|
||||||
|
"prefer-stable": true
|
||||||
}
|
}
|
||||||
|
|
|
||||||
15
config.php
15
config.php
|
|
@ -56,4 +56,19 @@ 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,
|
||||||
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|
|
||||||
39
phpunit.xml
Normal file
39
phpunit.xml
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
<?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>
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
use Illuminate\Support\Facades\Route;
|
|
||||||
|
|
||||||
// API routes are registered via Core modules
|
// API routes are registered via Core modules
|
||||||
|
|
|
||||||
|
|
@ -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,6 +286,78 @@ 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([
|
||||||
|
|
|
||||||
|
|
@ -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->metadata['archived_at']);
|
$this->assertNotNull($fresh->archived_at);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_it_generates_unique_slugs(): void
|
public function test_it_generates_unique_slugs(): void
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
254
tests/Feature/ApiKeyManagerTest.php
Normal file
254
tests/Feature/ApiKeyManagerTest.php
Normal file
|
|
@ -0,0 +1,254 @@
|
||||||
|
<?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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -2,9 +2,21 @@
|
||||||
|
|
||||||
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);
|
||||||
|
|
@ -60,11 +72,15 @@ 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
|
// Clean up potential leftover draft and state files
|
||||||
$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);
|
||||||
|
|
@ -79,5 +95,227 @@ 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
148
tests/Feature/ForAgentsControllerTest.php
Normal file
148
tests/Feature/ForAgentsControllerTest.php
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
<?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');
|
||||||
|
});
|
||||||
|
});
|
||||||
272
tests/Feature/Jobs/BatchContentGenerationTest.php
Normal file
272
tests/Feature/Jobs/BatchContentGenerationTest.php
Normal file
|
|
@ -0,0 +1,272 @@
|
||||||
|
<?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);
|
||||||
|
});
|
||||||
|
});
|
||||||
813
tests/Feature/Jobs/ProcessContentTaskTest.php
Normal file
813
tests/Feature/Jobs/ProcessContentTaskTest.php
Normal file
|
|
@ -0,0 +1,813 @@
|
||||||
|
<?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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
140
tests/Feature/Livewire/ApiKeyManagerTest.php
Normal file
140
tests/Feature/Livewire/ApiKeyManagerTest.php
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
238
tests/Feature/Livewire/ApiKeysTest.php
Normal file
238
tests/Feature/Livewire/ApiKeysTest.php
Normal file
|
|
@ -0,0 +1,238 @@
|
||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
102
tests/Feature/Livewire/DashboardTest.php
Normal file
102
tests/Feature/Livewire/DashboardTest.php
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
50
tests/Feature/Livewire/LivewireTestCase.php
Normal file
50
tests/Feature/Livewire/LivewireTestCase.php
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
229
tests/Feature/Livewire/PlanDetailTest.php
Normal file
229
tests/Feature/Livewire/PlanDetailTest.php
Normal file
|
|
@ -0,0 +1,229 @@
|
||||||
|
<?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));
|
||||||
|
}
|
||||||
|
}
|
||||||
165
tests/Feature/Livewire/PlansTest.php
Normal file
165
tests/Feature/Livewire/PlansTest.php
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
160
tests/Feature/Livewire/PlaygroundTest.php
Normal file
160
tests/Feature/Livewire/PlaygroundTest.php
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
87
tests/Feature/Livewire/RequestLogTest.php
Normal file
87
tests/Feature/Livewire/RequestLogTest.php
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
<?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();
|
||||||
|
}
|
||||||
|
}
|
||||||
167
tests/Feature/Livewire/SessionDetailTest.php
Normal file
167
tests/Feature/Livewire/SessionDetailTest.php
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
202
tests/Feature/Livewire/SessionsTest.php
Normal file
202
tests/Feature/Livewire/SessionsTest.php
Normal file
|
|
@ -0,0 +1,202 @@
|
||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
173
tests/Feature/Livewire/TemplatesTest.php
Normal file
173
tests/Feature/Livewire/TemplatesTest.php
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
<?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', []);
|
||||||
|
}
|
||||||
|
}
|
||||||
119
tests/Feature/Livewire/ToolAnalyticsTest.php
Normal file
119
tests/Feature/Livewire/ToolAnalyticsTest.php
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
148
tests/Feature/Livewire/ToolCallsTest.php
Normal file
148
tests/Feature/Livewire/ToolCallsTest.php
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
<?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
Loading…
Add table
Reference in a new issue