service = new ToolDependencyService; Cache::flush(); } public function test_can_register_dependencies(): void { $deps = [ ToolDependency::toolCalled('plan_create'), ToolDependency::contextExists('workspace_id'), ]; $this->service->register('custom_tool', $deps); $registered = $this->service->getDependencies('custom_tool'); $this->assertCount(2, $registered); $this->assertSame('plan_create', $registered[0]->key); $this->assertSame(DependencyType::TOOL_CALLED, $registered[0]->type); } public function test_returns_empty_for_unregistered_tool(): void { $deps = $this->service->getDependencies('nonexistent_tool'); $this->assertEmpty($deps); } public function test_check_dependencies_passes_when_no_deps(): void { $result = $this->service->checkDependencies( sessionId: 'test-session', toolName: 'tool_without_deps', context: [], args: [], ); $this->assertTrue($result); } public function test_check_dependencies_fails_when_tool_not_called(): void { $this->service->register('dependent_tool', [ ToolDependency::toolCalled('required_tool'), ]); $result = $this->service->checkDependencies( sessionId: 'test-session', toolName: 'dependent_tool', context: [], args: [], ); $this->assertFalse($result); } public function test_check_dependencies_passes_after_tool_called(): void { $this->service->register('dependent_tool', [ ToolDependency::toolCalled('required_tool'), ]); // Record the required tool call $this->service->recordToolCall('test-session', 'required_tool'); $result = $this->service->checkDependencies( sessionId: 'test-session', toolName: 'dependent_tool', context: [], args: [], ); $this->assertTrue($result); } public function test_check_context_exists_dependency(): void { $this->service->register('workspace_tool', [ ToolDependency::contextExists('workspace_id'), ]); // Without workspace_id $result = $this->service->checkDependencies( sessionId: 'test-session', toolName: 'workspace_tool', context: [], args: [], ); $this->assertFalse($result); // With workspace_id $result = $this->service->checkDependencies( sessionId: 'test-session', toolName: 'workspace_tool', context: ['workspace_id' => 123], args: [], ); $this->assertTrue($result); } public function test_check_session_state_dependency(): void { $this->service->register('session_tool', [ ToolDependency::sessionState('session_id'), ]); // Without session_id $result = $this->service->checkDependencies( sessionId: 'test-session', toolName: 'session_tool', context: [], args: [], ); $this->assertFalse($result); // With null session_id (should still fail) $result = $this->service->checkDependencies( sessionId: 'test-session', toolName: 'session_tool', context: ['session_id' => null], args: [], ); $this->assertFalse($result); // With valid session_id $result = $this->service->checkDependencies( sessionId: 'test-session', toolName: 'session_tool', context: ['session_id' => 'ses_123'], args: [], ); $this->assertTrue($result); } public function test_get_missing_dependencies(): void { $this->service->register('multi_dep_tool', [ ToolDependency::toolCalled('tool_a'), ToolDependency::toolCalled('tool_b'), ToolDependency::contextExists('workspace_id'), ]); // Record one tool call $this->service->recordToolCall('test-session', 'tool_a'); $missing = $this->service->getMissingDependencies( sessionId: 'test-session', toolName: 'multi_dep_tool', context: [], args: [], ); $this->assertCount(2, $missing); $this->assertSame('tool_b', $missing[0]->key); $this->assertSame('workspace_id', $missing[1]->key); } public function test_validate_dependencies_throws_exception(): void { $this->service->register('validated_tool', [ ToolDependency::toolCalled('required_tool', 'You must call required_tool first'), ]); $this->expectException(MissingDependencyException::class); $this->expectExceptionMessage('Cannot execute \'validated_tool\''); $this->service->validateDependencies( sessionId: 'test-session', toolName: 'validated_tool', context: [], args: [], ); } public function test_validate_dependencies_passes_when_met(): void { $this->service->register('validated_tool', [ ToolDependency::toolCalled('required_tool'), ]); $this->service->recordToolCall('test-session', 'required_tool'); // Should not throw $this->service->validateDependencies( sessionId: 'test-session', toolName: 'validated_tool', context: [], args: [], ); $this->assertTrue(true); // No exception means pass } public function test_optional_dependencies_are_skipped(): void { $this->service->register('soft_dep_tool', [ ToolDependency::toolCalled('hard_req'), ToolDependency::toolCalled('soft_req')->asOptional(), ]); $this->service->recordToolCall('test-session', 'hard_req'); // Should pass even though soft_req not called $result = $this->service->checkDependencies( sessionId: 'test-session', toolName: 'soft_dep_tool', context: [], args: [], ); $this->assertTrue($result); } public function test_record_and_get_tool_call_history(): void { $this->service->recordToolCall('test-session', 'tool_a', ['arg1' => 'value1']); $this->service->recordToolCall('test-session', 'tool_b'); $this->service->recordToolCall('test-session', 'tool_a', ['arg1' => 'value2']); $calledTools = $this->service->getCalledTools('test-session'); $this->assertCount(2, $calledTools); $this->assertContains('tool_a', $calledTools); $this->assertContains('tool_b', $calledTools); $history = $this->service->getToolHistory('test-session'); $this->assertCount(3, $history); $this->assertSame('tool_a', $history[0]['tool']); $this->assertSame(['arg1' => 'value1'], $history[0]['args']); } public function test_clear_session(): void { $this->service->recordToolCall('test-session', 'tool_a'); $this->assertNotEmpty($this->service->getCalledTools('test-session')); $this->service->clearSession('test-session'); $this->assertEmpty($this->service->getCalledTools('test-session')); } public function test_get_dependency_graph(): void { $this->service->register('tool_a', []); $this->service->register('tool_b', [ ToolDependency::toolCalled('tool_a'), ]); $this->service->register('tool_c', [ ToolDependency::toolCalled('tool_b'), ]); $graph = $this->service->getDependencyGraph(); $this->assertArrayHasKey('tool_a', $graph); $this->assertArrayHasKey('tool_b', $graph); $this->assertArrayHasKey('tool_c', $graph); // tool_b depends on tool_a $this->assertContains('tool_b', $graph['tool_a']['dependents']); // tool_c depends on tool_b $this->assertContains('tool_c', $graph['tool_b']['dependents']); } public function test_get_dependent_tools(): void { $this->service->register('base_tool', []); $this->service->register('dep_tool_1', [ ToolDependency::toolCalled('base_tool'), ]); $this->service->register('dep_tool_2', [ ToolDependency::toolCalled('base_tool'), ]); $dependents = $this->service->getDependentTools('base_tool'); $this->assertCount(2, $dependents); $this->assertContains('dep_tool_1', $dependents); $this->assertContains('dep_tool_2', $dependents); } public function test_get_topological_order(): void { $this->service->register('tool_a', []); $this->service->register('tool_b', [ ToolDependency::toolCalled('tool_a'), ]); $this->service->register('tool_c', [ ToolDependency::toolCalled('tool_b'), ]); $order = $this->service->getTopologicalOrder(); $indexA = array_search('tool_a', $order); $indexB = array_search('tool_b', $order); $indexC = array_search('tool_c', $order); $this->assertLessThan($indexB, $indexA); $this->assertLessThan($indexC, $indexB); } public function test_custom_validator(): void { $this->service->register('custom_validated_tool', [ ToolDependency::custom('has_permission', 'User must have admin permission'), ]); // Register custom validator that checks for admin role $this->service->registerCustomValidator('has_permission', function ($context, $args) { return ($context['role'] ?? null) === 'admin'; }); // Without admin role $result = $this->service->checkDependencies( sessionId: 'test-session', toolName: 'custom_validated_tool', context: ['role' => 'user'], args: [], ); $this->assertFalse($result); // With admin role $result = $this->service->checkDependencies( sessionId: 'test-session', toolName: 'custom_validated_tool', context: ['role' => 'admin'], args: [], ); $this->assertTrue($result); } public function test_suggested_tool_order(): void { $this->service->register('tool_a', []); $this->service->register('tool_b', [ ToolDependency::toolCalled('tool_a'), ]); $this->service->register('tool_c', [ ToolDependency::toolCalled('tool_b'), ]); try { $this->service->validateDependencies( sessionId: 'test-session', toolName: 'tool_c', context: [], args: [], ); $this->fail('Should have thrown MissingDependencyException'); } catch (MissingDependencyException $e) { $this->assertContains('tool_a', $e->suggestedOrder); $this->assertContains('tool_b', $e->suggestedOrder); $this->assertContains('tool_c', $e->suggestedOrder); // Verify order $indexA = array_search('tool_a', $e->suggestedOrder); $indexB = array_search('tool_b', $e->suggestedOrder); $this->assertLessThan($indexB, $indexA); } } public function test_session_isolation(): void { $this->service->register('isolated_tool', [ ToolDependency::toolCalled('prereq'), ]); // Record in session 1 $this->service->recordToolCall('session-1', 'prereq'); // Session 1 should pass $result1 = $this->service->checkDependencies( sessionId: 'session-1', toolName: 'isolated_tool', context: [], args: [], ); $this->assertTrue($result1); // Session 2 should fail (different session) $result2 = $this->service->checkDependencies( sessionId: 'session-2', toolName: 'isolated_tool', context: [], args: [], ); $this->assertFalse($result2); } public function test_missing_dependency_exception_api_response(): void { $missing = [ ToolDependency::toolCalled('tool_a', 'Tool A must be called first'), ToolDependency::contextExists('workspace_id', 'Workspace context required'), ]; $exception = new MissingDependencyException( toolName: 'target_tool', missingDependencies: $missing, suggestedOrder: ['tool_a', 'target_tool'], ); $response = $exception->toApiResponse(); $this->assertSame('dependency_not_met', $response['error']); $this->assertSame('target_tool', $response['tool']); $this->assertCount(2, $response['missing_dependencies']); $this->assertSame(['tool_a', 'target_tool'], $response['suggested_order']); $this->assertArrayHasKey('help', $response); } public function test_default_dependencies_registered(): void { // The service should have default dependencies registered $sessionLogDeps = $this->service->getDependencies('session_log'); $this->assertNotEmpty($sessionLogDeps); $this->assertSame(DependencyType::SESSION_STATE, $sessionLogDeps[0]->type); $this->assertSame('session_id', $sessionLogDeps[0]->key); } public function test_tool_dependency_factory_methods(): void { $toolCalled = ToolDependency::toolCalled('some_tool'); $this->assertSame(DependencyType::TOOL_CALLED, $toolCalled->type); $this->assertSame('some_tool', $toolCalled->key); $sessionState = ToolDependency::sessionState('session_key'); $this->assertSame(DependencyType::SESSION_STATE, $sessionState->type); $contextExists = ToolDependency::contextExists('context_key'); $this->assertSame(DependencyType::CONTEXT_EXISTS, $contextExists->type); $entityExists = ToolDependency::entityExists('plan', 'Plan must exist', ['arg_key' => 'plan_slug']); $this->assertSame(DependencyType::ENTITY_EXISTS, $entityExists->type); $this->assertSame('plan_slug', $entityExists->metadata['arg_key']); $custom = ToolDependency::custom('custom_check', 'Custom validation'); $this->assertSame(DependencyType::CUSTOM, $custom->type); } public function test_tool_dependency_to_and_from_array(): void { $original = ToolDependency::toolCalled('some_tool', 'Must call first') ->asOptional(); $array = $original->toArray(); $this->assertSame('tool_called', $array['type']); $this->assertSame('some_tool', $array['key']); $this->assertTrue($array['optional']); $restored = ToolDependency::fromArray($array); $this->assertSame($original->type, $restored->type); $this->assertSame($original->key, $restored->key); $this->assertSame($original->optional, $restored->optional); } }