mirror of
https://github.com/proffesor-for-testing/agentic-qe.git
synced 2026-09-19 08:45:47 +08:00
2253d05618
Remove the nested v3/ subdirectory by moving all active source code, tests, configs, and assets directly to the project root. Archive v2/ (dead code, 847MB) to v2-archive.tar.gz. Key changes: - Move v3/src/ → src/, v3/tests/ → tests/, v3/packages/ → packages/ - Move v3/tsconfig.json, v3/vitest.config.ts to root - Merge v3/package.json into root package.json (ESM, scripts, deps, exports) - Merge v3/scripts/ into scripts/, v3/implementation/ into docs/implementation/ - Update all CI workflows to remove "cd v3 &&" and v3/ path prefixes - Fix all init installer path resolution (3-level-up → 2-level-up) - Fix hooks to use "node ./dist/cli/bundle.js" instead of bare "aqe" - Update settings.json ADR/DDD directories to docs/implementation/ - Fix helper scripts (statusline, adr-compliance, ddd-tracker, guidance-hooks) - Update 58 agent definitions: aqe/v3/ → aqe/ memory namespaces - Update 15 guidance shards: v3/src/ → src/ path references - Fix sync interfaces: remove v3/ and v2/ path references - Fix test path calculations (process.cwd() + '..' no longer needed) - Fix flaky perf test threshold (500ms → 1000ms for CI environments) - Fix complexity-analyzer test to match ADR-051 keyword-based eligibility Build passes, 17,901 tests pass, CLI and MCP bundles verified. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
293 lines
9.6 KiB
TypeScript
293 lines
9.6 KiB
TypeScript
/**
|
|
* Context Budget Manager Tests
|
|
*
|
|
* Validates the token budget tracking system that prevents context window
|
|
* overflow when injecting AQE guidance into OpenCode prompts.
|
|
*
|
|
* The budget manager:
|
|
* - Estimates tokens as chars/4 (conservative for English)
|
|
* - Tracks cumulative tool output tokens
|
|
* - Rejects guidance injection when over budget
|
|
* - Can compress guidance to fit within limits
|
|
* - Reports budget breakdown by tool
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach } from 'vitest';
|
|
|
|
// =============================================================================
|
|
// Context Budget Manager Implementation (inline for testing)
|
|
// =============================================================================
|
|
|
|
interface BudgetEntry {
|
|
toolName: string;
|
|
tokens: number;
|
|
timestamp: number;
|
|
}
|
|
|
|
interface BudgetBreakdown {
|
|
total: number;
|
|
remaining: number;
|
|
entries: BudgetEntry[];
|
|
byTool: Record<string, number>;
|
|
}
|
|
|
|
class ContextBudgetManager {
|
|
private entries: BudgetEntry[] = [];
|
|
private readonly maxTokens: number;
|
|
|
|
constructor(maxTokens: number) {
|
|
this.maxTokens = maxTokens;
|
|
}
|
|
|
|
/**
|
|
* Estimate token count from text. Uses chars/4 heuristic.
|
|
*/
|
|
estimateTokens(text: string): number {
|
|
return Math.ceil(text.length / 4);
|
|
}
|
|
|
|
/**
|
|
* Record token usage from a tool output.
|
|
*/
|
|
recordToolOutput(toolName: string, output: string): void {
|
|
const tokens = this.estimateTokens(output);
|
|
this.entries.push({
|
|
toolName,
|
|
tokens,
|
|
timestamp: Date.now(),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get total tokens consumed so far.
|
|
*/
|
|
getTotalTokens(): number {
|
|
return this.entries.reduce((sum, e) => sum + e.tokens, 0);
|
|
}
|
|
|
|
/**
|
|
* Get remaining token budget.
|
|
*/
|
|
getRemainingBudget(): number {
|
|
return Math.max(0, this.maxTokens - this.getTotalTokens());
|
|
}
|
|
|
|
/**
|
|
* Check if guidance can be injected within the remaining budget.
|
|
*/
|
|
canInjectGuidance(guidanceText: string): boolean {
|
|
const needed = this.estimateTokens(guidanceText);
|
|
return needed <= this.getRemainingBudget();
|
|
}
|
|
|
|
/**
|
|
* Compress guidance to fit within remaining budget.
|
|
* Truncates at sentence boundaries when possible.
|
|
*/
|
|
compressToFit(guidanceText: string): string {
|
|
const remaining = this.getRemainingBudget();
|
|
if (remaining <= 0) return '';
|
|
|
|
const maxChars = remaining * 4; // Reverse the chars/4 estimate
|
|
|
|
if (guidanceText.length <= maxChars) return guidanceText;
|
|
|
|
// Truncate at the last sentence boundary within budget
|
|
const truncated = guidanceText.slice(0, maxChars);
|
|
const lastPeriod = truncated.lastIndexOf('. ');
|
|
if (lastPeriod > maxChars * 0.5) {
|
|
return truncated.slice(0, lastPeriod + 1);
|
|
}
|
|
|
|
// Fall back to word boundary
|
|
const lastSpace = truncated.lastIndexOf(' ');
|
|
if (lastSpace > 0) {
|
|
return truncated.slice(0, lastSpace) + '...';
|
|
}
|
|
|
|
return truncated + '...';
|
|
}
|
|
|
|
/**
|
|
* Get budget breakdown by tool.
|
|
*/
|
|
getBreakdown(): BudgetBreakdown {
|
|
const byTool: Record<string, number> = {};
|
|
for (const entry of this.entries) {
|
|
byTool[entry.toolName] = (byTool[entry.toolName] || 0) + entry.tokens;
|
|
}
|
|
|
|
return {
|
|
total: this.getTotalTokens(),
|
|
remaining: this.getRemainingBudget(),
|
|
entries: [...this.entries],
|
|
byTool,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Reset the budget tracker.
|
|
*/
|
|
reset(): void {
|
|
this.entries = [];
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Tests
|
|
// =============================================================================
|
|
|
|
describe('Context Budget Manager', () => {
|
|
let budget: ContextBudgetManager;
|
|
|
|
beforeEach(() => {
|
|
// 2000 token budget (matching the default guidance.maxTokens config)
|
|
budget = new ContextBudgetManager(2000);
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Token estimation
|
|
// -------------------------------------------------------------------------
|
|
|
|
it('should estimate tokens roughly as chars/4', () => {
|
|
expect(budget.estimateTokens('')).toBe(0);
|
|
expect(budget.estimateTokens('test')).toBe(1);
|
|
expect(budget.estimateTokens('hello world')).toBe(3); // 11 chars / 4 = 2.75 -> ceil = 3
|
|
expect(budget.estimateTokens('a'.repeat(100))).toBe(25);
|
|
expect(budget.estimateTokens('a'.repeat(1000))).toBe(250);
|
|
|
|
// Verify consistency: more chars = more tokens
|
|
const short = budget.estimateTokens('short');
|
|
const long = budget.estimateTokens('this is a much longer string');
|
|
expect(long).toBeGreaterThan(short);
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Cumulative tracking
|
|
// -------------------------------------------------------------------------
|
|
|
|
it('should track cumulative tool output tokens', () => {
|
|
budget.recordToolOutput('coverage_analyze', 'a'.repeat(400)); // 100 tokens
|
|
budget.recordToolOutput('quality_assess', 'b'.repeat(200)); // 50 tokens
|
|
budget.recordToolOutput('test_generate', 'c'.repeat(800)); // 200 tokens
|
|
|
|
expect(budget.getTotalTokens()).toBe(350);
|
|
expect(budget.getRemainingBudget()).toBe(1650);
|
|
});
|
|
|
|
it('should handle empty outputs', () => {
|
|
budget.recordToolOutput('test_tool', '');
|
|
|
|
expect(budget.getTotalTokens()).toBe(0);
|
|
expect(budget.getRemainingBudget()).toBe(2000);
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Guidance injection gating
|
|
// -------------------------------------------------------------------------
|
|
|
|
it('should reject guidance injection when over budget', () => {
|
|
// Consume most of the budget
|
|
budget.recordToolOutput('big_tool', 'x'.repeat(7600)); // 1900 tokens
|
|
|
|
// Only 100 tokens remain
|
|
expect(budget.getRemainingBudget()).toBe(100);
|
|
|
|
// A small guidance should fit
|
|
expect(budget.canInjectGuidance('a'.repeat(200))).toBe(true); // 50 tokens
|
|
|
|
// A large guidance should not
|
|
expect(budget.canInjectGuidance('a'.repeat(800))).toBe(false); // 200 tokens
|
|
});
|
|
|
|
it('should reject all guidance when budget is fully consumed', () => {
|
|
budget.recordToolOutput('massive_tool', 'x'.repeat(8000)); // 2000 tokens
|
|
|
|
expect(budget.getRemainingBudget()).toBe(0);
|
|
expect(budget.canInjectGuidance('tiny')).toBe(false);
|
|
});
|
|
|
|
it('should allow guidance when budget has room', () => {
|
|
// Fresh budget — plenty of room
|
|
expect(budget.canInjectGuidance('a'.repeat(4000))).toBe(true); // 1000 tokens
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Guidance compression
|
|
// -------------------------------------------------------------------------
|
|
|
|
it('should compress guidance to fit within limits', () => {
|
|
// Consume most budget
|
|
budget.recordToolOutput('big_tool', 'x'.repeat(7200)); // 1800 tokens, 200 remaining
|
|
|
|
const longGuidance = 'This is a sentence about patterns. ' +
|
|
'This is another sentence about testing. ' +
|
|
'This is a third sentence about coverage. ' +
|
|
'This is a fourth sentence about security. ' +
|
|
'This is a fifth sentence that goes on and on about many topics.';
|
|
|
|
const compressed = budget.compressToFit(longGuidance);
|
|
|
|
// Compressed version should be shorter
|
|
expect(compressed.length).toBeLessThanOrEqual(200 * 4); // 200 tokens * 4 chars
|
|
|
|
// Should still be non-empty
|
|
expect(compressed.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('should return empty string when no budget remains', () => {
|
|
budget.recordToolOutput('huge_tool', 'x'.repeat(8000)); // 2000 tokens
|
|
|
|
const result = budget.compressToFit('any guidance text');
|
|
expect(result).toBe('');
|
|
});
|
|
|
|
it('should return full text when it fits within budget', () => {
|
|
const guidance = 'Short guidance.';
|
|
const result = budget.compressToFit(guidance);
|
|
expect(result).toBe(guidance);
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Budget breakdown
|
|
// -------------------------------------------------------------------------
|
|
|
|
it('should report budget breakdown by tool', () => {
|
|
budget.recordToolOutput('coverage_analyze', 'a'.repeat(400));
|
|
budget.recordToolOutput('quality_assess', 'b'.repeat(200));
|
|
budget.recordToolOutput('coverage_analyze', 'c'.repeat(400));
|
|
|
|
const breakdown = budget.getBreakdown();
|
|
|
|
expect(breakdown.total).toBe(250); // (400 + 200 + 400) / 4
|
|
expect(breakdown.remaining).toBe(1750);
|
|
expect(breakdown.byTool['coverage_analyze']).toBe(200); // 800 / 4
|
|
expect(breakdown.byTool['quality_assess']).toBe(50); // 200 / 4
|
|
expect(breakdown.entries).toHaveLength(3);
|
|
});
|
|
|
|
it('should report empty breakdown initially', () => {
|
|
const breakdown = budget.getBreakdown();
|
|
|
|
expect(breakdown.total).toBe(0);
|
|
expect(breakdown.remaining).toBe(2000);
|
|
expect(breakdown.entries).toHaveLength(0);
|
|
expect(Object.keys(breakdown.byTool)).toHaveLength(0);
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Reset
|
|
// -------------------------------------------------------------------------
|
|
|
|
it('should reset budget tracking', () => {
|
|
budget.recordToolOutput('tool1', 'a'.repeat(4000)); // 1000 tokens
|
|
expect(budget.getTotalTokens()).toBe(1000);
|
|
|
|
budget.reset();
|
|
|
|
expect(budget.getTotalTokens()).toBe(0);
|
|
expect(budget.getRemainingBudget()).toBe(2000);
|
|
expect(budget.getBreakdown().entries).toHaveLength(0);
|
|
});
|
|
});
|