Compare commits
58 commits
feat/phase
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be820fead8 | ||
|
|
5f016c6275 | ||
|
|
ae4188c063 | ||
| b86714db6e | |||
|
|
6cd9ca09d7 | ||
|
|
e47998bc15 | ||
|
|
938081f2f5 | ||
| 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 |
117 changed files with 7726 additions and 516 deletions
68
.forgejo/workflows/ci.yml
Normal file
68
.forgejo/workflows/ci.yml
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
name: PHP ${{ matrix.php }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
fail-fast: true
|
||||||
|
matrix:
|
||||||
|
php: ["8.3", "8.4"]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup PHP
|
||||||
|
uses: https://github.com/shivammathur/setup-php@v2
|
||||||
|
with:
|
||||||
|
php-version: ${{ matrix.php }}
|
||||||
|
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite
|
||||||
|
coverage: pcov
|
||||||
|
|
||||||
|
- 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
|
||||||
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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\Mcp\Tools\Agent\AgentTool;
|
||||||
use Core\Mod\Agentic\Models\AgentPlan;
|
use Core\Mod\Agentic\Models\AgentPlan;
|
||||||
use Core\Mod\Agentic\Models\AgentWorkspaceState;
|
use Core\Mod\Agentic\Models\AgentWorkspaceState;
|
||||||
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set a workspace state value.
|
* Set a workspace state value.
|
||||||
|
|
@ -81,7 +81,7 @@ class StateSet extends AgentTool
|
||||||
// Validate workspace context for tenant isolation
|
// Validate workspace context for tenant isolation
|
||||||
$workspaceId = $context['workspace_id'] ?? null;
|
$workspaceId = $context['workspace_id'] ?? null;
|
||||||
if ($workspaceId === null) {
|
if ($workspaceId === null) {
|
||||||
return $this->error('workspace_id is required 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
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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,15 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace Core\Mod\Agentic\Models;
|
namespace Core\Mod\Agentic\Models;
|
||||||
|
|
||||||
|
use Core\Mod\Agentic\Database\Factories\AgentPlanFactory;
|
||||||
|
use Core\Tenant\Concerns\BelongsToWorkspace;
|
||||||
|
use Core\Tenant\Models\Workspace;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Core\Mod\Agentic\Database\Factories\AgentPlanFactory;
|
|
||||||
use Core\Tenant\Concerns\BelongsToWorkspace;
|
|
||||||
use Core\Tenant\Models\Workspace;
|
|
||||||
use Spatie\Activitylog\LogOptions;
|
use Spatie\Activitylog\LogOptions;
|
||||||
use Spatie\Activitylog\Traits\LogsActivity;
|
use Spatie\Activitylog\Traits\LogsActivity;
|
||||||
|
|
||||||
|
|
@ -99,17 +100,17 @@ class AgentPlan 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 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 +121,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 +129,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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
|
@ -54,14 +55,14 @@ class AgentWorkspaceState extends Model
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scopes
|
// Scopes
|
||||||
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;
|
||||||
|
|
||||||
return $query->where('agent_plan_id', $planId);
|
return $query->where('agent_plan_id', $planId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function scopeOfType($query, string $type)
|
public function scopeOfType(Builder $query, string $type): Builder
|
||||||
{
|
{
|
||||||
return $query->where('type', $type);
|
return $query->where('type', $type);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -132,7 +133,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,7 +141,7 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
40
TODO.md
40
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,15 +116,15 @@ 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**
|
- [ ] **DX-003: Plan template variable errors not actionable**
|
||||||
- Location: `Services/PlanTemplateService.php::validateVariables()`
|
- Location: `Services/PlanTemplateService.php::validateVariables()`
|
||||||
|
|
@ -136,15 +137,19 @@ Production-quality task list for the AI agent orchestration package.
|
||||||
- Issue: Two similar models for same purpose
|
- Issue: Two similar models for same purpose
|
||||||
- Fix: Consolidate into single model, or clarify distinct purposes
|
- Fix: Consolidate into single model, or clarify distinct purposes
|
||||||
|
|
||||||
- [ ] **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 +169,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
|
||||||
|
|
@ -283,6 +288,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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -794,7 +794,7 @@ describe('edge cases', function () {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles malformed YAML gracefully', function () {
|
it('handles malformed YAML gracefully', function () {
|
||||||
File::put($this->testTemplatesPath.'/malformed.yaml', "invalid: yaml: content: [");
|
File::put($this->testTemplatesPath.'/malformed.yaml', 'invalid: yaml: content: [');
|
||||||
|
|
||||||
// Should not throw when listing
|
// Should not throw when listing
|
||||||
$result = $this->service->list();
|
$result = $this->service->list();
|
||||||
|
|
|
||||||
279
tests/Feature/PromptVersionTest.php
Normal file
279
tests/Feature/PromptVersionTest.php
Normal file
|
|
@ -0,0 +1,279 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Core\Mod\Agentic\Models\Prompt;
|
||||||
|
use Core\Mod\Agentic\Models\PromptVersion;
|
||||||
|
use Core\Tenant\Models\User;
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Version Creation Tests
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('version creation', function () {
|
||||||
|
it('can be created with required attributes', function () {
|
||||||
|
$prompt = Prompt::create([
|
||||||
|
'name' => 'Test Prompt',
|
||||||
|
'system_prompt' => 'You are a helpful assistant.',
|
||||||
|
'user_template' => 'Answer this: {{{question}}}',
|
||||||
|
'variables' => ['question'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$version = PromptVersion::create([
|
||||||
|
'prompt_id' => $prompt->id,
|
||||||
|
'version' => 1,
|
||||||
|
'system_prompt' => 'You are a helpful assistant.',
|
||||||
|
'user_template' => 'Answer this: {{{question}}}',
|
||||||
|
'variables' => ['question'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($version->id)->not->toBeNull()
|
||||||
|
->and($version->version)->toBe(1)
|
||||||
|
->and($version->prompt_id)->toBe($prompt->id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('casts variables as array', function () {
|
||||||
|
$prompt = Prompt::create(['name' => 'Test Prompt']);
|
||||||
|
|
||||||
|
$version = PromptVersion::create([
|
||||||
|
'prompt_id' => $prompt->id,
|
||||||
|
'version' => 1,
|
||||||
|
'variables' => ['topic', 'tone'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($version->variables)
|
||||||
|
->toBeArray()
|
||||||
|
->toBe(['topic', 'tone']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('casts version as integer', function () {
|
||||||
|
$prompt = Prompt::create(['name' => 'Test Prompt']);
|
||||||
|
|
||||||
|
$version = PromptVersion::create([
|
||||||
|
'prompt_id' => $prompt->id,
|
||||||
|
'version' => 3,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($version->version)->toBeInt()->toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can be created without optional fields', function () {
|
||||||
|
$prompt = Prompt::create(['name' => 'Minimal Prompt']);
|
||||||
|
|
||||||
|
$version = PromptVersion::create([
|
||||||
|
'prompt_id' => $prompt->id,
|
||||||
|
'version' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($version->id)->not->toBeNull()
|
||||||
|
->and($version->system_prompt)->toBeNull()
|
||||||
|
->and($version->user_template)->toBeNull()
|
||||||
|
->and($version->created_by)->toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Relationship Tests
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('relationships', function () {
|
||||||
|
it('belongs to a prompt', function () {
|
||||||
|
$prompt = Prompt::create([
|
||||||
|
'name' => 'Parent Prompt',
|
||||||
|
'system_prompt' => 'System text.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$version = PromptVersion::create([
|
||||||
|
'prompt_id' => $prompt->id,
|
||||||
|
'version' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($version->prompt)->toBeInstanceOf(Prompt::class)
|
||||||
|
->and($version->prompt->id)->toBe($prompt->id)
|
||||||
|
->and($version->prompt->name)->toBe('Parent Prompt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('belongs to a creator user', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$prompt = Prompt::create(['name' => 'Test Prompt']);
|
||||||
|
|
||||||
|
$version = PromptVersion::create([
|
||||||
|
'prompt_id' => $prompt->id,
|
||||||
|
'version' => 1,
|
||||||
|
'created_by' => $user->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($version->creator)->toBeInstanceOf(User::class)
|
||||||
|
->and($version->creator->id)->toBe($user->id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has null creator when created_by is null', function () {
|
||||||
|
$prompt = Prompt::create(['name' => 'Test Prompt']);
|
||||||
|
|
||||||
|
$version = PromptVersion::create([
|
||||||
|
'prompt_id' => $prompt->id,
|
||||||
|
'version' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($version->creator)->toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Restore Method Tests
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('restore', function () {
|
||||||
|
it('restores system_prompt and user_template to the parent prompt', function () {
|
||||||
|
$prompt = Prompt::create([
|
||||||
|
'name' => 'Test Prompt',
|
||||||
|
'system_prompt' => 'Original system prompt.',
|
||||||
|
'user_template' => 'Original template.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$version = PromptVersion::create([
|
||||||
|
'prompt_id' => $prompt->id,
|
||||||
|
'version' => 1,
|
||||||
|
'system_prompt' => 'Versioned system prompt.',
|
||||||
|
'user_template' => 'Versioned template.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$prompt->update([
|
||||||
|
'system_prompt' => 'Newer system prompt.',
|
||||||
|
'user_template' => 'Newer template.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$version->restore();
|
||||||
|
|
||||||
|
$fresh = $prompt->fresh();
|
||||||
|
expect($fresh->system_prompt)->toBe('Versioned system prompt.')
|
||||||
|
->and($fresh->user_template)->toBe('Versioned template.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores variables to the parent prompt', function () {
|
||||||
|
$prompt = Prompt::create([
|
||||||
|
'name' => 'Test Prompt',
|
||||||
|
'variables' => ['topic'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$version = PromptVersion::create([
|
||||||
|
'prompt_id' => $prompt->id,
|
||||||
|
'version' => 1,
|
||||||
|
'variables' => ['topic', 'tone'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$prompt->update(['variables' => ['topic', 'tone', 'length']]);
|
||||||
|
|
||||||
|
$version->restore();
|
||||||
|
|
||||||
|
expect($prompt->fresh()->variables)->toBe(['topic', 'tone']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the parent prompt instance after restore', function () {
|
||||||
|
$prompt = Prompt::create([
|
||||||
|
'name' => 'Test Prompt',
|
||||||
|
'system_prompt' => 'Old.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$version = PromptVersion::create([
|
||||||
|
'prompt_id' => $prompt->id,
|
||||||
|
'version' => 1,
|
||||||
|
'system_prompt' => 'Versioned.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$result = $version->restore();
|
||||||
|
|
||||||
|
expect($result)->toBeInstanceOf(Prompt::class)
|
||||||
|
->and($result->id)->toBe($prompt->id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Version History Tests
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('version history', function () {
|
||||||
|
it('prompt tracks multiple versions in descending order', function () {
|
||||||
|
$prompt = Prompt::create([
|
||||||
|
'name' => 'Evolving Prompt',
|
||||||
|
'system_prompt' => 'v1.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
PromptVersion::create(['prompt_id' => $prompt->id, 'version' => 1, 'system_prompt' => 'v1.']);
|
||||||
|
PromptVersion::create(['prompt_id' => $prompt->id, 'version' => 2, 'system_prompt' => 'v2.']);
|
||||||
|
PromptVersion::create(['prompt_id' => $prompt->id, 'version' => 3, 'system_prompt' => 'v3.']);
|
||||||
|
|
||||||
|
$versions = $prompt->versions()->get();
|
||||||
|
|
||||||
|
expect($versions)->toHaveCount(3)
|
||||||
|
->and($versions->first()->version)->toBe(3)
|
||||||
|
->and($versions->last()->version)->toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('createVersion snapshots current prompt state', function () {
|
||||||
|
$prompt = Prompt::create([
|
||||||
|
'name' => 'Test Prompt',
|
||||||
|
'system_prompt' => 'Original system prompt.',
|
||||||
|
'user_template' => 'Original template.',
|
||||||
|
'variables' => ['topic'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$version = $prompt->createVersion();
|
||||||
|
|
||||||
|
expect($version)->toBeInstanceOf(PromptVersion::class)
|
||||||
|
->and($version->version)->toBe(1)
|
||||||
|
->and($version->system_prompt)->toBe('Original system prompt.')
|
||||||
|
->and($version->user_template)->toBe('Original template.')
|
||||||
|
->and($version->variables)->toBe(['topic']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('createVersion increments version number', function () {
|
||||||
|
$prompt = Prompt::create([
|
||||||
|
'name' => 'Test Prompt',
|
||||||
|
'system_prompt' => 'v1.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$v1 = $prompt->createVersion();
|
||||||
|
$prompt->update(['system_prompt' => 'v2.']);
|
||||||
|
$v2 = $prompt->createVersion();
|
||||||
|
|
||||||
|
expect($v1->version)->toBe(1)
|
||||||
|
->and($v2->version)->toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('createVersion records the creator user id', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$prompt = Prompt::create([
|
||||||
|
'name' => 'Test Prompt',
|
||||||
|
'system_prompt' => 'System text.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$version = $prompt->createVersion($user->id);
|
||||||
|
|
||||||
|
expect($version->created_by)->toBe($user->id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('versions are scoped to their parent prompt', function () {
|
||||||
|
$promptA = Prompt::create(['name' => 'Prompt A']);
|
||||||
|
$promptB = Prompt::create(['name' => 'Prompt B']);
|
||||||
|
|
||||||
|
PromptVersion::create(['prompt_id' => $promptA->id, 'version' => 1]);
|
||||||
|
PromptVersion::create(['prompt_id' => $promptA->id, 'version' => 2]);
|
||||||
|
PromptVersion::create(['prompt_id' => $promptB->id, 'version' => 1]);
|
||||||
|
|
||||||
|
expect($promptA->versions()->count())->toBe(2)
|
||||||
|
->and($promptB->versions()->count())->toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deleting prompt cascades to versions', function () {
|
||||||
|
$prompt = Prompt::create(['name' => 'Test Prompt']);
|
||||||
|
|
||||||
|
PromptVersion::create(['prompt_id' => $prompt->id, 'version' => 1]);
|
||||||
|
PromptVersion::create(['prompt_id' => $prompt->id, 'version' => 2]);
|
||||||
|
|
||||||
|
$promptId = $prompt->id;
|
||||||
|
$prompt->delete();
|
||||||
|
|
||||||
|
expect(PromptVersion::where('prompt_id', $promptId)->count())->toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -4,16 +4,16 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace Core\Mod\Agentic\Tests\Feature;
|
namespace Core\Mod\Agentic\Tests\Feature;
|
||||||
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
use Core\Mod\Agentic\Models\AgentPlan;
|
|
||||||
use Core\Mod\Agentic\Models\AgentWorkspaceState;
|
|
||||||
use Core\Mod\Agentic\Models\Task;
|
|
||||||
use Core\Mod\Agentic\Mcp\Tools\Agent\Plan\PlanGet;
|
use Core\Mod\Agentic\Mcp\Tools\Agent\Plan\PlanGet;
|
||||||
use Core\Mod\Agentic\Mcp\Tools\Agent\Plan\PlanList;
|
use Core\Mod\Agentic\Mcp\Tools\Agent\Plan\PlanList;
|
||||||
use Core\Mod\Agentic\Mcp\Tools\Agent\State\StateGet;
|
use Core\Mod\Agentic\Mcp\Tools\Agent\State\StateGet;
|
||||||
use Core\Mod\Agentic\Mcp\Tools\Agent\State\StateList;
|
use Core\Mod\Agentic\Mcp\Tools\Agent\State\StateList;
|
||||||
use Core\Mod\Agentic\Mcp\Tools\Agent\State\StateSet;
|
use Core\Mod\Agentic\Mcp\Tools\Agent\State\StateSet;
|
||||||
|
use Core\Mod\Agentic\Models\AgentPlan;
|
||||||
|
use Core\Mod\Agentic\Models\AgentWorkspaceState;
|
||||||
|
use Core\Mod\Agentic\Models\Task;
|
||||||
use Core\Tenant\Models\Workspace;
|
use Core\Tenant\Models\Workspace;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -24,6 +24,7 @@ class SecurityTest extends TestCase
|
||||||
use RefreshDatabase;
|
use RefreshDatabase;
|
||||||
|
|
||||||
private Workspace $workspace;
|
private Workspace $workspace;
|
||||||
|
|
||||||
private Workspace $otherWorkspace;
|
private Workspace $otherWorkspace;
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
|
|
@ -43,7 +44,7 @@ class SecurityTest extends TestCase
|
||||||
'workspace_id' => $this->workspace->id,
|
'workspace_id' => $this->workspace->id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$tool = new StateSet();
|
$tool = new StateSet;
|
||||||
$result = $tool->handle([
|
$result = $tool->handle([
|
||||||
'plan_slug' => $plan->slug,
|
'plan_slug' => $plan->slug,
|
||||||
'key' => 'test_key',
|
'key' => 'test_key',
|
||||||
|
|
@ -60,7 +61,7 @@ class SecurityTest extends TestCase
|
||||||
'workspace_id' => $this->otherWorkspace->id,
|
'workspace_id' => $this->otherWorkspace->id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$tool = new StateSet();
|
$tool = new StateSet;
|
||||||
$result = $tool->handle([
|
$result = $tool->handle([
|
||||||
'plan_slug' => $otherPlan->slug,
|
'plan_slug' => $otherPlan->slug,
|
||||||
'key' => 'test_key',
|
'key' => 'test_key',
|
||||||
|
|
@ -77,7 +78,7 @@ class SecurityTest extends TestCase
|
||||||
'workspace_id' => $this->workspace->id,
|
'workspace_id' => $this->workspace->id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$tool = new StateSet();
|
$tool = new StateSet;
|
||||||
$result = $tool->handle([
|
$result = $tool->handle([
|
||||||
'plan_slug' => $plan->slug,
|
'plan_slug' => $plan->slug,
|
||||||
'key' => 'test_key',
|
'key' => 'test_key',
|
||||||
|
|
@ -105,7 +106,7 @@ class SecurityTest extends TestCase
|
||||||
'value' => ['data' => 'secret'],
|
'value' => ['data' => 'secret'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$tool = new StateGet();
|
$tool = new StateGet;
|
||||||
$result = $tool->handle([
|
$result = $tool->handle([
|
||||||
'plan_slug' => $plan->slug,
|
'plan_slug' => $plan->slug,
|
||||||
'key' => 'test_key',
|
'key' => 'test_key',
|
||||||
|
|
@ -127,7 +128,7 @@ class SecurityTest extends TestCase
|
||||||
'value' => ['data' => 'sensitive'],
|
'value' => ['data' => 'sensitive'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$tool = new StateGet();
|
$tool = new StateGet;
|
||||||
$result = $tool->handle([
|
$result = $tool->handle([
|
||||||
'plan_slug' => $otherPlan->slug,
|
'plan_slug' => $otherPlan->slug,
|
||||||
'key' => 'secret_key',
|
'key' => 'secret_key',
|
||||||
|
|
@ -149,7 +150,7 @@ class SecurityTest extends TestCase
|
||||||
'value' => ['data' => 'allowed'],
|
'value' => ['data' => 'allowed'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$tool = new StateGet();
|
$tool = new StateGet;
|
||||||
$result = $tool->handle([
|
$result = $tool->handle([
|
||||||
'plan_slug' => $plan->slug,
|
'plan_slug' => $plan->slug,
|
||||||
'key' => 'test_key',
|
'key' => 'test_key',
|
||||||
|
|
@ -170,7 +171,7 @@ class SecurityTest extends TestCase
|
||||||
'workspace_id' => $this->workspace->id,
|
'workspace_id' => $this->workspace->id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$tool = new StateList();
|
$tool = new StateList;
|
||||||
$result = $tool->handle([
|
$result = $tool->handle([
|
||||||
'plan_slug' => $plan->slug,
|
'plan_slug' => $plan->slug,
|
||||||
], []); // No workspace_id in context
|
], []); // No workspace_id in context
|
||||||
|
|
@ -191,7 +192,7 @@ class SecurityTest extends TestCase
|
||||||
'value' => ['data' => 'sensitive'],
|
'value' => ['data' => 'sensitive'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$tool = new StateList();
|
$tool = new StateList;
|
||||||
$result = $tool->handle([
|
$result = $tool->handle([
|
||||||
'plan_slug' => $otherPlan->slug,
|
'plan_slug' => $otherPlan->slug,
|
||||||
], ['workspace_id' => $this->workspace->id]); // Different workspace
|
], ['workspace_id' => $this->workspace->id]); // Different workspace
|
||||||
|
|
@ -210,7 +211,7 @@ class SecurityTest extends TestCase
|
||||||
'workspace_id' => $this->workspace->id,
|
'workspace_id' => $this->workspace->id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$tool = new PlanGet();
|
$tool = new PlanGet;
|
||||||
$result = $tool->handle([
|
$result = $tool->handle([
|
||||||
'slug' => $plan->slug,
|
'slug' => $plan->slug,
|
||||||
], []); // No workspace_id in context
|
], []); // No workspace_id in context
|
||||||
|
|
@ -226,7 +227,7 @@ class SecurityTest extends TestCase
|
||||||
'title' => 'Secret Plan',
|
'title' => 'Secret Plan',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$tool = new PlanGet();
|
$tool = new PlanGet;
|
||||||
$result = $tool->handle([
|
$result = $tool->handle([
|
||||||
'slug' => $otherPlan->slug,
|
'slug' => $otherPlan->slug,
|
||||||
], ['workspace_id' => $this->workspace->id]); // Different workspace
|
], ['workspace_id' => $this->workspace->id]); // Different workspace
|
||||||
|
|
@ -242,7 +243,7 @@ class SecurityTest extends TestCase
|
||||||
'title' => 'My Plan',
|
'title' => 'My Plan',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$tool = new PlanGet();
|
$tool = new PlanGet;
|
||||||
$result = $tool->handle([
|
$result = $tool->handle([
|
||||||
'slug' => $plan->slug,
|
'slug' => $plan->slug,
|
||||||
], ['workspace_id' => $this->workspace->id]);
|
], ['workspace_id' => $this->workspace->id]);
|
||||||
|
|
@ -258,7 +259,7 @@ class SecurityTest extends TestCase
|
||||||
|
|
||||||
public function test_plan_list_requires_workspace_context(): void
|
public function test_plan_list_requires_workspace_context(): void
|
||||||
{
|
{
|
||||||
$tool = new PlanList();
|
$tool = new PlanList;
|
||||||
$result = $tool->handle([], []); // No workspace_id in context
|
$result = $tool->handle([], []); // No workspace_id in context
|
||||||
|
|
||||||
$this->assertArrayHasKey('error', $result);
|
$this->assertArrayHasKey('error', $result);
|
||||||
|
|
@ -277,7 +278,7 @@ class SecurityTest extends TestCase
|
||||||
'title' => 'Other Plan',
|
'title' => 'Other Plan',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$tool = new PlanList();
|
$tool = new PlanList;
|
||||||
$result = $tool->handle([], ['workspace_id' => $this->workspace->id]);
|
$result = $tool->handle([], ['workspace_id' => $this->workspace->id]);
|
||||||
|
|
||||||
$this->assertArrayHasKey('success', $result);
|
$this->assertArrayHasKey('success', $result);
|
||||||
|
|
@ -387,7 +388,7 @@ class SecurityTest extends TestCase
|
||||||
|
|
||||||
public function test_state_set_has_workspace_dependency(): void
|
public function test_state_set_has_workspace_dependency(): void
|
||||||
{
|
{
|
||||||
$tool = new StateSet();
|
$tool = new StateSet;
|
||||||
$dependencies = $tool->dependencies();
|
$dependencies = $tool->dependencies();
|
||||||
|
|
||||||
$this->assertNotEmpty($dependencies);
|
$this->assertNotEmpty($dependencies);
|
||||||
|
|
@ -396,7 +397,7 @@ class SecurityTest extends TestCase
|
||||||
|
|
||||||
public function test_state_get_has_workspace_dependency(): void
|
public function test_state_get_has_workspace_dependency(): void
|
||||||
{
|
{
|
||||||
$tool = new StateGet();
|
$tool = new StateGet;
|
||||||
$dependencies = $tool->dependencies();
|
$dependencies = $tool->dependencies();
|
||||||
|
|
||||||
$this->assertNotEmpty($dependencies);
|
$this->assertNotEmpty($dependencies);
|
||||||
|
|
@ -405,7 +406,7 @@ class SecurityTest extends TestCase
|
||||||
|
|
||||||
public function test_state_list_has_workspace_dependency(): void
|
public function test_state_list_has_workspace_dependency(): void
|
||||||
{
|
{
|
||||||
$tool = new StateList();
|
$tool = new StateList;
|
||||||
$dependencies = $tool->dependencies();
|
$dependencies = $tool->dependencies();
|
||||||
|
|
||||||
$this->assertNotEmpty($dependencies);
|
$this->assertNotEmpty($dependencies);
|
||||||
|
|
@ -414,7 +415,7 @@ class SecurityTest extends TestCase
|
||||||
|
|
||||||
public function test_plan_get_has_workspace_dependency(): void
|
public function test_plan_get_has_workspace_dependency(): void
|
||||||
{
|
{
|
||||||
$tool = new PlanGet();
|
$tool = new PlanGet;
|
||||||
$dependencies = $tool->dependencies();
|
$dependencies = $tool->dependencies();
|
||||||
|
|
||||||
$this->assertNotEmpty($dependencies);
|
$this->assertNotEmpty($dependencies);
|
||||||
|
|
@ -423,7 +424,7 @@ class SecurityTest extends TestCase
|
||||||
|
|
||||||
public function test_plan_list_has_workspace_dependency(): void
|
public function test_plan_list_has_workspace_dependency(): void
|
||||||
{
|
{
|
||||||
$tool = new PlanList();
|
$tool = new PlanList;
|
||||||
$dependencies = $tool->dependencies();
|
$dependencies = $tool->dependencies();
|
||||||
|
|
||||||
$this->assertNotEmpty($dependencies);
|
$this->assertNotEmpty($dependencies);
|
||||||
|
|
|
||||||
36
tests/Fixtures/HadesUser.php
Normal file
36
tests/Fixtures/HadesUser.php
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Fixtures;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fake user fixture for Livewire component tests.
|
||||||
|
*
|
||||||
|
* Satisfies the isHades() check used by all admin components.
|
||||||
|
*/
|
||||||
|
class HadesUser extends Authenticatable
|
||||||
|
{
|
||||||
|
protected $fillable = ['id', 'name', 'email'];
|
||||||
|
|
||||||
|
protected $table = 'users';
|
||||||
|
|
||||||
|
public $timestamps = false;
|
||||||
|
|
||||||
|
public function isHades(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function defaultHostWorkspace(): ?object
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAuthIdentifier(): mixed
|
||||||
|
{
|
||||||
|
return $this->attributes['id'] ?? 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,17 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Tests;
|
namespace Tests;
|
||||||
|
|
||||||
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
|
use Orchestra\Testbench\TestCase as BaseTestCase;
|
||||||
|
|
||||||
abstract class TestCase extends BaseTestCase
|
abstract class TestCase extends BaseTestCase
|
||||||
{
|
{
|
||||||
//
|
protected function getPackageProviders($app): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
\Core\Mod\Agentic\Boot::class,
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
785
tests/Unit/AgentDetectionTest.php
Normal file
785
tests/Unit/AgentDetectionTest.php
Normal file
|
|
@ -0,0 +1,785 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for the AgentDetection service.
|
||||||
|
*
|
||||||
|
* Covers User-Agent pattern matching for known AI providers, browser and
|
||||||
|
* non-agent bot detection, MCP token identification, and edge cases.
|
||||||
|
* Documents the UA patterns used to identify each agent type.
|
||||||
|
*
|
||||||
|
* Resolves: #13
|
||||||
|
*/
|
||||||
|
|
||||||
|
use Core\Mod\Agentic\Services\AgentDetection;
|
||||||
|
use Core\Mod\Agentic\Support\AgentIdentity;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Edge Cases
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('edge cases', function () {
|
||||||
|
it('returns unknownAgent for null User-Agent', function () {
|
||||||
|
$service = new AgentDetection;
|
||||||
|
$identity = $service->identifyFromUserAgent(null);
|
||||||
|
|
||||||
|
expect($identity->provider)->toBe('unknown')
|
||||||
|
->and($identity->isAgent())->toBeTrue()
|
||||||
|
->and($identity->isKnown())->toBeFalse()
|
||||||
|
->and($identity->confidence)->toBe(AgentIdentity::CONFIDENCE_LOW);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns unknownAgent for empty string User-Agent', function () {
|
||||||
|
$service = new AgentDetection;
|
||||||
|
$identity = $service->identifyFromUserAgent('');
|
||||||
|
|
||||||
|
expect($identity->provider)->toBe('unknown')
|
||||||
|
->and($identity->isAgent())->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns unknownAgent for whitespace-only User-Agent', function () {
|
||||||
|
$service = new AgentDetection;
|
||||||
|
$identity = $service->identifyFromUserAgent(' ');
|
||||||
|
|
||||||
|
expect($identity->provider)->toBe('unknown')
|
||||||
|
->and($identity->isAgent())->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns unknownAgent for generic programmatic client with no known indicators', function () {
|
||||||
|
$service = new AgentDetection;
|
||||||
|
// A plain HTTP client string without browser or bot keywords
|
||||||
|
$identity = $service->identifyFromUserAgent('my-custom-client/1.0');
|
||||||
|
|
||||||
|
expect($identity->provider)->toBe('unknown')
|
||||||
|
->and($identity->isAgent())->toBeTrue()
|
||||||
|
->and($identity->isKnown())->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns unknownAgent for numeric-only User-Agent', function () {
|
||||||
|
$service = new AgentDetection;
|
||||||
|
$identity = $service->identifyFromUserAgent('1.0');
|
||||||
|
|
||||||
|
expect($identity->provider)->toBe('unknown');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Anthropic / Claude Detection
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('Anthropic/Claude detection', function () {
|
||||||
|
/**
|
||||||
|
* Pattern: /claude[\s\-_]?code/i
|
||||||
|
* Examples: "claude-code/1.2.3", "ClaudeCode/1.0", "claude_code"
|
||||||
|
*/
|
||||||
|
it('detects Claude Code User-Agent', function () {
|
||||||
|
$service = new AgentDetection;
|
||||||
|
$identity = $service->identifyFromUserAgent('claude-code/1.2.3');
|
||||||
|
|
||||||
|
expect($identity->provider)->toBe('anthropic')
|
||||||
|
->and($identity->isKnown())->toBeTrue()
|
||||||
|
->and($identity->confidence)->toBe(AgentIdentity::CONFIDENCE_HIGH);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pattern: /\banthropic[\s\-_]?api\b/i
|
||||||
|
* Examples: "anthropic-api/1.0", "Anthropic API Client/2.0"
|
||||||
|
*/
|
||||||
|
it('detects Anthropic API User-Agent', function () {
|
||||||
|
$service = new AgentDetection;
|
||||||
|
$identity = $service->identifyFromUserAgent('anthropic-api/1.0 Python/3.11');
|
||||||
|
|
||||||
|
expect($identity->provider)->toBe('anthropic')
|
||||||
|
->and($identity->confidence)->toBe(AgentIdentity::CONFIDENCE_HIGH);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pattern: /\bclaude\b.*\bai\b/i
|
||||||
|
* Examples: "Claude AI/2.0", "claude ai client"
|
||||||
|
*/
|
||||||
|
it('detects Claude AI User-Agent', function () {
|
||||||
|
$service = new AgentDetection;
|
||||||
|
$identity = $service->identifyFromUserAgent('Claude AI Agent/1.0');
|
||||||
|
|
||||||
|
expect($identity->provider)->toBe('anthropic');
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pattern: /\bclaude\b.*\bassistant\b/i
|
||||||
|
* Examples: "claude assistant/1.0", "Claude Assistant integration"
|
||||||
|
*/
|
||||||
|
it('detects Claude Assistant User-Agent', function () {
|
||||||
|
$service = new AgentDetection;
|
||||||
|
$identity = $service->identifyFromUserAgent('claude assistant integration/2.0');
|
||||||
|
|
||||||
|
expect($identity->provider)->toBe('anthropic');
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Model pattern: /claude[\s\-_]?opus/i
|
||||||
|
* Examples: "claude-opus", "Claude Opus", "claude_opus"
|
||||||
|
*/
|
||||||
|
it('detects claude-opus model from User-Agent', function () {
|
||||||
|
$service = new AgentDetection;
|
||||||
|
$identity = $service->identifyFromUserAgent('claude-opus claude-code/1.0');
|
||||||
|
|
||||||
|
expect($identity->provider)->toBe('anthropic')
|
||||||
|
->and($identity->model)->toBe('claude-opus');
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Model pattern: /claude[\s\-_]?sonnet/i
|
||||||
|
* Examples: "claude-sonnet", "Claude Sonnet", "claude_sonnet"
|
||||||
|
*/
|
||||||
|
it('detects claude-sonnet model from User-Agent', function () {
|
||||||
|
$service = new AgentDetection;
|
||||||
|
$identity = $service->identifyFromUserAgent('claude-sonnet claude-code/1.0');
|
||||||
|
|
||||||
|
expect($identity->provider)->toBe('anthropic')
|
||||||
|
->and($identity->model)->toBe('claude-sonnet');
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Model pattern: /claude[\s\-_]?haiku/i
|
||||||
|
* Examples: "claude-haiku", "Claude Haiku", "claude_haiku"
|
||||||
|
*/
|
||||||
|
it('detects claude-haiku model from User-Agent', function () {
|
||||||
|
$service = new AgentDetection;
|
||||||
|
$identity = $service->identifyFromUserAgent('Claude Haiku claude-code/1.0');
|
||||||
|
|
||||||
|
expect($identity->provider)->toBe('anthropic')
|
||||||
|
->and($identity->model)->toBe('claude-haiku');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null model when no Anthropic model pattern matches', function () {
|
||||||
|
$service = new AgentDetection;
|
||||||
|
$identity = $service->identifyFromUserAgent('claude-code/1.0');
|
||||||
|
|
||||||
|
expect($identity->provider)->toBe('anthropic')
|
||||||
|
->and($identity->model)->toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// OpenAI / ChatGPT Detection
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('OpenAI/ChatGPT detection', function () {
|
||||||
|
/**
|
||||||
|
* Pattern: /\bChatGPT\b/i
|
||||||
|
* Examples: "ChatGPT/1.2", "chatgpt-plugin/1.0"
|
||||||
|
*/
|
||||||
|
it('detects ChatGPT User-Agent', function () {
|
||||||
|
$service = new AgentDetection;
|
||||||
|
$identity = $service->identifyFromUserAgent('ChatGPT/1.2 OpenAI');
|
||||||
|
|
||||||
|
expect($identity->provider)->toBe('openai')
|
||||||
|
->and($identity->confidence)->toBe(AgentIdentity::CONFIDENCE_HIGH);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pattern: /\bOpenAI\b/i
|
||||||
|
* Examples: "OpenAI Python SDK/1.0", "openai-node/4.0"
|
||||||
|
*/
|
||||||
|
it('detects OpenAI User-Agent', function () {
|
||||||
|
$service = new AgentDetection;
|
||||||
|
$identity = $service->identifyFromUserAgent('OpenAI Python SDK/1.0');
|
||||||
|
|
||||||
|
expect($identity->provider)->toBe('openai');
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pattern: /\bGPT[\s\-_]?4\b/i
|
||||||
|
* Model pattern: /\bGPT[\s\-_]?4/i
|
||||||
|
* Examples: "GPT-4 Agent/1.0", "GPT4 client", "GPT 4"
|
||||||
|
*/
|
||||||
|
it('detects GPT-4 and sets gpt-4 model', function () {
|
||||||
|
$service = new AgentDetection;
|
||||||
|
$identity = $service->identifyFromUserAgent('GPT-4 Agent/1.0');
|
||||||
|
|
||||||
|
expect($identity->provider)->toBe('openai')
|
||||||
|
->and($identity->model)->toBe('gpt-4');
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pattern: /\bGPT[\s\-_]?3\.?5\b/i
|
||||||
|
* Model pattern: /\bGPT[\s\-_]?3\.?5/i
|
||||||
|
* Examples: "GPT-3.5 Turbo", "GPT35 client", "GPT 3.5"
|
||||||
|
*/
|
||||||
|
it('detects GPT-3.5 and sets gpt-3.5 model', function () {
|
||||||
|
$service = new AgentDetection;
|
||||||
|
$identity = $service->identifyFromUserAgent('GPT-3.5 Turbo client/1.0');
|
||||||
|
|
||||||
|
expect($identity->provider)->toBe('openai')
|
||||||
|
->and($identity->model)->toBe('gpt-3.5');
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pattern: /\bo1[\s\-_]?preview\b/i
|
||||||
|
* Examples: "o1-preview OpenAI client/1.0"
|
||||||
|
*/
|
||||||
|
it('detects o1-preview User-Agent', function () {
|
||||||
|
$service = new AgentDetection;
|
||||||
|
$identity = $service->identifyFromUserAgent('o1-preview OpenAI client/1.0');
|
||||||
|
|
||||||
|
expect($identity->provider)->toBe('openai');
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pattern: /\bo1[\s\-_]?mini\b/i
|
||||||
|
* Examples: "o1-mini OpenAI client/1.0"
|
||||||
|
*/
|
||||||
|
it('detects o1-mini User-Agent', function () {
|
||||||
|
$service = new AgentDetection;
|
||||||
|
$identity = $service->identifyFromUserAgent('o1-mini OpenAI client/1.0');
|
||||||
|
|
||||||
|
expect($identity->provider)->toBe('openai');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Google / Gemini Detection
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('Google/Gemini detection', function () {
|
||||||
|
/**
|
||||||
|
* Pattern: /\bGoogle[\s\-_]?AI\b/i
|
||||||
|
* Examples: "Google AI Studio/1.0", "GoogleAI/2.0"
|
||||||
|
*/
|
||||||
|
it('detects Google AI User-Agent', function () {
|
||||||
|
$service = new AgentDetection;
|
||||||
|
$identity = $service->identifyFromUserAgent('Google AI Studio/1.0');
|
||||||
|
|
||||||
|
expect($identity->provider)->toBe('google')
|
||||||
|
->and($identity->confidence)->toBe(AgentIdentity::CONFIDENCE_HIGH);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pattern: /\bGemini\b/i
|
||||||
|
* Examples: "Gemini API Client/2.0", "gemini-client/1.0"
|
||||||
|
*/
|
||||||
|
it('detects Gemini User-Agent', function () {
|
||||||
|
$service = new AgentDetection;
|
||||||
|
$identity = $service->identifyFromUserAgent('Gemini API Client/2.0');
|
||||||
|
|
||||||
|
expect($identity->provider)->toBe('google');
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pattern: /\bBard\b/i
|
||||||
|
* Examples: "Bard/1.0", "Google Bard client"
|
||||||
|
*/
|
||||||
|
it('detects Bard User-Agent', function () {
|
||||||
|
$service = new AgentDetection;
|
||||||
|
$identity = $service->identifyFromUserAgent('Bard/1.0');
|
||||||
|
|
||||||
|
expect($identity->provider)->toBe('google');
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pattern: /\bPaLM\b/i
|
||||||
|
* Examples: "PaLM API/2.0", "Google PaLM"
|
||||||
|
*/
|
||||||
|
it('detects PaLM User-Agent', function () {
|
||||||
|
$service = new AgentDetection;
|
||||||
|
$identity = $service->identifyFromUserAgent('PaLM API/2.0');
|
||||||
|
|
||||||
|
expect($identity->provider)->toBe('google');
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Model pattern: /gemini[\s\-_]?(1\.5[\s\-_]?)?pro/i
|
||||||
|
* Examples: "Gemini Pro client/1.0", "gemini-pro/1.0", "gemini-1.5-pro"
|
||||||
|
*/
|
||||||
|
it('detects gemini-pro model from User-Agent', function () {
|
||||||
|
$service = new AgentDetection;
|
||||||
|
$identity = $service->identifyFromUserAgent('Gemini Pro client/1.0');
|
||||||
|
|
||||||
|
expect($identity->provider)->toBe('google')
|
||||||
|
->and($identity->model)->toBe('gemini-pro');
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Model pattern: /gemini[\s\-_]?(1\.5[\s\-_]?)?flash/i
|
||||||
|
* Examples: "gemini-flash/1.5", "Gemini Flash client", "gemini-1.5-flash"
|
||||||
|
*/
|
||||||
|
it('detects gemini-flash model from User-Agent', function () {
|
||||||
|
$service = new AgentDetection;
|
||||||
|
$identity = $service->identifyFromUserAgent('gemini-flash/1.5');
|
||||||
|
|
||||||
|
expect($identity->provider)->toBe('google')
|
||||||
|
->and($identity->model)->toBe('gemini-flash');
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Model pattern: /gemini[\s\-_]?(1\.5[\s\-_]?)?ultra/i
|
||||||
|
* Examples: "Gemini Ultra/1.0", "gemini-1.5-ultra"
|
||||||
|
*/
|
||||||
|
it('detects gemini-ultra model from User-Agent', function () {
|
||||||
|
$service = new AgentDetection;
|
||||||
|
$identity = $service->identifyFromUserAgent('Gemini Ultra/1.0');
|
||||||
|
|
||||||
|
expect($identity->provider)->toBe('google')
|
||||||
|
->and($identity->model)->toBe('gemini-ultra');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Meta / LLaMA Detection
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('Meta/LLaMA detection', function () {
|
||||||
|
/**
|
||||||
|
* Pattern: /\bMeta[\s\-_]?AI\b/i
|
||||||
|
* Examples: "Meta AI assistant/1.0", "MetaAI/1.0"
|
||||||
|
*/
|
||||||
|
it('detects Meta AI User-Agent', function () {
|
||||||
|
$service = new AgentDetection;
|
||||||
|
$identity = $service->identifyFromUserAgent('Meta AI assistant/1.0');
|
||||||
|
|
||||||
|
expect($identity->provider)->toBe('meta')
|
||||||
|
->and($identity->confidence)->toBe(AgentIdentity::CONFIDENCE_HIGH);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pattern: /\bLLaMA\b/i
|
||||||
|
* Examples: "LLaMA model client/1.0", "llama-inference"
|
||||||
|
*/
|
||||||
|
it('detects LLaMA User-Agent', function () {
|
||||||
|
$service = new AgentDetection;
|
||||||
|
$identity = $service->identifyFromUserAgent('LLaMA model client/1.0');
|
||||||
|
|
||||||
|
expect($identity->provider)->toBe('meta');
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pattern: /\bLlama[\s\-_]?[23]\b/i
|
||||||
|
* Model pattern: /llama[\s\-_]?3/i
|
||||||
|
* Examples: "Llama-3 inference client/1.0", "Llama3/1.0", "Llama 3"
|
||||||
|
*/
|
||||||
|
it('detects Llama 3 and sets llama-3 model', function () {
|
||||||
|
$service = new AgentDetection;
|
||||||
|
$identity = $service->identifyFromUserAgent('Llama-3 inference client/1.0');
|
||||||
|
|
||||||
|
expect($identity->provider)->toBe('meta')
|
||||||
|
->and($identity->model)->toBe('llama-3');
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pattern: /\bLlama[\s\-_]?[23]\b/i
|
||||||
|
* Model pattern: /llama[\s\-_]?2/i
|
||||||
|
* Examples: "Llama-2 inference client/1.0", "Llama2/1.0", "Llama 2"
|
||||||
|
*/
|
||||||
|
it('detects Llama 2 and sets llama-2 model', function () {
|
||||||
|
$service = new AgentDetection;
|
||||||
|
$identity = $service->identifyFromUserAgent('Llama-2 inference client/1.0');
|
||||||
|
|
||||||
|
expect($identity->provider)->toBe('meta')
|
||||||
|
->and($identity->model)->toBe('llama-2');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Mistral Detection
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('Mistral detection', function () {
|
||||||
|
/**
|
||||||
|
* Pattern: /\bMistral\b/i
|
||||||
|
* Examples: "Mistral AI client/1.0", "mistral-python/1.0"
|
||||||
|
*/
|
||||||
|
it('detects Mistral User-Agent', function () {
|
||||||
|
$service = new AgentDetection;
|
||||||
|
$identity = $service->identifyFromUserAgent('Mistral AI client/1.0');
|
||||||
|
|
||||||
|
expect($identity->provider)->toBe('mistral')
|
||||||
|
->and($identity->confidence)->toBe(AgentIdentity::CONFIDENCE_HIGH);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pattern: /\bMixtral\b/i
|
||||||
|
* Model pattern: /mixtral/i
|
||||||
|
* Examples: "Mixtral-8x7B client/1.0", "mixtral inference"
|
||||||
|
*/
|
||||||
|
it('detects Mixtral User-Agent and sets mixtral model', function () {
|
||||||
|
$service = new AgentDetection;
|
||||||
|
$identity = $service->identifyFromUserAgent('Mixtral-8x7B client/1.0');
|
||||||
|
|
||||||
|
expect($identity->provider)->toBe('mistral')
|
||||||
|
->and($identity->model)->toBe('mixtral');
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Model pattern: /mistral[\s\-_]?large/i
|
||||||
|
* Examples: "Mistral Large API/2.0", "mistral-large/1.0"
|
||||||
|
*/
|
||||||
|
it('detects mistral-large model from User-Agent', function () {
|
||||||
|
$service = new AgentDetection;
|
||||||
|
$identity = $service->identifyFromUserAgent('Mistral Large API/2.0');
|
||||||
|
|
||||||
|
expect($identity->provider)->toBe('mistral')
|
||||||
|
->and($identity->model)->toBe('mistral-large');
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Model pattern: /mistral[\s\-_]?medium/i
|
||||||
|
* Examples: "Mistral Medium/1.0", "mistral-medium client"
|
||||||
|
*/
|
||||||
|
it('detects mistral-medium model from User-Agent', function () {
|
||||||
|
$service = new AgentDetection;
|
||||||
|
$identity = $service->identifyFromUserAgent('mistral-medium client/1.0');
|
||||||
|
|
||||||
|
expect($identity->provider)->toBe('mistral')
|
||||||
|
->and($identity->model)->toBe('mistral-medium');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Browser Detection (not an agent)
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('browser detection', function () {
|
||||||
|
/**
|
||||||
|
* Pattern: /\bMozilla\b/i
|
||||||
|
* All modern browsers include "Mozilla/5.0" in their UA string.
|
||||||
|
* Chrome example: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) ... Chrome/120..."
|
||||||
|
*/
|
||||||
|
it('detects Chrome browser as not an agent', function () {
|
||||||
|
$service = new AgentDetection;
|
||||||
|
$identity = $service->identifyFromUserAgent(
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($identity->isNotAgent())->toBeTrue()
|
||||||
|
->and($identity->provider)->toBe('not_agent')
|
||||||
|
->and($identity->confidence)->toBe(AgentIdentity::CONFIDENCE_HIGH);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Firefox example: "Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0"
|
||||||
|
*/
|
||||||
|
it('detects Firefox browser as not an agent', function () {
|
||||||
|
$service = new AgentDetection;
|
||||||
|
$identity = $service->identifyFromUserAgent(
|
||||||
|
'Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($identity->isNotAgent())->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safari example: "Mozilla/5.0 (Macintosh; ...) ... Version/17.0 Safari/605.1.15"
|
||||||
|
*/
|
||||||
|
it('detects Safari browser as not an agent', function () {
|
||||||
|
$service = new AgentDetection;
|
||||||
|
$identity = $service->identifyFromUserAgent(
|
||||||
|
'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($identity->isNotAgent())->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edge example: "Mozilla/5.0 ... Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0"
|
||||||
|
*/
|
||||||
|
it('detects Edge browser as not an agent', function () {
|
||||||
|
$service = new AgentDetection;
|
||||||
|
$identity = $service->identifyFromUserAgent(
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($identity->isNotAgent())->toBeTrue();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Non-Agent Bot Detection
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('non-agent bot detection', function () {
|
||||||
|
/**
|
||||||
|
* Pattern: /\bGooglebot\b/i
|
||||||
|
* Example: "Googlebot/2.1 (+http://www.google.com/bot.html)"
|
||||||
|
*/
|
||||||
|
it('detects Googlebot as not an agent', function () {
|
||||||
|
$service = new AgentDetection;
|
||||||
|
$identity = $service->identifyFromUserAgent(
|
||||||
|
'Googlebot/2.1 (+http://www.google.com/bot.html)'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($identity->isNotAgent())->toBeTrue()
|
||||||
|
->and($identity->provider)->toBe('not_agent');
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pattern: /\bBingbot\b/i
|
||||||
|
* Example: "Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)"
|
||||||
|
*/
|
||||||
|
it('detects Bingbot as not an agent', function () {
|
||||||
|
$service = new AgentDetection;
|
||||||
|
$identity = $service->identifyFromUserAgent(
|
||||||
|
'Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($identity->isNotAgent())->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pattern: /\bcurl\b/i
|
||||||
|
* Example: "curl/7.68.0"
|
||||||
|
*/
|
||||||
|
it('detects curl as not an agent', function () {
|
||||||
|
$service = new AgentDetection;
|
||||||
|
$identity = $service->identifyFromUserAgent('curl/7.68.0');
|
||||||
|
|
||||||
|
expect($identity->isNotAgent())->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pattern: /\bpython-requests\b/i
|
||||||
|
* Example: "python-requests/2.28.0"
|
||||||
|
*/
|
||||||
|
it('detects python-requests as not an agent', function () {
|
||||||
|
$service = new AgentDetection;
|
||||||
|
$identity = $service->identifyFromUserAgent('python-requests/2.28.0');
|
||||||
|
|
||||||
|
expect($identity->isNotAgent())->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pattern: /\bPostman\b/i
|
||||||
|
* Example: "PostmanRuntime/7.32.0"
|
||||||
|
*/
|
||||||
|
it('detects Postman as not an agent', function () {
|
||||||
|
$service = new AgentDetection;
|
||||||
|
$identity = $service->identifyFromUserAgent('PostmanRuntime/7.32.0');
|
||||||
|
|
||||||
|
expect($identity->isNotAgent())->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pattern: /\bSlackbot\b/i
|
||||||
|
* Example: "Slackbot-LinkExpanding 1.0 (+https://api.slack.com/robots)"
|
||||||
|
*/
|
||||||
|
it('detects Slackbot as not an agent', function () {
|
||||||
|
$service = new AgentDetection;
|
||||||
|
$identity = $service->identifyFromUserAgent(
|
||||||
|
'Slackbot-LinkExpanding 1.0 (+https://api.slack.com/robots)'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($identity->isNotAgent())->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pattern: /\bgo-http-client\b/i
|
||||||
|
* Example: "Go-http-client/1.1"
|
||||||
|
*/
|
||||||
|
it('detects Go-http-client as not an agent', function () {
|
||||||
|
$service = new AgentDetection;
|
||||||
|
$identity = $service->identifyFromUserAgent('Go-http-client/1.1');
|
||||||
|
|
||||||
|
expect($identity->isNotAgent())->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pattern: /\baxios\b/i
|
||||||
|
* Example: "axios/1.4.0"
|
||||||
|
*/
|
||||||
|
it('detects axios as not an agent', function () {
|
||||||
|
$service = new AgentDetection;
|
||||||
|
$identity = $service->identifyFromUserAgent('axios/1.4.0');
|
||||||
|
|
||||||
|
expect($identity->isNotAgent())->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pattern: /\bnode-fetch\b/i
|
||||||
|
* Example: "node-fetch/2.6.9"
|
||||||
|
*/
|
||||||
|
it('detects node-fetch as not an agent', function () {
|
||||||
|
$service = new AgentDetection;
|
||||||
|
$identity = $service->identifyFromUserAgent('node-fetch/2.6.9');
|
||||||
|
|
||||||
|
expect($identity->isNotAgent())->toBeTrue();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// MCP Token Detection
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('MCP token detection', function () {
|
||||||
|
/**
|
||||||
|
* Structured token format: "provider:model:secret"
|
||||||
|
* Example: "anthropic:claude-opus:abc123"
|
||||||
|
*/
|
||||||
|
it('identifies Anthropic from structured MCP token', function () {
|
||||||
|
$service = new AgentDetection;
|
||||||
|
$identity = $service->identifyFromMcpToken('anthropic:claude-opus:secret123');
|
||||||
|
|
||||||
|
expect($identity->provider)->toBe('anthropic')
|
||||||
|
->and($identity->model)->toBe('claude-opus')
|
||||||
|
->and($identity->confidence)->toBe(AgentIdentity::CONFIDENCE_HIGH);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Structured token format: "provider:model:secret"
|
||||||
|
* Example: "openai:gpt-4:xyz789"
|
||||||
|
*/
|
||||||
|
it('identifies OpenAI from structured MCP token', function () {
|
||||||
|
$service = new AgentDetection;
|
||||||
|
$identity = $service->identifyFromMcpToken('openai:gpt-4:secret456');
|
||||||
|
|
||||||
|
expect($identity->provider)->toBe('openai')
|
||||||
|
->and($identity->model)->toBe('gpt-4')
|
||||||
|
->and($identity->confidence)->toBe(AgentIdentity::CONFIDENCE_HIGH);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Structured token format: "provider:model:secret"
|
||||||
|
* Example: "google:gemini-pro:zyx321"
|
||||||
|
*/
|
||||||
|
it('identifies Google from structured MCP token', function () {
|
||||||
|
$service = new AgentDetection;
|
||||||
|
$identity = $service->identifyFromMcpToken('google:gemini-pro:secret789');
|
||||||
|
|
||||||
|
expect($identity->provider)->toBe('google')
|
||||||
|
->and($identity->model)->toBe('gemini-pro')
|
||||||
|
->and($identity->confidence)->toBe(AgentIdentity::CONFIDENCE_HIGH);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts meta and mistral providers in structured tokens', function () {
|
||||||
|
$service = new AgentDetection;
|
||||||
|
|
||||||
|
expect($service->identifyFromMcpToken('meta:llama-3:secret')->provider)->toBe('meta');
|
||||||
|
expect($service->identifyFromMcpToken('mistral:mistral-large:secret')->provider)->toBe('mistral');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns medium-confidence unknown for unrecognised token string', function () {
|
||||||
|
$service = new AgentDetection;
|
||||||
|
// No colon separator — cannot parse as structured token
|
||||||
|
$identity = $service->identifyFromMcpToken('some-random-opaque-token');
|
||||||
|
|
||||||
|
expect($identity->provider)->toBe('unknown')
|
||||||
|
->and($identity->confidence)->toBe(AgentIdentity::CONFIDENCE_MEDIUM);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns medium-confidence unknown for structured token with invalid provider', function () {
|
||||||
|
$service = new AgentDetection;
|
||||||
|
$identity = $service->identifyFromMcpToken('facebook:llama:secret');
|
||||||
|
|
||||||
|
expect($identity->provider)->toBe('unknown')
|
||||||
|
->and($identity->confidence)->toBe(AgentIdentity::CONFIDENCE_MEDIUM);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prioritises MCP token header over User-Agent in HTTP request', function () {
|
||||||
|
$service = new AgentDetection;
|
||||||
|
$request = Request::create('/test', 'GET', [], [], [], [
|
||||||
|
'HTTP_X_MCP_TOKEN' => 'anthropic:claude-sonnet:token123',
|
||||||
|
'HTTP_USER_AGENT' => 'python-requests/2.28.0',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// MCP token takes precedence; UA would indicate notAnAgent otherwise
|
||||||
|
$identity = $service->identify($request);
|
||||||
|
|
||||||
|
expect($identity->provider)->toBe('anthropic')
|
||||||
|
->and($identity->model)->toBe('claude-sonnet');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to User-Agent when no MCP token header is present', function () {
|
||||||
|
$service = new AgentDetection;
|
||||||
|
$request = Request::create('/test', 'GET', [], [], [], [
|
||||||
|
'HTTP_USER_AGENT' => 'claude-code/1.0',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$identity = $service->identify($request);
|
||||||
|
|
||||||
|
expect($identity->provider)->toBe('anthropic');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Provider Validation
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('provider validation', function () {
|
||||||
|
it('accepts all known valid providers', function () {
|
||||||
|
$service = new AgentDetection;
|
||||||
|
$validProviders = ['anthropic', 'openai', 'google', 'meta', 'mistral', 'local', 'unknown'];
|
||||||
|
|
||||||
|
foreach ($validProviders as $provider) {
|
||||||
|
expect($service->isValidProvider($provider))
|
||||||
|
->toBeTrue("Expected '{$provider}' to be a valid provider");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unknown provider names', function () {
|
||||||
|
$service = new AgentDetection;
|
||||||
|
|
||||||
|
expect($service->isValidProvider('facebook'))->toBeFalse()
|
||||||
|
->and($service->isValidProvider('huggingface'))->toBeFalse()
|
||||||
|
->and($service->isValidProvider(''))->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects not_agent as a provider (it is a sentinel value, not a provider)', function () {
|
||||||
|
$service = new AgentDetection;
|
||||||
|
|
||||||
|
expect($service->isValidProvider('not_agent'))->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns all valid providers as an array', function () {
|
||||||
|
$service = new AgentDetection;
|
||||||
|
$providers = $service->getValidProviders();
|
||||||
|
|
||||||
|
expect($providers)
|
||||||
|
->toContain('anthropic')
|
||||||
|
->toContain('openai')
|
||||||
|
->toContain('google')
|
||||||
|
->toContain('meta')
|
||||||
|
->toContain('mistral')
|
||||||
|
->toContain('local')
|
||||||
|
->toContain('unknown');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// isAgentUserAgent Shorthand
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('isAgentUserAgent shorthand', function () {
|
||||||
|
it('returns true for known AI agent User-Agents', function () {
|
||||||
|
$service = new AgentDetection;
|
||||||
|
|
||||||
|
expect($service->isAgentUserAgent('claude-code/1.0'))->toBeTrue()
|
||||||
|
->and($service->isAgentUserAgent('OpenAI Python/1.0'))->toBeTrue()
|
||||||
|
->and($service->isAgentUserAgent('Gemini API/2.0'))->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for browser User-Agents', function () {
|
||||||
|
$service = new AgentDetection;
|
||||||
|
$browserUA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0';
|
||||||
|
|
||||||
|
expect($service->isAgentUserAgent($browserUA))->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for crawler User-Agents', function () {
|
||||||
|
$service = new AgentDetection;
|
||||||
|
|
||||||
|
expect($service->isAgentUserAgent('Googlebot/2.1'))->toBeFalse()
|
||||||
|
->and($service->isAgentUserAgent('curl/7.68.0'))->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true for null User-Agent (unknown programmatic access)', function () {
|
||||||
|
$service = new AgentDetection;
|
||||||
|
// Null UA returns unknownAgent; isAgent() is true because provider !== 'not_agent'
|
||||||
|
expect($service->isAgentUserAgent(null))->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true for unrecognised non-browser User-Agent', function () {
|
||||||
|
$service = new AgentDetection;
|
||||||
|
// No browser indicators → unknownAgent → isAgent() true
|
||||||
|
expect($service->isAgentUserAgent('custom-agent/0.1'))->toBeTrue();
|
||||||
|
});
|
||||||
|
});
|
||||||
287
tests/Unit/AgentToolRegistryTest.php
Normal file
287
tests/Unit/AgentToolRegistryTest.php
Normal file
|
|
@ -0,0 +1,287 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for AgentToolRegistry caching behaviour (PERF-002).
|
||||||
|
*
|
||||||
|
* Verifies that forApiKey() caches results, that the cache is invalidated
|
||||||
|
* when permissions change or a key is revoked, and that the TTL is honoured.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use Core\Api\Models\ApiKey;
|
||||||
|
use Core\Mod\Agentic\Mcp\Tools\Agent\Contracts\AgentToolInterface;
|
||||||
|
use Core\Mod\Agentic\Services\AgentToolRegistry;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Helpers
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a minimal AgentToolInterface stub.
|
||||||
|
*/
|
||||||
|
function makeTool(string $name, array $scopes = [], string $category = 'test'): AgentToolInterface
|
||||||
|
{
|
||||||
|
return new class($name, $scopes, $category) implements AgentToolInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly string $toolName,
|
||||||
|
private readonly array $toolScopes,
|
||||||
|
private readonly string $toolCategory,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function name(): string
|
||||||
|
{
|
||||||
|
return $this->toolName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function description(): string
|
||||||
|
{
|
||||||
|
return 'Test tool';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function inputSchema(): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(array $args, array $context = []): array
|
||||||
|
{
|
||||||
|
return ['success' => true];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function requiredScopes(): array
|
||||||
|
{
|
||||||
|
return $this->toolScopes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function category(): string
|
||||||
|
{
|
||||||
|
return $this->toolCategory;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a minimal ApiKey mock with controllable scopes and tool_scopes.
|
||||||
|
*
|
||||||
|
* Uses Mockery to avoid requiring the real ApiKey class at load time,
|
||||||
|
* since the php-api package is not available in this test environment.
|
||||||
|
*/
|
||||||
|
function makeApiKey(int $id, array $scopes = [], ?array $toolScopes = null): ApiKey
|
||||||
|
{
|
||||||
|
$key = Mockery::mock(ApiKey::class);
|
||||||
|
$key->shouldReceive('getKey')->andReturn($id);
|
||||||
|
$key->shouldReceive('hasScope')->andReturnUsing(
|
||||||
|
fn (string $scope) => in_array($scope, $scopes, true)
|
||||||
|
);
|
||||||
|
$key->tool_scopes = $toolScopes;
|
||||||
|
|
||||||
|
return $key;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Caching – basic behaviour
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('forApiKey caching', function () {
|
||||||
|
beforeEach(function () {
|
||||||
|
Cache::flush();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the correct tools on first call (cache miss)', function () {
|
||||||
|
$registry = new AgentToolRegistry;
|
||||||
|
$registry->register(makeTool('plan.create', ['plans.write']));
|
||||||
|
$registry->register(makeTool('session.start', ['sessions.write']));
|
||||||
|
|
||||||
|
$apiKey = makeApiKey(1, ['plans.write', 'sessions.write']);
|
||||||
|
|
||||||
|
$tools = $registry->forApiKey($apiKey);
|
||||||
|
|
||||||
|
expect($tools->keys()->sort()->values()->all())
|
||||||
|
->toBe(['plan.create', 'session.start']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores permitted tool names in cache after first call', function () {
|
||||||
|
$registry = new AgentToolRegistry;
|
||||||
|
$registry->register(makeTool('plan.create', ['plans.write']));
|
||||||
|
|
||||||
|
$apiKey = makeApiKey(42, ['plans.write']);
|
||||||
|
|
||||||
|
$registry->forApiKey($apiKey);
|
||||||
|
|
||||||
|
$cached = Cache::get('agent_tool_registry:api_key:42');
|
||||||
|
expect($cached)->toBe(['plan.create']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns same result on second call (cache hit)', function () {
|
||||||
|
$registry = new AgentToolRegistry;
|
||||||
|
$registry->register(makeTool('plan.create', ['plans.write']));
|
||||||
|
$registry->register(makeTool('session.start', ['sessions.write']));
|
||||||
|
|
||||||
|
$apiKey = makeApiKey(1, ['plans.write']);
|
||||||
|
|
||||||
|
$first = $registry->forApiKey($apiKey)->keys()->all();
|
||||||
|
$second = $registry->forApiKey($apiKey)->keys()->all();
|
||||||
|
|
||||||
|
expect($second)->toBe($first);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters tools whose required scopes the key lacks', function () {
|
||||||
|
$registry = new AgentToolRegistry;
|
||||||
|
$registry->register(makeTool('plan.create', ['plans.write']));
|
||||||
|
$registry->register(makeTool('session.start', ['sessions.write']));
|
||||||
|
|
||||||
|
$apiKey = makeApiKey(1, ['plans.write']); // only plans.write
|
||||||
|
|
||||||
|
$tools = $registry->forApiKey($apiKey);
|
||||||
|
|
||||||
|
expect($tools->has('plan.create'))->toBeTrue()
|
||||||
|
->and($tools->has('session.start'))->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects tool_scopes allowlist on the api key', function () {
|
||||||
|
$registry = new AgentToolRegistry;
|
||||||
|
$registry->register(makeTool('plan.create', []));
|
||||||
|
$registry->register(makeTool('session.start', []));
|
||||||
|
|
||||||
|
$apiKey = makeApiKey(5, [], ['plan.create']); // explicitly restricted
|
||||||
|
|
||||||
|
$tools = $registry->forApiKey($apiKey);
|
||||||
|
|
||||||
|
expect($tools->has('plan.create'))->toBeTrue()
|
||||||
|
->and($tools->has('session.start'))->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows all tools when tool_scopes is null', function () {
|
||||||
|
$registry = new AgentToolRegistry;
|
||||||
|
$registry->register(makeTool('plan.create', []));
|
||||||
|
$registry->register(makeTool('session.start', []));
|
||||||
|
|
||||||
|
$apiKey = makeApiKey(7, [], null); // null = unrestricted
|
||||||
|
|
||||||
|
$tools = $registry->forApiKey($apiKey);
|
||||||
|
|
||||||
|
expect($tools)->toHaveCount(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caches separately per api key id', function () {
|
||||||
|
$registry = new AgentToolRegistry;
|
||||||
|
$registry->register(makeTool('plan.create', ['plans.write']));
|
||||||
|
$registry->register(makeTool('session.start', ['sessions.write']));
|
||||||
|
|
||||||
|
$keyA = makeApiKey(100, ['plans.write']);
|
||||||
|
$keyB = makeApiKey(200, ['sessions.write']);
|
||||||
|
|
||||||
|
$toolsA = $registry->forApiKey($keyA)->keys()->all();
|
||||||
|
$toolsB = $registry->forApiKey($keyB)->keys()->all();
|
||||||
|
|
||||||
|
expect($toolsA)->toBe(['plan.create'])
|
||||||
|
->and($toolsB)->toBe(['session.start']);
|
||||||
|
|
||||||
|
expect(Cache::get('agent_tool_registry:api_key:100'))->toBe(['plan.create'])
|
||||||
|
->and(Cache::get('agent_tool_registry:api_key:200'))->toBe(['session.start']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Cache TTL
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('cache TTL', function () {
|
||||||
|
it('declares CACHE_TTL constant as 3600 (1 hour)', function () {
|
||||||
|
expect(AgentToolRegistry::CACHE_TTL)->toBe(3600);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores entries in cache after first call', function () {
|
||||||
|
Cache::flush();
|
||||||
|
|
||||||
|
$registry = new AgentToolRegistry;
|
||||||
|
$registry->register(makeTool('plan.create', []));
|
||||||
|
|
||||||
|
$apiKey = makeApiKey(99, []);
|
||||||
|
$registry->forApiKey($apiKey);
|
||||||
|
|
||||||
|
expect(Cache::has('agent_tool_registry:api_key:99'))->toBeTrue();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Cache invalidation – flushCacheForApiKey
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('flushCacheForApiKey', function () {
|
||||||
|
beforeEach(function () {
|
||||||
|
Cache::flush();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes the cached entry for the given key id', function () {
|
||||||
|
$registry = new AgentToolRegistry;
|
||||||
|
$registry->register(makeTool('plan.create', []));
|
||||||
|
|
||||||
|
$apiKey = makeApiKey(10, []);
|
||||||
|
$registry->forApiKey($apiKey);
|
||||||
|
|
||||||
|
expect(Cache::has('agent_tool_registry:api_key:10'))->toBeTrue();
|
||||||
|
|
||||||
|
$registry->flushCacheForApiKey(10);
|
||||||
|
|
||||||
|
expect(Cache::has('agent_tool_registry:api_key:10'))->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-fetches permitted tools after cache flush', function () {
|
||||||
|
$registry = new AgentToolRegistry;
|
||||||
|
$registry->register(makeTool('plan.create', []));
|
||||||
|
|
||||||
|
$apiKey = makeApiKey(11, []);
|
||||||
|
|
||||||
|
// Prime the cache (only plan.create at this point)
|
||||||
|
expect($registry->forApiKey($apiKey)->keys()->all())->toBe(['plan.create']);
|
||||||
|
|
||||||
|
$registry->flushCacheForApiKey(11);
|
||||||
|
|
||||||
|
// Register an additional tool – should appear now that cache is gone
|
||||||
|
$registry->register(makeTool('session.start', []));
|
||||||
|
$after = $registry->forApiKey($apiKey)->keys()->sort()->values()->all();
|
||||||
|
|
||||||
|
expect($after)->toBe(['plan.create', 'session.start']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not affect cache entries for other key ids', function () {
|
||||||
|
$registry = new AgentToolRegistry;
|
||||||
|
$registry->register(makeTool('plan.create', []));
|
||||||
|
|
||||||
|
$key12 = makeApiKey(12, []);
|
||||||
|
$key13 = makeApiKey(13, []);
|
||||||
|
|
||||||
|
$registry->forApiKey($key12);
|
||||||
|
$registry->forApiKey($key13);
|
||||||
|
|
||||||
|
$registry->flushCacheForApiKey(12);
|
||||||
|
|
||||||
|
expect(Cache::has('agent_tool_registry:api_key:12'))->toBeFalse()
|
||||||
|
->and(Cache::has('agent_tool_registry:api_key:13'))->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a string key id', function () {
|
||||||
|
$registry = new AgentToolRegistry;
|
||||||
|
$registry->register(makeTool('plan.create', []));
|
||||||
|
|
||||||
|
$apiKey = makeApiKey(20, []);
|
||||||
|
$registry->forApiKey($apiKey);
|
||||||
|
|
||||||
|
$registry->flushCacheForApiKey('20');
|
||||||
|
|
||||||
|
expect(Cache::has('agent_tool_registry:api_key:20'))->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a no-op when cache entry does not exist', function () {
|
||||||
|
$registry = new AgentToolRegistry;
|
||||||
|
|
||||||
|
// Should not throw when nothing is cached
|
||||||
|
$registry->flushCacheForApiKey(999);
|
||||||
|
|
||||||
|
expect(Cache::has('agent_tool_registry:api_key:999'))->toBeFalse();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -15,6 +15,7 @@ use Core\Mod\Agentic\Services\ClaudeService;
|
||||||
use Core\Mod\Agentic\Services\GeminiService;
|
use Core\Mod\Agentic\Services\GeminiService;
|
||||||
use Core\Mod\Agentic\Services\OpenAIService;
|
use Core\Mod\Agentic\Services\OpenAIService;
|
||||||
use Illuminate\Support\Facades\Config;
|
use Illuminate\Support\Facades\Config;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
@ -27,7 +28,7 @@ describe('provider registration', function () {
|
||||||
Config::set('services.google.ai_api_key', 'test-gemini-key');
|
Config::set('services.google.ai_api_key', 'test-gemini-key');
|
||||||
Config::set('services.openai.api_key', 'test-openai-key');
|
Config::set('services.openai.api_key', 'test-openai-key');
|
||||||
|
|
||||||
$manager = new AgenticManager();
|
$manager = new AgenticManager;
|
||||||
|
|
||||||
expect($manager->claude())->toBeInstanceOf(ClaudeService::class)
|
expect($manager->claude())->toBeInstanceOf(ClaudeService::class)
|
||||||
->and($manager->gemini())->toBeInstanceOf(GeminiService::class)
|
->and($manager->gemini())->toBeInstanceOf(GeminiService::class)
|
||||||
|
|
@ -38,7 +39,7 @@ describe('provider registration', function () {
|
||||||
Config::set('services.anthropic.api_key', 'test-key');
|
Config::set('services.anthropic.api_key', 'test-key');
|
||||||
Config::set('services.anthropic.model', 'claude-opus-4-20250514');
|
Config::set('services.anthropic.model', 'claude-opus-4-20250514');
|
||||||
|
|
||||||
$manager = new AgenticManager();
|
$manager = new AgenticManager;
|
||||||
|
|
||||||
expect($manager->claude()->defaultModel())->toBe('claude-opus-4-20250514');
|
expect($manager->claude()->defaultModel())->toBe('claude-opus-4-20250514');
|
||||||
});
|
});
|
||||||
|
|
@ -47,7 +48,7 @@ describe('provider registration', function () {
|
||||||
Config::set('services.google.ai_api_key', 'test-key');
|
Config::set('services.google.ai_api_key', 'test-key');
|
||||||
Config::set('services.google.ai_model', 'gemini-1.5-pro');
|
Config::set('services.google.ai_model', 'gemini-1.5-pro');
|
||||||
|
|
||||||
$manager = new AgenticManager();
|
$manager = new AgenticManager;
|
||||||
|
|
||||||
expect($manager->gemini()->defaultModel())->toBe('gemini-1.5-pro');
|
expect($manager->gemini()->defaultModel())->toBe('gemini-1.5-pro');
|
||||||
});
|
});
|
||||||
|
|
@ -56,7 +57,7 @@ describe('provider registration', function () {
|
||||||
Config::set('services.openai.api_key', 'test-key');
|
Config::set('services.openai.api_key', 'test-key');
|
||||||
Config::set('services.openai.model', 'gpt-4o');
|
Config::set('services.openai.model', 'gpt-4o');
|
||||||
|
|
||||||
$manager = new AgenticManager();
|
$manager = new AgenticManager;
|
||||||
|
|
||||||
expect($manager->openai()->defaultModel())->toBe('gpt-4o');
|
expect($manager->openai()->defaultModel())->toBe('gpt-4o');
|
||||||
});
|
});
|
||||||
|
|
@ -65,7 +66,7 @@ describe('provider registration', function () {
|
||||||
Config::set('services.anthropic.api_key', 'test-key');
|
Config::set('services.anthropic.api_key', 'test-key');
|
||||||
Config::set('services.anthropic.model', null);
|
Config::set('services.anthropic.model', null);
|
||||||
|
|
||||||
$manager = new AgenticManager();
|
$manager = new AgenticManager;
|
||||||
|
|
||||||
expect($manager->claude()->defaultModel())->toBe('claude-sonnet-4-20250514');
|
expect($manager->claude()->defaultModel())->toBe('claude-sonnet-4-20250514');
|
||||||
});
|
});
|
||||||
|
|
@ -74,7 +75,7 @@ describe('provider registration', function () {
|
||||||
Config::set('services.google.ai_api_key', 'test-key');
|
Config::set('services.google.ai_api_key', 'test-key');
|
||||||
Config::set('services.google.ai_model', null);
|
Config::set('services.google.ai_model', null);
|
||||||
|
|
||||||
$manager = new AgenticManager();
|
$manager = new AgenticManager;
|
||||||
|
|
||||||
expect($manager->gemini()->defaultModel())->toBe('gemini-2.0-flash');
|
expect($manager->gemini()->defaultModel())->toBe('gemini-2.0-flash');
|
||||||
});
|
});
|
||||||
|
|
@ -83,7 +84,7 @@ describe('provider registration', function () {
|
||||||
Config::set('services.openai.api_key', 'test-key');
|
Config::set('services.openai.api_key', 'test-key');
|
||||||
Config::set('services.openai.model', null);
|
Config::set('services.openai.model', null);
|
||||||
|
|
||||||
$manager = new AgenticManager();
|
$manager = new AgenticManager;
|
||||||
|
|
||||||
expect($manager->openai()->defaultModel())->toBe('gpt-4o-mini');
|
expect($manager->openai()->defaultModel())->toBe('gpt-4o-mini');
|
||||||
});
|
});
|
||||||
|
|
@ -101,7 +102,7 @@ describe('provider retrieval', function () {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('retrieves provider by name using provider() method', function () {
|
it('retrieves provider by name using provider() method', function () {
|
||||||
$manager = new AgenticManager();
|
$manager = new AgenticManager;
|
||||||
|
|
||||||
expect($manager->provider('claude'))->toBeInstanceOf(ClaudeService::class)
|
expect($manager->provider('claude'))->toBeInstanceOf(ClaudeService::class)
|
||||||
->and($manager->provider('gemini'))->toBeInstanceOf(GeminiService::class)
|
->and($manager->provider('gemini'))->toBeInstanceOf(GeminiService::class)
|
||||||
|
|
@ -109,27 +110,27 @@ describe('provider retrieval', function () {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns default provider when null passed to provider()', function () {
|
it('returns default provider when null passed to provider()', function () {
|
||||||
$manager = new AgenticManager();
|
$manager = new AgenticManager;
|
||||||
|
|
||||||
// Default is 'claude'
|
// Default is 'claude'
|
||||||
expect($manager->provider(null))->toBeInstanceOf(ClaudeService::class);
|
expect($manager->provider(null))->toBeInstanceOf(ClaudeService::class);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns default provider when no argument passed to provider()', function () {
|
it('returns default provider when no argument passed to provider()', function () {
|
||||||
$manager = new AgenticManager();
|
$manager = new AgenticManager;
|
||||||
|
|
||||||
expect($manager->provider())->toBeInstanceOf(ClaudeService::class);
|
expect($manager->provider())->toBeInstanceOf(ClaudeService::class);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws exception for unknown provider name', function () {
|
it('throws exception for unknown provider name', function () {
|
||||||
$manager = new AgenticManager();
|
$manager = new AgenticManager;
|
||||||
|
|
||||||
expect(fn () => $manager->provider('unknown'))
|
expect(fn () => $manager->provider('unknown'))
|
||||||
->toThrow(InvalidArgumentException::class, 'Unknown AI provider: unknown');
|
->toThrow(InvalidArgumentException::class, 'Unknown AI provider: unknown');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns provider implementing AgenticProviderInterface', function () {
|
it('returns provider implementing AgenticProviderInterface', function () {
|
||||||
$manager = new AgenticManager();
|
$manager = new AgenticManager;
|
||||||
|
|
||||||
expect($manager->provider('claude'))->toBeInstanceOf(AgenticProviderInterface::class);
|
expect($manager->provider('claude'))->toBeInstanceOf(AgenticProviderInterface::class);
|
||||||
});
|
});
|
||||||
|
|
@ -147,13 +148,13 @@ describe('default provider', function () {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses claude as default provider initially', function () {
|
it('uses claude as default provider initially', function () {
|
||||||
$manager = new AgenticManager();
|
$manager = new AgenticManager;
|
||||||
|
|
||||||
expect($manager->provider()->name())->toBe('claude');
|
expect($manager->provider()->name())->toBe('claude');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('allows changing default provider to gemini', function () {
|
it('allows changing default provider to gemini', function () {
|
||||||
$manager = new AgenticManager();
|
$manager = new AgenticManager;
|
||||||
|
|
||||||
$manager->setDefault('gemini');
|
$manager->setDefault('gemini');
|
||||||
|
|
||||||
|
|
@ -161,7 +162,7 @@ describe('default provider', function () {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('allows changing default provider to openai', function () {
|
it('allows changing default provider to openai', function () {
|
||||||
$manager = new AgenticManager();
|
$manager = new AgenticManager;
|
||||||
|
|
||||||
$manager->setDefault('openai');
|
$manager->setDefault('openai');
|
||||||
|
|
||||||
|
|
@ -169,14 +170,14 @@ describe('default provider', function () {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws exception when setting unknown default provider', function () {
|
it('throws exception when setting unknown default provider', function () {
|
||||||
$manager = new AgenticManager();
|
$manager = new AgenticManager;
|
||||||
|
|
||||||
expect(fn () => $manager->setDefault('unknown'))
|
expect(fn () => $manager->setDefault('unknown'))
|
||||||
->toThrow(InvalidArgumentException::class, 'Unknown AI provider: unknown');
|
->toThrow(InvalidArgumentException::class, 'Unknown AI provider: unknown');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('allows switching default provider multiple times', function () {
|
it('allows switching default provider multiple times', function () {
|
||||||
$manager = new AgenticManager();
|
$manager = new AgenticManager;
|
||||||
|
|
||||||
$manager->setDefault('gemini');
|
$manager->setDefault('gemini');
|
||||||
expect($manager->provider()->name())->toBe('gemini');
|
expect($manager->provider()->name())->toBe('gemini');
|
||||||
|
|
@ -197,7 +198,7 @@ describe('provider availability', function () {
|
||||||
it('reports provider as available when API key is set', function () {
|
it('reports provider as available when API key is set', function () {
|
||||||
Config::set('services.anthropic.api_key', 'test-key');
|
Config::set('services.anthropic.api_key', 'test-key');
|
||||||
|
|
||||||
$manager = new AgenticManager();
|
$manager = new AgenticManager;
|
||||||
|
|
||||||
expect($manager->isAvailable('claude'))->toBeTrue();
|
expect($manager->isAvailable('claude'))->toBeTrue();
|
||||||
});
|
});
|
||||||
|
|
@ -207,7 +208,7 @@ describe('provider availability', function () {
|
||||||
Config::set('services.google.ai_api_key', '');
|
Config::set('services.google.ai_api_key', '');
|
||||||
Config::set('services.openai.api_key', '');
|
Config::set('services.openai.api_key', '');
|
||||||
|
|
||||||
$manager = new AgenticManager();
|
$manager = new AgenticManager;
|
||||||
|
|
||||||
expect($manager->isAvailable('claude'))->toBeFalse()
|
expect($manager->isAvailable('claude'))->toBeFalse()
|
||||||
->and($manager->isAvailable('gemini'))->toBeFalse()
|
->and($manager->isAvailable('gemini'))->toBeFalse()
|
||||||
|
|
@ -217,13 +218,13 @@ describe('provider availability', function () {
|
||||||
it('reports provider as unavailable when API key is null', function () {
|
it('reports provider as unavailable when API key is null', function () {
|
||||||
Config::set('services.anthropic.api_key', null);
|
Config::set('services.anthropic.api_key', null);
|
||||||
|
|
||||||
$manager = new AgenticManager();
|
$manager = new AgenticManager;
|
||||||
|
|
||||||
expect($manager->isAvailable('claude'))->toBeFalse();
|
expect($manager->isAvailable('claude'))->toBeFalse();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns false for unknown provider name', function () {
|
it('returns false for unknown provider name', function () {
|
||||||
$manager = new AgenticManager();
|
$manager = new AgenticManager;
|
||||||
|
|
||||||
expect($manager->isAvailable('unknown'))->toBeFalse();
|
expect($manager->isAvailable('unknown'))->toBeFalse();
|
||||||
});
|
});
|
||||||
|
|
@ -233,7 +234,7 @@ describe('provider availability', function () {
|
||||||
Config::set('services.google.ai_api_key', '');
|
Config::set('services.google.ai_api_key', '');
|
||||||
Config::set('services.openai.api_key', 'test-key');
|
Config::set('services.openai.api_key', 'test-key');
|
||||||
|
|
||||||
$manager = new AgenticManager();
|
$manager = new AgenticManager;
|
||||||
|
|
||||||
expect($manager->isAvailable('claude'))->toBeTrue()
|
expect($manager->isAvailable('claude'))->toBeTrue()
|
||||||
->and($manager->isAvailable('gemini'))->toBeFalse()
|
->and($manager->isAvailable('gemini'))->toBeFalse()
|
||||||
|
|
@ -251,7 +252,7 @@ describe('available providers list', function () {
|
||||||
Config::set('services.google.ai_api_key', 'test-gemini-key');
|
Config::set('services.google.ai_api_key', 'test-gemini-key');
|
||||||
Config::set('services.openai.api_key', 'test-openai-key');
|
Config::set('services.openai.api_key', 'test-openai-key');
|
||||||
|
|
||||||
$manager = new AgenticManager();
|
$manager = new AgenticManager;
|
||||||
$available = $manager->availableProviders();
|
$available = $manager->availableProviders();
|
||||||
|
|
||||||
expect($available)->toHaveCount(3)
|
expect($available)->toHaveCount(3)
|
||||||
|
|
@ -263,7 +264,7 @@ describe('available providers list', function () {
|
||||||
Config::set('services.google.ai_api_key', '');
|
Config::set('services.google.ai_api_key', '');
|
||||||
Config::set('services.openai.api_key', '');
|
Config::set('services.openai.api_key', '');
|
||||||
|
|
||||||
$manager = new AgenticManager();
|
$manager = new AgenticManager;
|
||||||
|
|
||||||
expect($manager->availableProviders())->toBeEmpty();
|
expect($manager->availableProviders())->toBeEmpty();
|
||||||
});
|
});
|
||||||
|
|
@ -273,7 +274,7 @@ describe('available providers list', function () {
|
||||||
Config::set('services.google.ai_api_key', '');
|
Config::set('services.google.ai_api_key', '');
|
||||||
Config::set('services.openai.api_key', 'test-key');
|
Config::set('services.openai.api_key', 'test-key');
|
||||||
|
|
||||||
$manager = new AgenticManager();
|
$manager = new AgenticManager;
|
||||||
$available = $manager->availableProviders();
|
$available = $manager->availableProviders();
|
||||||
|
|
||||||
expect($available)->toHaveCount(2)
|
expect($available)->toHaveCount(2)
|
||||||
|
|
@ -283,7 +284,7 @@ describe('available providers list', function () {
|
||||||
it('returns providers implementing AgenticProviderInterface', function () {
|
it('returns providers implementing AgenticProviderInterface', function () {
|
||||||
Config::set('services.anthropic.api_key', 'test-key');
|
Config::set('services.anthropic.api_key', 'test-key');
|
||||||
|
|
||||||
$manager = new AgenticManager();
|
$manager = new AgenticManager;
|
||||||
$available = $manager->availableProviders();
|
$available = $manager->availableProviders();
|
||||||
|
|
||||||
foreach ($available as $provider) {
|
foreach ($available as $provider) {
|
||||||
|
|
@ -304,7 +305,7 @@ describe('direct provider access methods', function () {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns ClaudeService from claude() method', function () {
|
it('returns ClaudeService from claude() method', function () {
|
||||||
$manager = new AgenticManager();
|
$manager = new AgenticManager;
|
||||||
|
|
||||||
expect($manager->claude())
|
expect($manager->claude())
|
||||||
->toBeInstanceOf(ClaudeService::class)
|
->toBeInstanceOf(ClaudeService::class)
|
||||||
|
|
@ -312,7 +313,7 @@ describe('direct provider access methods', function () {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns GeminiService from gemini() method', function () {
|
it('returns GeminiService from gemini() method', function () {
|
||||||
$manager = new AgenticManager();
|
$manager = new AgenticManager;
|
||||||
|
|
||||||
expect($manager->gemini())
|
expect($manager->gemini())
|
||||||
->toBeInstanceOf(GeminiService::class)
|
->toBeInstanceOf(GeminiService::class)
|
||||||
|
|
@ -320,7 +321,7 @@ describe('direct provider access methods', function () {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns OpenAIService from openai() method', function () {
|
it('returns OpenAIService from openai() method', function () {
|
||||||
$manager = new AgenticManager();
|
$manager = new AgenticManager;
|
||||||
|
|
||||||
expect($manager->openai())
|
expect($manager->openai())
|
||||||
->toBeInstanceOf(OpenAIService::class)
|
->toBeInstanceOf(OpenAIService::class)
|
||||||
|
|
@ -328,7 +329,7 @@ describe('direct provider access methods', function () {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns same instance on repeated calls', function () {
|
it('returns same instance on repeated calls', function () {
|
||||||
$manager = new AgenticManager();
|
$manager = new AgenticManager;
|
||||||
|
|
||||||
$claude1 = $manager->claude();
|
$claude1 = $manager->claude();
|
||||||
$claude2 = $manager->claude();
|
$claude2 = $manager->claude();
|
||||||
|
|
@ -343,6 +344,8 @@ describe('direct provider access methods', function () {
|
||||||
|
|
||||||
describe('edge cases', function () {
|
describe('edge cases', function () {
|
||||||
it('handles missing configuration gracefully', function () {
|
it('handles missing configuration gracefully', function () {
|
||||||
|
Log::spy();
|
||||||
|
|
||||||
Config::set('services.anthropic.api_key', null);
|
Config::set('services.anthropic.api_key', null);
|
||||||
Config::set('services.anthropic.model', null);
|
Config::set('services.anthropic.model', null);
|
||||||
Config::set('services.google.ai_api_key', null);
|
Config::set('services.google.ai_api_key', null);
|
||||||
|
|
@ -350,7 +353,7 @@ describe('edge cases', function () {
|
||||||
Config::set('services.openai.api_key', null);
|
Config::set('services.openai.api_key', null);
|
||||||
Config::set('services.openai.model', null);
|
Config::set('services.openai.model', null);
|
||||||
|
|
||||||
$manager = new AgenticManager();
|
$manager = new AgenticManager;
|
||||||
|
|
||||||
// Should still construct without throwing
|
// Should still construct without throwing
|
||||||
expect($manager->claude())->toBeInstanceOf(ClaudeService::class)
|
expect($manager->claude())->toBeInstanceOf(ClaudeService::class)
|
||||||
|
|
@ -359,12 +362,15 @@ describe('edge cases', function () {
|
||||||
|
|
||||||
// But all should be unavailable
|
// But all should be unavailable
|
||||||
expect($manager->availableProviders())->toBeEmpty();
|
expect($manager->availableProviders())->toBeEmpty();
|
||||||
|
|
||||||
|
// Warnings logged for all three unconfigured providers
|
||||||
|
Log::shouldHaveReceived('warning')->times(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('provider retrieval is case-sensitive', function () {
|
it('provider retrieval is case-sensitive', function () {
|
||||||
Config::set('services.anthropic.api_key', 'test-key');
|
Config::set('services.anthropic.api_key', 'test-key');
|
||||||
|
|
||||||
$manager = new AgenticManager();
|
$manager = new AgenticManager;
|
||||||
|
|
||||||
expect(fn () => $manager->provider('Claude'))
|
expect(fn () => $manager->provider('Claude'))
|
||||||
->toThrow(InvalidArgumentException::class);
|
->toThrow(InvalidArgumentException::class);
|
||||||
|
|
@ -373,7 +379,7 @@ describe('edge cases', function () {
|
||||||
it('isAvailable handles case sensitivity', function () {
|
it('isAvailable handles case sensitivity', function () {
|
||||||
Config::set('services.anthropic.api_key', 'test-key');
|
Config::set('services.anthropic.api_key', 'test-key');
|
||||||
|
|
||||||
$manager = new AgenticManager();
|
$manager = new AgenticManager;
|
||||||
|
|
||||||
expect($manager->isAvailable('claude'))->toBeTrue()
|
expect($manager->isAvailable('claude'))->toBeTrue()
|
||||||
->and($manager->isAvailable('Claude'))->toBeFalse()
|
->and($manager->isAvailable('Claude'))->toBeFalse()
|
||||||
|
|
@ -381,9 +387,101 @@ describe('edge cases', function () {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('setDefault handles case sensitivity', function () {
|
it('setDefault handles case sensitivity', function () {
|
||||||
$manager = new AgenticManager();
|
$manager = new AgenticManager;
|
||||||
|
|
||||||
expect(fn () => $manager->setDefault('Gemini'))
|
expect(fn () => $manager->setDefault('Gemini'))
|
||||||
->toThrow(InvalidArgumentException::class);
|
->toThrow(InvalidArgumentException::class);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// API Key Validation Warning Tests
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('API key validation warnings', function () {
|
||||||
|
it('logs a warning when Claude API key is not configured', function () {
|
||||||
|
Log::spy();
|
||||||
|
Config::set('services.anthropic.api_key', '');
|
||||||
|
Config::set('services.google.ai_api_key', 'test-gemini-key');
|
||||||
|
Config::set('services.openai.api_key', 'test-openai-key');
|
||||||
|
|
||||||
|
new AgenticManager;
|
||||||
|
|
||||||
|
Log::shouldHaveReceived('warning')
|
||||||
|
->once()
|
||||||
|
->withArgs(fn (string $message) => str_contains($message, 'claude') && str_contains($message, 'ANTHROPIC_API_KEY'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs a warning when Gemini API key is not configured', function () {
|
||||||
|
Log::spy();
|
||||||
|
Config::set('services.anthropic.api_key', 'test-claude-key');
|
||||||
|
Config::set('services.google.ai_api_key', '');
|
||||||
|
Config::set('services.openai.api_key', 'test-openai-key');
|
||||||
|
|
||||||
|
new AgenticManager;
|
||||||
|
|
||||||
|
Log::shouldHaveReceived('warning')
|
||||||
|
->once()
|
||||||
|
->withArgs(fn (string $message) => str_contains($message, 'gemini') && str_contains($message, 'GOOGLE_AI_API_KEY'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs a warning when OpenAI API key is not configured', function () {
|
||||||
|
Log::spy();
|
||||||
|
Config::set('services.anthropic.api_key', 'test-claude-key');
|
||||||
|
Config::set('services.google.ai_api_key', 'test-gemini-key');
|
||||||
|
Config::set('services.openai.api_key', '');
|
||||||
|
|
||||||
|
new AgenticManager;
|
||||||
|
|
||||||
|
Log::shouldHaveReceived('warning')
|
||||||
|
->once()
|
||||||
|
->withArgs(fn (string $message) => str_contains($message, 'openai') && str_contains($message, 'OPENAI_API_KEY'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs a warning when API key is null', function () {
|
||||||
|
Log::spy();
|
||||||
|
Config::set('services.anthropic.api_key', null);
|
||||||
|
Config::set('services.google.ai_api_key', 'test-gemini-key');
|
||||||
|
Config::set('services.openai.api_key', 'test-openai-key');
|
||||||
|
|
||||||
|
new AgenticManager;
|
||||||
|
|
||||||
|
Log::shouldHaveReceived('warning')
|
||||||
|
->once()
|
||||||
|
->withArgs(fn (string $message) => str_contains($message, 'claude'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs warnings for all three providers when no keys are configured', function () {
|
||||||
|
Log::spy();
|
||||||
|
Config::set('services.anthropic.api_key', '');
|
||||||
|
Config::set('services.google.ai_api_key', '');
|
||||||
|
Config::set('services.openai.api_key', '');
|
||||||
|
|
||||||
|
new AgenticManager;
|
||||||
|
|
||||||
|
Log::shouldHaveReceived('warning')->times(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not log warnings when all API keys are configured', function () {
|
||||||
|
Log::spy();
|
||||||
|
Config::set('services.anthropic.api_key', 'test-claude-key');
|
||||||
|
Config::set('services.google.ai_api_key', 'test-gemini-key');
|
||||||
|
Config::set('services.openai.api_key', 'test-openai-key');
|
||||||
|
|
||||||
|
new AgenticManager;
|
||||||
|
|
||||||
|
Log::shouldNotHaveReceived('warning');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('only warns for providers that have missing keys, not all providers', function () {
|
||||||
|
Log::spy();
|
||||||
|
Config::set('services.anthropic.api_key', 'test-key');
|
||||||
|
Config::set('services.google.ai_api_key', '');
|
||||||
|
Config::set('services.openai.api_key', '');
|
||||||
|
|
||||||
|
new AgenticManager;
|
||||||
|
|
||||||
|
// Only gemini and openai should warn – not claude
|
||||||
|
Log::shouldHaveReceived('warning')->times(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,9 @@ declare(strict_types=1);
|
||||||
|
|
||||||
use Core\Mod\Agentic\Services\AgenticResponse;
|
use Core\Mod\Agentic\Services\AgenticResponse;
|
||||||
use Core\Mod\Agentic\Services\ClaudeService;
|
use Core\Mod\Agentic\Services\ClaudeService;
|
||||||
|
use Illuminate\Http\Client\ConnectionException;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
|
|
||||||
const CLAUDE_API_URL = 'https://api.anthropic.com/v1/messages';
|
const CLAUDE_API_URL = 'https://api.anthropic.com/v1/messages';
|
||||||
|
|
@ -345,3 +347,78 @@ describe('error handling', function () {
|
||||||
->toThrow(RuntimeException::class);
|
->toThrow(RuntimeException::class);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Stream Error Handling Tests
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('stream error handling', function () {
|
||||||
|
it('yields error event when connection fails', function () {
|
||||||
|
Http::fake(function () {
|
||||||
|
throw new ConnectionException('Connection refused');
|
||||||
|
});
|
||||||
|
|
||||||
|
$service = new ClaudeService('test-api-key');
|
||||||
|
$results = iterator_to_array($service->stream('System', 'User'));
|
||||||
|
|
||||||
|
expect($results)->toHaveCount(1)
|
||||||
|
->and($results[0])->toBeArray()
|
||||||
|
->and($results[0]['type'])->toBe('error')
|
||||||
|
->and($results[0]['message'])->toContain('Connection refused');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('yields error event when request throws a runtime exception', function () {
|
||||||
|
Http::fake(function () {
|
||||||
|
throw new RuntimeException('Unexpected failure');
|
||||||
|
});
|
||||||
|
|
||||||
|
$service = new ClaudeService('test-api-key');
|
||||||
|
$results = iterator_to_array($service->stream('System', 'User'));
|
||||||
|
|
||||||
|
expect($results)->toHaveCount(1)
|
||||||
|
->and($results[0]['type'])->toBe('error')
|
||||||
|
->and($results[0]['message'])->toBe('Unexpected failure');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('error event contains type and message keys', function () {
|
||||||
|
Http::fake(function () {
|
||||||
|
throw new RuntimeException('Stream broke');
|
||||||
|
});
|
||||||
|
|
||||||
|
$service = new ClaudeService('test-api-key');
|
||||||
|
$event = iterator_to_array($service->stream('System', 'User'))[0];
|
||||||
|
|
||||||
|
expect($event)->toHaveKeys(['type', 'message'])
|
||||||
|
->and($event['type'])->toBe('error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs stream errors', function () {
|
||||||
|
Log::spy();
|
||||||
|
|
||||||
|
Http::fake(function () {
|
||||||
|
throw new RuntimeException('Logging test error');
|
||||||
|
});
|
||||||
|
|
||||||
|
$service = new ClaudeService('test-api-key');
|
||||||
|
iterator_to_array($service->stream('System', 'User'));
|
||||||
|
|
||||||
|
Log::shouldHaveReceived('error')
|
||||||
|
->with('Claude stream error', \Mockery::on(fn ($ctx) => str_contains($ctx['message'], 'Logging test error')))
|
||||||
|
->once();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('yields text chunks normally when no error occurs', function () {
|
||||||
|
$stream = "data: {\"type\": \"content_block_delta\", \"delta\": {\"text\": \"Hello\"}}\n\n";
|
||||||
|
$stream .= "data: {\"type\": \"content_block_delta\", \"delta\": {\"text\": \" world\"}}\n\n";
|
||||||
|
$stream .= "data: [DONE]\n\n";
|
||||||
|
|
||||||
|
Http::fake([
|
||||||
|
CLAUDE_API_URL => Http::response($stream, 200, ['Content-Type' => 'text/event-stream']),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$service = new ClaudeService('test-api-key');
|
||||||
|
$results = iterator_to_array($service->stream('System', 'User'));
|
||||||
|
|
||||||
|
expect($results)->toBe(['Hello', ' world']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue