|
| 1 | +import { |
| 2 | + CacheApplyDispatcher, |
| 3 | +} from '../cache-apply.dispatcher'; |
| 4 | +import { CacheResolverService, type ResolvedCache } from '../cache-resolver.service'; |
| 5 | +import type { ConnectionRegistry } from '../../connections/connection-registry.service'; |
| 6 | +import { ApplyFailedError } from '../errors'; |
| 7 | +import type { StoredCacheProposal } from '@betterdb/shared'; |
| 8 | + |
| 9 | +class FakeClient { |
| 10 | + public hsets: Array<{ key: string; field: string; value: string }> = []; |
| 11 | + public deletes: string[] = []; |
| 12 | + |
| 13 | + async hset(key: string, field: string, value: string): Promise<number> { |
| 14 | + this.hsets.push({ key, field, value }); |
| 15 | + return 1; |
| 16 | + } |
| 17 | + |
| 18 | + async del(...keys: string[]): Promise<number> { |
| 19 | + this.deletes.push(...keys); |
| 20 | + return keys.length; |
| 21 | + } |
| 22 | + |
| 23 | + async scan(cursor: string, _match: string, _pattern: string, _count: string, _n: number): Promise<[string, string[]]> { |
| 24 | + return [cursor === '0' ? '0' : '0', []]; |
| 25 | + } |
| 26 | + |
| 27 | + async call(): Promise<unknown> { |
| 28 | + return [0]; |
| 29 | + } |
| 30 | +} |
| 31 | + |
| 32 | +const buildDispatcher = (cache: ResolvedCache, client: FakeClient) => { |
| 33 | + const registry = { |
| 34 | + get: () => ({ getClient: () => client }), |
| 35 | + } as unknown as ConnectionRegistry; |
| 36 | + const resolver = { |
| 37 | + resolveCacheByName: async () => cache, |
| 38 | + } as unknown as CacheResolverService; |
| 39 | + return new CacheApplyDispatcher(registry, resolver); |
| 40 | +}; |
| 41 | + |
| 42 | +const SEMANTIC_CACHE: ResolvedCache = { |
| 43 | + name: 'sc:prod', |
| 44 | + type: 'semantic_cache', |
| 45 | + prefix: 'sc:prod', |
| 46 | + capabilities: ['threshold_adjust'], |
| 47 | + protocol_version: 1, |
| 48 | + live: true, |
| 49 | +}; |
| 50 | + |
| 51 | +const AGENT_CACHE: ResolvedCache = { |
| 52 | + name: 'ac:prod', |
| 53 | + type: 'agent_cache', |
| 54 | + prefix: 'ac:prod', |
| 55 | + capabilities: [], |
| 56 | + protocol_version: 1, |
| 57 | + live: true, |
| 58 | +}; |
| 59 | + |
| 60 | +const proposal = (overrides: Partial<StoredCacheProposal>): StoredCacheProposal => |
| 61 | + ({ |
| 62 | + id: 'p1', |
| 63 | + connection_id: 'c1', |
| 64 | + cache_name: 'sc:prod', |
| 65 | + cache_type: 'semantic_cache', |
| 66 | + proposal_type: 'threshold_adjust', |
| 67 | + proposal_payload: { category: null, current_threshold: 0.1, new_threshold: 0.5 }, |
| 68 | + reasoning: 'r', |
| 69 | + status: 'approved', |
| 70 | + proposed_by: 'u', |
| 71 | + proposed_at: 0, |
| 72 | + reviewed_by: null, |
| 73 | + reviewed_at: null, |
| 74 | + applied_at: null, |
| 75 | + applied_result: null, |
| 76 | + expires_at: 0, |
| 77 | + ...overrides, |
| 78 | + } as StoredCacheProposal); |
| 79 | + |
| 80 | +describe('CacheApplyDispatcher', () => { |
| 81 | + it('semantic threshold_adjust writes HSET to {cache_name}:__config', async () => { |
| 82 | + const client = new FakeClient(); |
| 83 | + const dispatcher = buildDispatcher(SEMANTIC_CACHE, client); |
| 84 | + await dispatcher.dispatch(proposal({})); |
| 85 | + expect(client.hsets).toEqual([ |
| 86 | + { key: 'sc:prod:__config', field: 'threshold', value: '0.5' }, |
| 87 | + ]); |
| 88 | + }); |
| 89 | + |
| 90 | + it('semantic threshold_adjust with category writes namespaced field', async () => { |
| 91 | + const client = new FakeClient(); |
| 92 | + const dispatcher = buildDispatcher(SEMANTIC_CACHE, client); |
| 93 | + await dispatcher.dispatch( |
| 94 | + proposal({ |
| 95 | + proposal_payload: { category: 'support', current_threshold: 0.1, new_threshold: 0.7 }, |
| 96 | + }), |
| 97 | + ); |
| 98 | + expect(client.hsets[0]).toEqual({ |
| 99 | + key: 'sc:prod:__config', |
| 100 | + field: 'threshold:support', |
| 101 | + value: '0.7', |
| 102 | + }); |
| 103 | + }); |
| 104 | + |
| 105 | + it('semantic threshold_adjust fails when capability missing', async () => { |
| 106 | + const client = new FakeClient(); |
| 107 | + const dispatcher = buildDispatcher({ ...SEMANTIC_CACHE, capabilities: [] }, client); |
| 108 | + await expect(dispatcher.dispatch(proposal({}))).rejects.toBeInstanceOf(ApplyFailedError); |
| 109 | + expect(client.hsets).toEqual([]); |
| 110 | + }); |
| 111 | + |
| 112 | + it('agent tool_ttl_adjust writes JSON policy to {cache_name}:__tool_policies', async () => { |
| 113 | + const client = new FakeClient(); |
| 114 | + const dispatcher = buildDispatcher(AGENT_CACHE, client); |
| 115 | + await dispatcher.dispatch( |
| 116 | + proposal({ |
| 117 | + cache_name: 'ac:prod', |
| 118 | + cache_type: 'agent_cache', |
| 119 | + proposal_type: 'tool_ttl_adjust', |
| 120 | + proposal_payload: { |
| 121 | + tool_name: 'search_index', |
| 122 | + current_ttl_seconds: 60, |
| 123 | + new_ttl_seconds: 600, |
| 124 | + }, |
| 125 | + }), |
| 126 | + ); |
| 127 | + expect(client.hsets).toEqual([ |
| 128 | + { |
| 129 | + key: 'ac:prod:__tool_policies', |
| 130 | + field: 'search_index', |
| 131 | + value: JSON.stringify({ ttl: 600 }), |
| 132 | + }, |
| 133 | + ]); |
| 134 | + }); |
| 135 | + |
| 136 | + it('fails when cache type changed since proposal creation', async () => { |
| 137 | + const client = new FakeClient(); |
| 138 | + const dispatcher = buildDispatcher(AGENT_CACHE, client); |
| 139 | + await expect( |
| 140 | + dispatcher.dispatch( |
| 141 | + proposal({ cache_name: 'ac:prod', cache_type: 'semantic_cache' }), |
| 142 | + ), |
| 143 | + ).rejects.toBeInstanceOf(ApplyFailedError); |
| 144 | + }); |
| 145 | +}); |
0 commit comments