Testing
Never call a provider from tests. laravel/ai fakes the model, laravel/mcp drives tools directly, and the HTTP endpoints are normal routes.
Faking the model
use App\Ai\Agents\Assistant;
use Packstub\Agents\Support\AgentConversationStore;
use Packstub\Agents\Support\AgentTurns;
it('answers a question', function () {
actingAs($user);
Assistant::fake(['Two orders are waiting for a call.']);
$conversation = app(AgentConversationStore::class)->startConversation($user, 'What needs attention?');
$turn = app(AgentTurns::class)->enqueue($conversation, $user, ['prompt' => 'What needs attention?'], null, 'auto', null);
expect($turn->fresh()->status)->toBe(AgentTurn::DONE)
->and(ConversationMessage::query()->where('conversation_id', $conversation)->latest('id')->value('content'))
->toBe('Two orders are waiting for a call.');
});
Assistant::fake([...]) comes from laravel/ai's Promptable trait: each entry is one answer, in order.
A turn runs in a queued job. On the sync queue driver (the default in a test environment), or with chat.driver set to sync, it runs inside enqueue(), so the answer is stored when the call returns, as above. To test what happens while the job waits — the poll endpoint attaching to a running turn, Stop, the follow-ups waiting per conversation — fake the queue and run the pushed job yourself:
use Illuminate\Support\Facades\Queue;
use Packstub\Agents\Jobs\RunAgentTurn;
use Packstub\Agents\Support\AgentTurns;
Queue::fake();
$turn = app(AgentTurns::class)->enqueue($conversation, $user, ['prompt' => 'What needs attention?'], null, 'auto', null);
Assistant::fake(['Two orders are waiting for a call.']);
Queue::pushed(RunAgentTurn::class)->first()->handle(app(AgentTurns::class));
Queue::pushed(...)->first() is the job that was dispatched; handle() runs it as a worker would, with the workspace, person, guard and locale of the request restored.
In a Filament panel, Filament Agents drives the same turns through its chat page with Livewire's livewire(Chat::class).
Driving a tool
use App\Mcp\Servers\AcmeServer;
use App\Mcp\Tools\SearchOrders;
it('finds orders by number', function () {
actingAs($user);
AcmeServer::tool(SearchOrders::class, ['query' => 'RO-00012'])
->assertOk()
->assertSee('RO-00012');
});
it('refuses a tool the role does not allow', function () {
actingAs($viewer);
AcmeServer::tool(ConfirmOrder::class, ['id' => 1])
->assertHasErrors()
->assertSee('not found'); // a tool the role may not use is not registered on the server
$direct = app(ConfirmOrder::class)->handle(new \Laravel\Mcp\Request(['id' => 1]));
expect($direct->isError())->toBeTrue()
->and((string) $direct->content())->toContain('not allowed');
});
Server::tool() is laravel/mcp's testing helper: it runs the tool through the server, so the ability check applies exactly as in production.
The agent's tool list
use Laravel\Ai\Tools\McpServerTool;
use Packstub\Agents\Ai\ApprovableTool;
use Packstub\Agents\Facades\Agents;
it('wraps writes for approval', function () {
actingAs($user);
$tools = collect((new Assistant)->tools())->keyBy(fn ($tool) => $tool->name());
expect($tools->get('search-orders'))->toBeInstanceOf(McpServerTool::class)->not->toBeInstanceOf(ApprovableTool::class)
->and($tools->get('confirm-order'))->toBeInstanceOf(ApprovableTool::class);
});
The MCP endpoint
use function Pest\Laravel\postJson;
it('serves MCP with a read or write token', function () {
$mcp = ['Accept' => 'application/json, text/event-stream', 'MCP-Protocol-Version' => '2025-06-18'];
$read = $user->createToken('laptop', ['read'])->plainTextToken;
$write = $user->createToken('desk', ['read', 'write'])->plainTextToken;
postJson('/mcp', ['jsonrpc' => '2.0', 'id' => 0, 'method' => 'tools/list'], $mcp)
->assertStatus(401);
postJson('/mcp', ['jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/list'], ['Authorization' => 'Bearer '.$read] + $mcp)
->assertOk()
->assertJsonPath('result.tools.0.name', 'search-orders');
postJson('/mcp', ['jsonrpc' => '2.0', 'id' => 2, 'method' => 'tools/call', 'params' => ['name' => 'confirm-order', 'arguments' => ['id' => 1]]], ['Authorization' => 'Bearer '.$read] + $mcp)
->assertOk()
->assertJsonPath('error.message', 'Tool [confirm-order] not found.'); // a read token does not list write tools
auth()->forgetGuards(); // each MCP request is its own request in production; the test kernel keeps the resolved guard
postJson('/mcp', ['jsonrpc' => '2.0', 'id' => 3, 'method' => 'tools/call', 'params' => ['name' => 'confirm-order', 'arguments' => ['id' => 1]]], ['Authorization' => 'Bearer '.$write] + $mcp)
->assertOk()
->assertJsonPath('result.isError', false);
});
Budgets
use Packstub\Agents\Models\AgentLimit;
use Packstub\Agents\Support\AgentBudget;
use Packstub\Agents\Support\AgentLimits;
it('stops a turn when the daily limit is spent', function () {
AgentLimit::query()->create(['scope' => 'global', 'turns_per_day' => 1]);
AgentLimits::flush();
// … one assistant message exists …
expect(AgentBudget::refusal('hello'))->toContain("today's limit");
});
The package's own suite
composer test
The suite runs on Orchestra Testbench with an in-memory SQLite database and no Filament: a Widget model with a plain AgentResource class, four tools, an agent and a server registered through the facade, and covers tools and authorization, tokens and scopes, queued turns on their guard and workspace, the poll endpoint, budgets and limits.