createSession('Run Session'); $prompt = $service->appendMessage([ 'session_id' => $session->session_id, 'role' => Message::ROLE_USER, 'type' => 'user.prompt', 'content' => 'hello agent', ]); $runId = $dispatcher->dispatchForPrompt($session->session_id, $prompt->message_id); Queue::assertPushed(AgentRunJob::class, function ($job) use ($session, $runId) { return $job->sessionId === $session->session_id && $job->runId === $runId; }); // simulate worker execution (new AgentRunJob($session->session_id, $runId))->handle( app(RunLoop::class), app(OutputSink::class), app(CancelChecker::class) ); $messages = Message::query() ->where('session_id', $session->session_id) ->orderBy('seq') ->get(); $this->assertTrue($messages->contains(fn ($m) => $m->type === 'run.status' && ($m->payload['status'] ?? null) === 'RUNNING')); $this->assertTrue($messages->contains(fn ($m) => $m->type === 'agent.message' && ($m->payload['run_id'] ?? null) === $runId)); $this->assertTrue($messages->contains(fn ($m) => $m->type === 'run.status' && ($m->payload['status'] ?? null) === 'DONE')); } public function test_dispatch_is_idempotent_for_same_trigger(): void { Queue::fake(); $service = app(ChatService::class); $dispatcher = app(RunDispatcher::class); $session = $service->createSession('Idempotent Run'); $prompt = $service->appendMessage([ 'session_id' => $session->session_id, 'role' => Message::ROLE_USER, 'type' => 'user.prompt', 'content' => 'please run once', ]); $firstRunId = $dispatcher->dispatchForPrompt($session->session_id, $prompt->message_id); $secondRunId = $dispatcher->dispatchForPrompt($session->session_id, $prompt->message_id); $this->assertSame($firstRunId, $secondRunId); Queue::assertPushed(AgentRunJob::class, 1); $statusMessages = Message::query() ->where('session_id', $session->session_id) ->where('type', 'run.status') ->whereRaw("payload->>'trigger_message_id' = ?", [$prompt->message_id]) ->get(); $this->assertCount(1, $statusMessages); } public function test_second_prompt_dispatches_new_run_after_first_completes(): void { Queue::fake(); $service = app(ChatService::class); $dispatcher = app(RunDispatcher::class); $session = $service->createSession('Sequential Runs'); $firstPrompt = $service->appendMessage([ 'session_id' => $session->session_id, 'role' => Message::ROLE_USER, 'type' => 'user.prompt', 'content' => 'first run', ]); $firstRunId = $dispatcher->dispatchForPrompt($session->session_id, $firstPrompt->message_id); (new AgentRunJob($session->session_id, $firstRunId))->handle( app(RunLoop::class), app(OutputSink::class), app(CancelChecker::class) ); $secondPrompt = $service->appendMessage([ 'session_id' => $session->session_id, 'role' => Message::ROLE_USER, 'type' => 'user.prompt', 'content' => 'second run', ]); $secondRunId = $dispatcher->dispatchForPrompt($session->session_id, $secondPrompt->message_id); $this->assertNotSame($firstRunId, $secondRunId); Queue::assertPushed(AgentRunJob::class, 2); Queue::assertPushed(AgentRunJob::class, function ($job) use ($secondRunId, $session) { return $job->runId === $secondRunId && $job->sessionId === $session->session_id; }); } public function test_repeated_job_does_not_duplicate_agent_message(): void { Queue::fake(); $service = app(ChatService::class); $dispatcher = app(RunDispatcher::class); $session = $service->createSession('Retry Session'); $prompt = $service->appendMessage([ 'session_id' => $session->session_id, 'role' => Message::ROLE_USER, 'type' => 'user.prompt', 'content' => 'retry run', ]); $runId = $dispatcher->dispatchForPrompt($session->session_id, $prompt->message_id); (new AgentRunJob($session->session_id, $runId))->handle( app(RunLoop::class), app(OutputSink::class), app(CancelChecker::class) ); (new AgentRunJob($session->session_id, $runId))->handle( app(RunLoop::class), app(OutputSink::class), app(CancelChecker::class) ); $agentMessages = Message::query() ->where('session_id', $session->session_id) ->where('type', 'agent.message') ->whereRaw("payload->>'run_id' = ?", [$runId]) ->get(); $doneStatuses = Message::query() ->where('session_id', $session->session_id) ->where('type', 'run.status') ->whereRaw("payload->>'run_id' = ?", [$runId]) ->whereRaw("payload->>'status' = ?", ['DONE']) ->get(); $this->assertCount(1, $agentMessages); $this->assertCount(1, $doneStatuses); } public function test_cancel_prevents_agent_reply_and_marks_canceled(): void { Queue::fake(); $service = app(ChatService::class); $dispatcher = app(RunDispatcher::class); $loop = app(RunLoop::class); $session = $service->createSession('Cancel Session'); $prompt = $service->appendMessage([ 'session_id' => $session->session_id, 'role' => Message::ROLE_USER, 'type' => 'user.prompt', 'content' => 'please cancel', ]); $runId = $dispatcher->dispatchForPrompt($session->session_id, $prompt->message_id); $service->appendMessage([ 'session_id' => $session->session_id, 'role' => Message::ROLE_USER, 'type' => 'run.cancel.request', 'payload' => ['run_id' => $runId], ]); $loop->run($session->session_id, $runId); $messages = Message::query() ->where('session_id', $session->session_id) ->get(); $this->assertFalse($messages->contains(fn ($m) => $m->type === 'agent.message' && ($m->payload['run_id'] ?? null) === $runId)); $this->assertTrue($messages->contains(fn ($m) => $m->type === 'run.status' && ($m->payload['status'] ?? null) === 'CANCELED')); } public function test_provider_error_event_writes_error_and_failed_status(): void { Queue::fake(); $this->app->bind(AgentProviderInterface::class, function () { return new class implements AgentProviderInterface { public function stream(AgentContext $context, array $options = []): \Generator { yield ProviderEvent::error('HTTP_ERROR', 'provider failed', [ 'retryable' => true, 'http_status' => 500, ]); } }; }); $service = app(ChatService::class); $dispatcher = app(RunDispatcher::class); $session = $service->createSession('Provider Failure'); $prompt = $service->appendMessage([ 'session_id' => $session->session_id, 'role' => Message::ROLE_USER, 'type' => 'user.prompt', 'content' => 'trigger failure', ]); $runId = $dispatcher->dispatchForPrompt($session->session_id, $prompt->message_id); (new AgentRunJob($session->session_id, $runId))->handle( app(RunLoop::class), app(OutputSink::class), app(CancelChecker::class) ); $messages = Message::query() ->where('session_id', $session->session_id) ->get(); $this->assertTrue($messages->contains(function ($m) use ($runId) { return $m->type === 'run.status' && ($m->payload['status'] ?? null) === 'FAILED' && ($m->payload['run_id'] ?? null) === $runId; })); $this->assertTrue($messages->contains(function ($m) use ($runId) { return $m->type === 'error' && $m->content === 'HTTP_ERROR' && ($m->payload['run_id'] ?? null) === $runId && ($m->payload['retryable'] ?? null) === true && ($m->payload['http_status'] ?? null) === 500; })); } public function test_tool_call_triggers_child_run_and_continues_to_final_message(): void { $this->app->bind(AgentProviderInterface::class, function () { return new class implements AgentProviderInterface { public int $calls = 0; public function stream(AgentContext $context, array $options = []): \Generator { if ($this->calls === 0) { $this->calls++; yield ProviderEvent::toolDelta([ 'tool_calls' => [ [ 'id' => 'call_1', 'name' => 'get_time', 'arguments' => '{"format":"c"}', 'index' => 0, ], ], ]); yield ProviderEvent::done('tool_calls'); return; } yield ProviderEvent::messageDelta('tool done'); yield ProviderEvent::done('stop'); } }; }); $service = app(ChatService::class); $dispatcher = app(RunDispatcher::class); $session = $service->createSession('Tool Run'); $prompt = $service->appendMessage([ 'session_id' => $session->session_id, 'role' => Message::ROLE_USER, 'type' => 'user.prompt', 'content' => 'use tool', ]); $runId = $dispatcher->dispatchForPrompt($session->session_id, $prompt->message_id); (new AgentRunJob($session->session_id, $runId))->handle( app(RunLoop::class), app(OutputSink::class), app(CancelChecker::class) ); $messages = Message::query() ->where('session_id', $session->session_id) ->orderBy('seq') ->get(); $this->assertTrue($messages->contains(fn ($m) => $m->type === 'tool.call' && ($m->payload['tool_call_id'] ?? null) === 'call_1')); $this->assertTrue($messages->contains(fn ($m) => $m->type === 'tool.result' && ($m->payload['tool_call_id'] ?? null) === 'call_1')); $this->assertTrue($messages->contains(fn ($m) => $m->type === 'agent.message' && ($m->payload['run_id'] ?? null) === $runId)); $this->assertTrue($messages->contains(fn ($m) => $m->type === 'run.status' && ($m->payload['status'] ?? null) === 'DONE')); } }