redisUrl = getenv('REDIS_URL') ?: null; if ($this->redisUrl === null) { $this->markTestSkipped('REDIS_URL not set, skipping Redis tests'); } $this->redisCache = RedisCache::fromUrl($this->redisUrl, 'test_ctx:'); } protected function tearDown(): void { // Clean up test keys $this->redisCache->clear(); } public function testSetAndGet(): void { $this->redisCache->set('foo', ['bar' => 'baz']); $value = $this->redisCache->get('foo'); $this->assertIsArray($value); $this->assertEquals('baz', $value['bar']); } public function testHasReturnsTrueForExistingKey(): void { $this->redisCache->set('exists', 'value'); $this->assertTrue($this->redisCache->has('exists')); $this->assertFalse($this->redisCache->has('nonexistent')); } public function testDelete(): void { $this->redisCache->set('to_delete', 'value'); $this->assertTrue($this->redisCache->has('to_delete')); $this->redisCache->delete('to_delete'); $this->assertFalse($this->redisCache->has('to_delete')); } public function testGetReturnsNullForMissingKey(): void { $this->assertNull($this->redisCache->get('missing_key')); } public function testTtl(): void { // Set with 1 second TTL $this->redisCache->set('expires_soon', 'value', 1); $this->assertTrue($this->redisCache->has('expires_soon')); $this->assertEquals('value', $this->redisCache->get('expires_soon')); // Wait for expiry sleep(2); $this->assertFalse($this->redisCache->has('expires_soon')); } public function testContextPagingWithRedisCache(): void { $messageStore = RedisCache::fromUrl($this->redisUrl, 'test_msg:'); $summaryCache = RedisCache::fromUrl($this->redisUrl, 'test_sum:'); $contextPaging = new ContextPaging( tokenCounter: new TokenCounter(), messageStore: $messageStore, summaryCache: $summaryCache ); $contextPaging->setMaxContextTokens(100)->setResponseReserve(20); $longContent = str_repeat('This is a long message that will be summarized. ', 30); $md5 = md5($longContent); $messages = [ ['role' => 'user', 'content' => $longContent], ['role' => 'user', 'content' => 'Short question'], ]; $request = $this->createRequest($messages); $fitted = $contextPaging->fit($request); // Verify message was stored in Redis $storedMessage = $messageStore->get("msg:{$md5}"); $this->assertNotNull($storedMessage, 'Message should be stored in Redis'); $this->assertEquals($longContent, $storedMessage['content']); // Verify summary was cached $summaryKey = "summary:{$md5}"; $this->assertTrue($summaryCache->has($summaryKey), 'Summary should be cached'); // Clean up $messageStore->clear(); $summaryCache->clear(); } public function testSummaryPersistsBetweenRequests(): void { $messageStore = RedisCache::fromUrl($this->redisUrl, 'test_msg2:'); $summaryCache = RedisCache::fromUrl($this->redisUrl, 'test_sum2:'); $longContent = str_repeat('Persist test content. ', 50); $md5 = md5($longContent); // First request: create summary $contextPaging1 = new ContextPaging( tokenCounter: new TokenCounter(), messageStore: $messageStore, summaryCache: $summaryCache ); $contextPaging1->setMaxContextTokens(100)->setResponseReserve(20); $request1 = $this->createRequest([ ['role' => 'user', 'content' => $longContent], ['role' => 'user', 'content' => 'Short'], ]); $fitted1 = $contextPaging1->fit($request1); $fittedMessages1 = $fitted1->getParsedBody()['messages']; // Get the summary from cache $cachedSummary = $summaryCache->get("summary:{$md5}"); // Second request: should use cached summary $contextPaging2 = new ContextPaging( tokenCounter: new TokenCounter(), messageStore: $messageStore, summaryCache: $summaryCache ); $contextPaging2->setMaxContextTokens(100)->setResponseReserve(20); $request2 = $this->createRequest([ ['role' => 'user', 'content' => $longContent], ['role' => 'user', 'content' => 'Short'], ]); $fitted2 = $contextPaging2->fit($request2); $fittedMessages2 = $fitted2->getParsedBody()['messages']; // Summaries should be identical (from cache) $this->assertEquals( $fittedMessages1[0]['content'], $fittedMessages2[0]['content'], 'Summary should be identical from cache' ); // Clean up $messageStore->clear(); $summaryCache->clear(); } public function testInMemoryVsRedisParity(): void { $content = 'Test content for parity check'; $md5 = md5($content); // In-memory $inMemory = new InMemoryCache(); $inMemory->set("msg:{$md5}", ['role' => 'user', 'content' => $content]); // Redis $redis = RedisCache::fromUrl($this->redisUrl, 'test_parity:'); $redis->set("msg:{$md5}", ['role' => 'user', 'content' => $content]); // Both should return same data $this->assertEquals( $inMemory->get("msg:{$md5}"), $redis->get("msg:{$md5}"), 'In-memory and Redis should return same data' ); $redis->clear(); } public function testMessageStorePersistsAcrossInstances(): void { $messageStore = RedisCache::fromUrl($this->redisUrl, 'test_msg3:'); $summaryCache = RedisCache::fromUrl($this->redisUrl, 'test_sum3:'); $longContent = str_repeat('Cross-instance test. ', 40); $md5 = md5($longContent); // First instance: fit and store $instance1 = new ContextPaging( tokenCounter: new TokenCounter(), messageStore: $messageStore, summaryCache: $summaryCache ); $instance1->setMaxContextTokens(100)->setResponseReserve(20); $request = $this->createRequest([ ['role' => 'user', 'content' => $longContent], ['role' => 'user', 'content' => 'Query'], ]); $fitted = $instance1->fit($request); // Second instance: should be able to dereference from shared Redis $instance2 = new ContextPaging( tokenCounter: new TokenCounter(), messageStore: $messageStore, summaryCache: $summaryCache ); // Access the message store directly $retrievedMessage = $instance2->getMessageStore()->get("msg:{$md5}"); $this->assertNotNull($retrievedMessage, 'Second instance should see stored message'); $this->assertEquals($longContent, $retrievedMessage['content']); // Clean up $messageStore->clear(); $summaryCache->clear(); } private function createRequest(array $messages): ServerRequest { $body = ['messages' => $messages]; return new ServerRequest( method: 'POST', uri: 'test://localhost', headers: ['Content-Type' => 'application/json'], body: json_encode($body), version: '1.1', serverParams: [] )->withParsedBody($body); } }