prjct-cli 1.7.2 → 1.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,66 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.7.4] - 2026-02-07
4
+
5
+ ### Bug Fixes
6
+
7
+ - add eviction policies to all in-memory caches (PRJ-288) (#143)
8
+
9
+
10
+ ## [1.7.4] - 2026-02-07
11
+
12
+ ### Bug Fixes
13
+ - **Add eviction policies to all in-memory caches (PRJ-288)**: Replaced unbounded Maps with TTLCache in SessionLogManager and ContextBuilder, capped PatternStore decision contexts at 20 with FIFO eviction, and added 90-day archival for stale decisions to prevent unbounded memory growth.
14
+
15
+ ### Implementation Details
16
+ - SessionLogManager: replaced 2 `Map` caches with `TTLCache` (maxSize: 50, TTL: 1hr)
17
+ - ContextBuilder: replaced `Map` + `_mtimes` + manual TTL with single `TTLCache<CachedFile>` (maxSize: 200, TTL: 5s), added project-switch detection
18
+ - PatternStore: added `afterLoad()` hook to truncate oversized contexts arrays, FIFO cap at 20 in `recordDecision()`, new `archiveStaleDecisions()` method for 90-day archival
19
+ - Exposed `archiveStaleDecisions()` via MemorySystem facade
20
+
21
+ ### Test Plan
22
+
23
+ #### For QA
24
+ 1. Run `bun test core/__tests__/agentic/cache-eviction.test.ts` — 12 new tests pass
25
+ 2. Run `bun test` — full suite (538 tests) passes with no regressions
26
+ 3. Run `bun run build` — compiles without errors
27
+ 4. Verify `prjct sync` and `prjct status` work normally
28
+
29
+ #### For Users
30
+ **What changed:** Internal optimization — in-memory caches now bounded to prevent memory growth during long sessions.
31
+ **How to use:** No user-facing changes.
32
+ **Breaking changes:** None
33
+
34
+ ## [1.7.3] - 2026-02-07
35
+
36
+ ### Bug Fixes
37
+
38
+ - add Zod validation and token budgets for prompt injection (PRJ-282) (#142)
39
+
40
+
41
+ ## [1.7.3] - 2026-02-07
42
+
43
+ ### Bug Fixes
44
+ - **Validate auto-injected state in prompt builder (PRJ-282)**: Added `safeInject()` validation utility, token-aware truncation via `InjectionBudgetTracker`, and domain-based skill filtering to prevent oversized or irrelevant content in LLM prompts. Replaced hardcoded character limits with configurable token budgets.
45
+
46
+ ### Implementation Details
47
+ - Created `core/agentic/injection-validator.ts` with `safeInject()`, `safeInjectString()`, `truncateToTokenBudget()`, `estimateTokens()`, `filterSkillsByDomains()`, and `InjectionBudgetTracker` class
48
+ - Wired validation into `prompt-builder.ts`: auto-context truncation, agent/skill token budgets, cumulative state budget tracking
49
+ - Skills filtered by detected task domains before injection to reduce token waste
50
+ - 33 new unit tests covering all validation, filtering, and truncation paths
51
+
52
+ ### Test Plan
53
+
54
+ #### For QA
55
+ 1. Run `bun test` — all 526 tests pass (33 new)
56
+ 2. Verify `safeInject()` returns fallback on corrupt data
57
+ 3. Verify `filterSkillsByDomains()` excludes irrelevant skills
58
+ 4. Verify `InjectionBudgetTracker` enforces cumulative limits
59
+
60
+ #### For Users
61
+ - No user-facing changes — validation is automatic
62
+ - Breaking changes: None
63
+
3
64
  ## [1.7.2] - 2026-02-07
4
65
 
5
66
  ### Bug Fixes
@@ -0,0 +1,294 @@
1
+ /**
2
+ * Cache Eviction Policy Tests (PRJ-288)
3
+ * Verifies TTLCache integration, context caps, and archival.
4
+ */
5
+
6
+ import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'bun:test'
7
+ import fs from 'node:fs/promises'
8
+ import path from 'node:path'
9
+ import { ContextBuilder } from '../../agentic/context-builder'
10
+ import { MemorySystem, PatternStore } from '../../agentic/memory-system'
11
+ import pathManager from '../../infrastructure/path-manager'
12
+ import { SessionLogManager } from '../../session/session-log-manager'
13
+ import { TTLCache } from '../../utils/cache'
14
+
15
+ const TEST_GLOBAL_BASE_DIR = path.join(process.cwd(), '.tmp', 'prjct-cli-cache-tests')
16
+ let testCounter = 0
17
+ const getTestProjectId = () => `test-cache-${Date.now()}-${++testCounter}`
18
+
19
+ describe('Cache Eviction Policies (PRJ-288)', () => {
20
+ beforeAll(async () => {
21
+ pathManager.setGlobalBaseDir(TEST_GLOBAL_BASE_DIR)
22
+ await fs.mkdir(TEST_GLOBAL_BASE_DIR, { recursive: true })
23
+ })
24
+
25
+ afterAll(async () => {
26
+ await fs.rm(TEST_GLOBAL_BASE_DIR, { recursive: true, force: true })
27
+ })
28
+
29
+ // ===========================================================================
30
+ // TTLCache unit tests
31
+ // ===========================================================================
32
+
33
+ describe('TTLCache', () => {
34
+ it('should evict oldest entries when maxSize exceeded', () => {
35
+ const cache = new TTLCache<string>({ maxSize: 3, ttl: 60_000 })
36
+
37
+ cache.set('a', 'val-a')
38
+ cache.set('b', 'val-b')
39
+ cache.set('c', 'val-c')
40
+ expect(cache.size).toBe(3)
41
+
42
+ cache.set('d', 'val-d')
43
+ expect(cache.size).toBe(3)
44
+ // 'a' was oldest, should be evicted
45
+ expect(cache.get('a')).toBeNull()
46
+ expect(cache.get('d')).toBe('val-d')
47
+ })
48
+
49
+ it('should expire entries after TTL', async () => {
50
+ const cache = new TTLCache<string>({ maxSize: 10, ttl: 50 })
51
+
52
+ cache.set('key', 'value')
53
+ expect(cache.get('key')).toBe('value')
54
+
55
+ await new Promise((r) => setTimeout(r, 80))
56
+ expect(cache.get('key')).toBeNull()
57
+ })
58
+
59
+ it('should prune expired entries', async () => {
60
+ const cache = new TTLCache<string>({ maxSize: 10, ttl: 50 })
61
+
62
+ cache.set('a', '1')
63
+ cache.set('b', '2')
64
+ await new Promise((r) => setTimeout(r, 80))
65
+
66
+ cache.set('c', '3') // fresh
67
+ const pruned = cache.prune()
68
+ expect(pruned).toBe(2)
69
+ expect(cache.size).toBe(1)
70
+ expect(cache.get('c')).toBe('3')
71
+ })
72
+ })
73
+
74
+ // ===========================================================================
75
+ // SessionLogManager
76
+ // ===========================================================================
77
+
78
+ describe('SessionLogManager', () => {
79
+ it('should use TTLCache with LRU eviction at 50 entries', () => {
80
+ const manager = new SessionLogManager()
81
+ // Verify clearCache works (exercises TTLCache.clear())
82
+ manager.clearCache()
83
+ })
84
+ })
85
+
86
+ // ===========================================================================
87
+ // ContextBuilder
88
+ // ===========================================================================
89
+
90
+ describe('ContextBuilder', () => {
91
+ it('should report stats from TTLCache', () => {
92
+ const builder = new ContextBuilder()
93
+ const stats = builder.getCacheStats()
94
+ expect(stats).toHaveProperty('size')
95
+ expect(stats).toHaveProperty('maxSize')
96
+ expect(stats).toHaveProperty('ttl')
97
+ expect(stats.maxSize).toBe(200)
98
+ expect(stats.ttl).toBe(5000)
99
+ })
100
+
101
+ it('should clear cache and reset projectId', () => {
102
+ const builder = new ContextBuilder()
103
+ builder.clearCache()
104
+ const stats = builder.getCacheStats()
105
+ expect(stats.size).toBe(0)
106
+ })
107
+
108
+ it('should invalidate specific cache entries', async () => {
109
+ const builder = new ContextBuilder()
110
+
111
+ // Write a temp file, batchRead to populate cache, then invalidate
112
+ const tmpDir = path.join(TEST_GLOBAL_BASE_DIR, 'ctx-test')
113
+ await fs.mkdir(tmpDir, { recursive: true })
114
+ const tmpFile = path.join(tmpDir, 'test.txt')
115
+ await fs.writeFile(tmpFile, 'hello')
116
+
117
+ const result = await builder.batchRead([tmpFile])
118
+ expect(result.get(tmpFile)).toBe('hello')
119
+
120
+ // Invalidate and verify
121
+ builder.invalidateCache(tmpFile)
122
+ // After invalidation, a fresh batchRead should re-read from disk
123
+ await fs.writeFile(tmpFile, 'updated')
124
+ const result2 = await builder.batchRead([tmpFile])
125
+ expect(result2.get(tmpFile)).toBe('updated')
126
+ })
127
+ })
128
+
129
+ // ===========================================================================
130
+ // PatternStore - contexts cap
131
+ // ===========================================================================
132
+
133
+ describe('PatternStore contexts cap', () => {
134
+ let TEST_PROJECT_ID: string
135
+
136
+ beforeEach(() => {
137
+ TEST_PROJECT_ID = getTestProjectId()
138
+ })
139
+
140
+ it('should cap decision contexts at 20 (FIFO)', async () => {
141
+ const store = new PatternStore()
142
+
143
+ // Record a decision with many unique contexts
144
+ for (let i = 0; i < 25; i++) {
145
+ await store.recordDecision(TEST_PROJECT_ID, 'test-key', 'test-value', `context-${i}`)
146
+ }
147
+
148
+ const patterns = await store.load(TEST_PROJECT_ID)
149
+ const decision = patterns.decisions['test-key']
150
+ expect(decision.contexts.length).toBeLessThanOrEqual(20)
151
+ // Should keep the latest contexts
152
+ expect(decision.contexts).toContain('context-24')
153
+ expect(decision.contexts).toContain('context-5')
154
+ // Oldest should be evicted
155
+ expect(decision.contexts).not.toContain('context-0')
156
+ })
157
+
158
+ it('should truncate oversized contexts on afterLoad', async () => {
159
+ const store = new PatternStore()
160
+ const projectId = getTestProjectId()
161
+
162
+ // Manually write a patterns file with oversized contexts
163
+ const filePath = path.join(
164
+ pathManager.getGlobalProjectPath(projectId),
165
+ 'memory',
166
+ 'patterns.json'
167
+ )
168
+ await fs.mkdir(path.dirname(filePath), { recursive: true })
169
+
170
+ const oversizedPatterns = {
171
+ version: 1,
172
+ decisions: {
173
+ 'big-key': {
174
+ value: 'v',
175
+ count: 30,
176
+ firstSeen: '2025-01-01T00:00:00.000Z',
177
+ lastSeen: '2025-06-01T00:00:00.000Z',
178
+ confidence: 'high',
179
+ contexts: Array.from({ length: 50 }, (_, i) => `ctx-${i}`),
180
+ userConfirmed: true,
181
+ },
182
+ },
183
+ preferences: {},
184
+ workflows: {},
185
+ counters: {},
186
+ }
187
+ await fs.writeFile(filePath, JSON.stringify(oversizedPatterns, null, 2), 'utf-8')
188
+
189
+ // Load triggers afterLoad which should truncate
190
+ const patterns = await store.load(projectId)
191
+ expect(patterns.decisions['big-key'].contexts.length).toBe(20)
192
+ // Should keep the latest 20 (indices 30-49)
193
+ expect(patterns.decisions['big-key'].contexts[19]).toBe('ctx-49')
194
+ })
195
+ })
196
+
197
+ // ===========================================================================
198
+ // PatternStore - archival
199
+ // ===========================================================================
200
+
201
+ describe('PatternStore archival', () => {
202
+ it('should archive decisions older than 90 days', async () => {
203
+ const store = new PatternStore()
204
+ const projectId = getTestProjectId()
205
+
206
+ // Write patterns with a stale and an active decision
207
+ const filePath = path.join(
208
+ pathManager.getGlobalProjectPath(projectId),
209
+ 'memory',
210
+ 'patterns.json'
211
+ )
212
+ await fs.mkdir(path.dirname(filePath), { recursive: true })
213
+
214
+ const now = new Date()
215
+ const staleDate = new Date(now.getTime() - 100 * 24 * 60 * 60 * 1000) // 100 days ago
216
+ const recentDate = new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000) // 5 days ago
217
+
218
+ const patterns = {
219
+ version: 1,
220
+ decisions: {
221
+ 'stale-key': {
222
+ value: 'old',
223
+ count: 3,
224
+ firstSeen: staleDate.toISOString(),
225
+ lastSeen: staleDate.toISOString(),
226
+ confidence: 'medium',
227
+ contexts: ['ctx1'],
228
+ userConfirmed: false,
229
+ },
230
+ 'active-key': {
231
+ value: 'new',
232
+ count: 5,
233
+ firstSeen: recentDate.toISOString(),
234
+ lastSeen: recentDate.toISOString(),
235
+ confidence: 'high',
236
+ contexts: ['ctx2'],
237
+ userConfirmed: true,
238
+ },
239
+ },
240
+ preferences: {},
241
+ workflows: {},
242
+ counters: {},
243
+ }
244
+ await fs.writeFile(filePath, JSON.stringify(patterns, null, 2), 'utf-8')
245
+
246
+ // Reset store cache to force disk read
247
+ store.reset()
248
+ const archived = await store.archiveStaleDecisions(projectId)
249
+ expect(archived).toBe(1)
250
+
251
+ // Verify active decision remains
252
+ const updated = await store.load(projectId)
253
+ expect(updated.decisions['active-key']).toBeDefined()
254
+ expect(updated.decisions['stale-key']).toBeUndefined()
255
+
256
+ // Verify archive file was created
257
+ const archivePath = path.join(
258
+ pathManager.getGlobalProjectPath(projectId),
259
+ 'memory',
260
+ 'patterns-archive.json'
261
+ )
262
+ const archiveContent = JSON.parse(await fs.readFile(archivePath, 'utf-8'))
263
+ expect(archiveContent['stale-key']).toBeDefined()
264
+ expect(archiveContent['stale-key'].value).toBe('old')
265
+ })
266
+
267
+ it('should return 0 when no stale decisions exist', async () => {
268
+ const store = new PatternStore()
269
+ const projectId = getTestProjectId()
270
+
271
+ // Record a fresh decision
272
+ await store.recordDecision(projectId, 'fresh-key', 'fresh-val', 'ctx')
273
+ const archived = await store.archiveStaleDecisions(projectId)
274
+ expect(archived).toBe(0)
275
+ })
276
+ })
277
+
278
+ // ===========================================================================
279
+ // MemorySystem delegation
280
+ // ===========================================================================
281
+
282
+ describe('MemorySystem.archiveStaleDecisions', () => {
283
+ it('should delegate to PatternStore', async () => {
284
+ const ms = new MemorySystem()
285
+ const projectId = getTestProjectId()
286
+
287
+ // Just verify no crash - no stale data to archive
288
+ ms.resetState()
289
+ await ms.recordDecision(projectId, 'key', 'val', 'ctx')
290
+ const count = await ms.archiveStaleDecisions(projectId)
291
+ expect(count).toBe(0)
292
+ })
293
+ })
294
+ })
@@ -0,0 +1,255 @@
1
+ /**
2
+ * Injection Validator Tests
3
+ * Tests for safeInject, truncation, skill filtering, and budget tracking.
4
+ */
5
+
6
+ import { describe, expect, it } from 'bun:test'
7
+ import { z } from 'zod'
8
+ import {
9
+ DEFAULT_BUDGETS,
10
+ estimateTokens,
11
+ filterSkillsByDomains,
12
+ InjectionBudgetTracker,
13
+ safeInject,
14
+ safeInjectString,
15
+ truncateToTokenBudget,
16
+ } from '../../agentic/injection-validator'
17
+
18
+ // =============================================================================
19
+ // safeInject
20
+ // =============================================================================
21
+
22
+ describe('safeInject', () => {
23
+ const schema = z.object({ name: z.string(), value: z.number() })
24
+ const fallback = { name: 'unknown', value: 0 }
25
+
26
+ it('returns validated data on valid input', () => {
27
+ const data = { name: 'test', value: 42 }
28
+ expect(safeInject(data, schema, fallback)).toEqual(data)
29
+ })
30
+
31
+ it('returns fallback on invalid input', () => {
32
+ const data = { name: 123, value: 'bad' }
33
+ expect(safeInject(data, schema, fallback)).toEqual(fallback)
34
+ })
35
+
36
+ it('returns fallback on null input', () => {
37
+ expect(safeInject(null, schema, fallback)).toEqual(fallback)
38
+ })
39
+
40
+ it('returns fallback on undefined input', () => {
41
+ expect(safeInject(undefined, schema, fallback)).toEqual(fallback)
42
+ })
43
+
44
+ it('strips extra fields via Zod', () => {
45
+ const data = { name: 'test', value: 42, extra: 'ignored' }
46
+ const result = safeInject(data, schema, fallback)
47
+ expect(result.name).toBe('test')
48
+ expect(result.value).toBe(42)
49
+ })
50
+ })
51
+
52
+ // =============================================================================
53
+ // safeInjectString
54
+ // =============================================================================
55
+
56
+ describe('safeInjectString', () => {
57
+ const schema = z.object({ count: z.number() })
58
+ const formatter = (d: { count: number }) => `Items: ${d.count}`
59
+
60
+ it('returns formatted string on valid input', () => {
61
+ expect(safeInjectString({ count: 5 }, schema, formatter, 'N/A')).toBe('Items: 5')
62
+ })
63
+
64
+ it('returns fallback string on invalid input', () => {
65
+ expect(safeInjectString({ count: 'bad' }, schema, formatter, 'N/A')).toBe('N/A')
66
+ })
67
+
68
+ it('returns fallback string on null', () => {
69
+ expect(safeInjectString(null, schema, formatter, 'no data')).toBe('no data')
70
+ })
71
+ })
72
+
73
+ // =============================================================================
74
+ // truncateToTokenBudget
75
+ // =============================================================================
76
+
77
+ describe('truncateToTokenBudget', () => {
78
+ it('returns text unchanged if within budget', () => {
79
+ const text = 'short text'
80
+ expect(truncateToTokenBudget(text, 100)).toBe(text)
81
+ })
82
+
83
+ it('truncates text that exceeds budget', () => {
84
+ const text = 'a'.repeat(500) // 500 chars = ~125 tokens
85
+ const result = truncateToTokenBudget(text, 50) // 50 tokens = 200 chars
86
+ expect(result.length).toBeLessThan(500)
87
+ expect(result).toContain('truncated')
88
+ expect(result).toContain('~50 tokens')
89
+ })
90
+
91
+ it('truncates to exact char limit', () => {
92
+ const text = 'a'.repeat(100)
93
+ const result = truncateToTokenBudget(text, 10) // 10 tokens = 40 chars
94
+ expect(result.startsWith('a'.repeat(40))).toBe(true)
95
+ })
96
+
97
+ it('handles empty string', () => {
98
+ expect(truncateToTokenBudget('', 100)).toBe('')
99
+ })
100
+
101
+ it('handles zero budget', () => {
102
+ const result = truncateToTokenBudget('some text', 0)
103
+ expect(result).toContain('truncated')
104
+ })
105
+ })
106
+
107
+ // =============================================================================
108
+ // estimateTokens
109
+ // =============================================================================
110
+
111
+ describe('estimateTokens', () => {
112
+ it('estimates tokens at ~4 chars per token', () => {
113
+ expect(estimateTokens('a'.repeat(100))).toBe(25)
114
+ })
115
+
116
+ it('rounds up partial tokens', () => {
117
+ expect(estimateTokens('abc')).toBe(1) // 3/4 = 0.75, ceil = 1
118
+ })
119
+
120
+ it('handles empty string', () => {
121
+ expect(estimateTokens('')).toBe(0)
122
+ })
123
+ })
124
+
125
+ // =============================================================================
126
+ // filterSkillsByDomains
127
+ // =============================================================================
128
+
129
+ describe('filterSkillsByDomains', () => {
130
+ const skills = [
131
+ { name: 'react-patterns', content: 'React component patterns and hooks' },
132
+ { name: 'api-design', content: 'RESTful API design and endpoint patterns' },
133
+ { name: 'jest-testing', content: 'Jest test patterns and assertions' },
134
+ { name: 'docker-deploy', content: 'Docker and Kubernetes deployment' },
135
+ { name: 'general-coding', content: 'General coding best practices' },
136
+ ]
137
+
138
+ it('returns all skills when no domains detected', () => {
139
+ expect(filterSkillsByDomains(skills, [])).toEqual(skills)
140
+ })
141
+
142
+ it('returns all skills when skills array is empty', () => {
143
+ expect(filterSkillsByDomains([], ['frontend'])).toEqual([])
144
+ })
145
+
146
+ it('filters to frontend-relevant skills', () => {
147
+ const result = filterSkillsByDomains(skills, ['frontend'])
148
+ expect(result.some((s) => s.name === 'react-patterns')).toBe(true)
149
+ expect(result.some((s) => s.name === 'docker-deploy')).toBe(false)
150
+ })
151
+
152
+ it('filters to backend-relevant skills', () => {
153
+ const result = filterSkillsByDomains(skills, ['backend'])
154
+ expect(result.some((s) => s.name === 'api-design')).toBe(true)
155
+ expect(result.some((s) => s.name === 'react-patterns')).toBe(false)
156
+ })
157
+
158
+ it('filters to testing-relevant skills', () => {
159
+ const result = filterSkillsByDomains(skills, ['testing'])
160
+ expect(result.some((s) => s.name === 'jest-testing')).toBe(true)
161
+ })
162
+
163
+ it('supports multiple domains', () => {
164
+ const result = filterSkillsByDomains(skills, ['frontend', 'testing'])
165
+ expect(result.some((s) => s.name === 'react-patterns')).toBe(true)
166
+ expect(result.some((s) => s.name === 'jest-testing')).toBe(true)
167
+ expect(result.some((s) => s.name === 'docker-deploy')).toBe(false)
168
+ })
169
+
170
+ it('matches domain name itself as keyword', () => {
171
+ const customSkills = [{ name: 'devops-helper', content: 'general devops tools' }]
172
+ const result = filterSkillsByDomains(customSkills, ['devops'])
173
+ expect(result).toHaveLength(1)
174
+ })
175
+
176
+ it('is case insensitive', () => {
177
+ const result = filterSkillsByDomains(skills, ['Frontend'])
178
+ expect(result.some((s) => s.name === 'react-patterns')).toBe(true)
179
+ })
180
+ })
181
+
182
+ // =============================================================================
183
+ // InjectionBudgetTracker
184
+ // =============================================================================
185
+
186
+ describe('InjectionBudgetTracker', () => {
187
+ it('tracks cumulative token usage', () => {
188
+ const tracker = new InjectionBudgetTracker({ totalPrompt: 100 })
189
+ tracker.addSection('a'.repeat(200), 100) // 200 chars = 50 tokens
190
+ expect(tracker.totalUsed).toBe(50)
191
+ expect(tracker.remaining).toBe(50)
192
+ })
193
+
194
+ it('truncates sections to per-section budget', () => {
195
+ const tracker = new InjectionBudgetTracker({ totalPrompt: 1000 })
196
+ const result = tracker.addSection('a'.repeat(500), 50) // budget: 50 tokens = 200 chars
197
+ expect(result.length).toBeLessThan(500)
198
+ })
199
+
200
+ it('returns empty string when total budget exhausted', () => {
201
+ const tracker = new InjectionBudgetTracker({ totalPrompt: 10 })
202
+ tracker.addSection('a'.repeat(100), 50) // uses all 10 tokens of total budget
203
+ const result = tracker.addSection('more content', 50)
204
+ expect(result).toBe('')
205
+ })
206
+
207
+ it('fits content to remaining total budget', () => {
208
+ const tracker = new InjectionBudgetTracker({ totalPrompt: 30 })
209
+ tracker.addSection('a'.repeat(80), 30) // 80 chars = 20 tokens
210
+ // Remaining: 10 tokens
211
+ const result = tracker.addSection('b'.repeat(200), 100) // wants 100 tokens, only 10 left
212
+ expect(result.length).toBeLessThan(200)
213
+ expect(tracker.remaining).toBe(0)
214
+ })
215
+
216
+ it('uses default budgets when none provided', () => {
217
+ const tracker = new InjectionBudgetTracker()
218
+ expect(tracker.config.totalPrompt).toBe(DEFAULT_BUDGETS.totalPrompt)
219
+ expect(tracker.config.autoContext).toBe(DEFAULT_BUDGETS.autoContext)
220
+ })
221
+
222
+ it('allows partial budget overrides', () => {
223
+ const tracker = new InjectionBudgetTracker({ totalPrompt: 5000 })
224
+ expect(tracker.config.totalPrompt).toBe(5000)
225
+ expect(tracker.config.autoContext).toBe(DEFAULT_BUDGETS.autoContext) // unchanged
226
+ })
227
+
228
+ it('remaining never goes negative', () => {
229
+ const tracker = new InjectionBudgetTracker({ totalPrompt: 5 })
230
+ tracker.addSection('a'.repeat(1000), 500)
231
+ expect(tracker.remaining).toBe(0)
232
+ })
233
+ })
234
+
235
+ // =============================================================================
236
+ // DEFAULT_BUDGETS
237
+ // =============================================================================
238
+
239
+ describe('DEFAULT_BUDGETS', () => {
240
+ it('has all required fields', () => {
241
+ expect(DEFAULT_BUDGETS.autoContext).toBeGreaterThan(0)
242
+ expect(DEFAULT_BUDGETS.agentContent).toBeGreaterThan(0)
243
+ expect(DEFAULT_BUDGETS.skillContent).toBeGreaterThan(0)
244
+ expect(DEFAULT_BUDGETS.stateData).toBeGreaterThan(0)
245
+ expect(DEFAULT_BUDGETS.memories).toBeGreaterThan(0)
246
+ expect(DEFAULT_BUDGETS.totalPrompt).toBeGreaterThan(0)
247
+ })
248
+
249
+ it('totalPrompt is larger than individual budgets', () => {
250
+ expect(DEFAULT_BUDGETS.totalPrompt).toBeGreaterThan(DEFAULT_BUDGETS.autoContext)
251
+ expect(DEFAULT_BUDGETS.totalPrompt).toBeGreaterThan(DEFAULT_BUDGETS.agentContent)
252
+ expect(DEFAULT_BUDGETS.totalPrompt).toBeGreaterThan(DEFAULT_BUDGETS.skillContent)
253
+ expect(DEFAULT_BUDGETS.totalPrompt).toBeGreaterThan(DEFAULT_BUDGETS.stateData)
254
+ })
255
+ })