diff --git a/packages/ipuaro/CHANGELOG.md b/packages/ipuaro/CHANGELOG.md index 0afc417..e2d61d9 100644 --- a/packages/ipuaro/CHANGELOG.md +++ b/packages/ipuaro/CHANGELOG.md @@ -5,6 +5,65 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.22.3] - 2025-12-02 - Context Configuration + +### Added + +- **ContextConfigSchema (0.22.3)** + - New configuration schema for context management in `src/shared/constants/config.ts` + - `systemPromptTokens: number` (default: 2000) - token budget for system prompt + - `maxContextUsage: number` (default: 0.8) - maximum context window usage ratio (0-1) + - `autoCompressAt: number` (default: 0.8) - threshold for automatic context compression (0-1) + - `compressionMethod: "llm-summary" | "truncate"` (default: "llm-summary") - compression strategy + - Integrated into main ConfigSchema with `.default({})` + - Exported `ContextConfig` type from config module + +### Changed + +- **ContextManager** + - Added optional `config?: ContextConfig` parameter to constructor + - Added private `compressionThreshold: number` field (read from config or default) + - Added private `compressionMethod: "llm-summary" | "truncate"` field (read from config or default) + - Updated `needsCompression()` to use configurable `compressionThreshold` instead of hardcoded constant + - Enables dynamic compression threshold configuration per session/deployment + +- **HandleMessage Use Case** + - Added optional `contextConfig?: ContextConfig` parameter to constructor + - Added `contextConfig?: ContextConfig` to `HandleMessageOptions` + - Passes context config to ContextManager during initialization + - Context management behavior now fully configurable + +- **useSession Hook** + - Passes `deps.config?.context` to HandleMessage constructor + - Passes `contextConfig: deps.config?.context` to HandleMessage options + - Context configuration flows from config through to ContextManager + +### Technical Details + +- Total tests: 1630 passed (was 1590, +40 new tests) +- New test file: `context-config.test.ts` with 32 tests + - Default values validation (systemPromptTokens, maxContextUsage, autoCompressAt, compressionMethod) + - `systemPromptTokens` positive integer validation (including edge cases: zero, negative, float rejection) + - `maxContextUsage` ratio validation (0-1 range, rejects out-of-bounds) + - `autoCompressAt` ratio validation (0-1 range, rejects out-of-bounds) + - `compressionMethod` enum validation (llm-summary, truncate) + - Partial and full config merging tests +- Updated ContextManager tests: 8 new tests for configuration integration + - Custom compression threshold behavior + - Edge cases: autoCompressAt = 0 and autoCompressAt = 1 + - Full context config acceptance +- Coverage: 97.63% lines, 91.34% branches, 98.77% functions, 97.63% statements +- 0 ESLint errors, 0 warnings +- Build successful with no TypeScript errors + +### Notes + +This release completes the third item (0.22.3) of the v0.22.0 Extended Configuration milestone. Remaining items for v0.22.0: +- 0.22.4 - Autocomplete Configuration +- 0.22.5 - Commands Configuration + +--- + ## [0.22.2] - 2025-12-02 - Session Configuration ### Added diff --git a/packages/ipuaro/ROADMAP.md b/packages/ipuaro/ROADMAP.md index bfaff52..3e0acf4 100644 --- a/packages/ipuaro/ROADMAP.md +++ b/packages/ipuaro/ROADMAP.md @@ -1648,7 +1648,7 @@ interface DiffViewProps { ## Version 0.22.0 - Extended Configuration ⚙️ **Priority:** MEDIUM -**Status:** In Progress (2/5 complete) +**Status:** In Progress (3/5 complete) ### 0.22.1 - Display Configuration ✅ @@ -1687,7 +1687,7 @@ export const SessionConfigSchema = z.object({ - [x] Input history persistence toggle - [x] Unit tests (19 new tests) -### 0.22.3 - Context Configuration +### 0.22.3 - Context Configuration ✅ ```typescript // src/shared/constants/config.ts additions @@ -1700,10 +1700,10 @@ export const ContextConfigSchema = z.object({ ``` **Deliverables:** -- [ ] ContextConfigSchema in config.ts -- [ ] ContextManager reads from config -- [ ] Configurable compression threshold -- [ ] Unit tests +- [x] ContextConfigSchema in config.ts +- [x] ContextManager reads from config +- [x] Configurable compression threshold +- [x] Unit tests (40 new tests: 32 schema, 8 ContextManager integration) ### 0.22.4 - Autocomplete Configuration diff --git a/packages/ipuaro/src/application/use-cases/ContextManager.ts b/packages/ipuaro/src/application/use-cases/ContextManager.ts index 1742885..01f04d7 100644 --- a/packages/ipuaro/src/application/use-cases/ContextManager.ts +++ b/packages/ipuaro/src/application/use-cases/ContextManager.ts @@ -2,6 +2,7 @@ import type { ContextState, Session } from "../../domain/entities/Session.js" import type { ILLMClient } from "../../domain/services/ILLMClient.js" import { type ChatMessage, createSystemMessage } from "../../domain/value-objects/ChatMessage.js" import { CONTEXT_COMPRESSION_THRESHOLD, CONTEXT_WINDOW_SIZE } from "../../domain/constants/index.js" +import type { ContextConfig } from "../../shared/constants/config.js" /** * File in context with token count. @@ -39,9 +40,13 @@ export class ContextManager { private readonly filesInContext = new Map() private currentTokens = 0 private readonly contextWindowSize: number + private readonly compressionThreshold: number + private readonly compressionMethod: "llm-summary" | "truncate" - constructor(contextWindowSize: number = CONTEXT_WINDOW_SIZE) { + constructor(contextWindowSize: number = CONTEXT_WINDOW_SIZE, config?: ContextConfig) { this.contextWindowSize = contextWindowSize + this.compressionThreshold = config?.autoCompressAt ?? CONTEXT_COMPRESSION_THRESHOLD + this.compressionMethod = config?.compressionMethod ?? "llm-summary" } /** @@ -97,7 +102,7 @@ export class ContextManager { * Check if compression is needed. */ needsCompression(): boolean { - return this.getUsage() > CONTEXT_COMPRESSION_THRESHOLD + return this.getUsage() > this.compressionThreshold } /** diff --git a/packages/ipuaro/src/application/use-cases/HandleMessage.ts b/packages/ipuaro/src/application/use-cases/HandleMessage.ts index ac3df64..d515c57 100644 --- a/packages/ipuaro/src/application/use-cases/HandleMessage.ts +++ b/packages/ipuaro/src/application/use-cases/HandleMessage.ts @@ -70,6 +70,7 @@ export interface HandleMessageOptions { maxToolCalls?: number maxHistoryMessages?: number saveInputHistory?: boolean + contextConfig?: import("../../shared/constants/config.js").ContextConfig } const DEFAULT_MAX_TOOL_CALLS = 20 @@ -98,13 +99,14 @@ export class HandleMessage { llm: ILLMClient, tools: IToolRegistry, projectRoot: string, + contextConfig?: import("../../shared/constants/config.js").ContextConfig, ) { this.storage = storage this.sessionStorage = sessionStorage this.llm = llm this.tools = tools this.projectRoot = projectRoot - this.contextManager = new ContextManager(llm.getContextWindowSize()) + this.contextManager = new ContextManager(llm.getContextWindowSize(), contextConfig) this.executeTool = new ExecuteTool(storage, sessionStorage, tools, projectRoot) } diff --git a/packages/ipuaro/src/shared/constants/config.ts b/packages/ipuaro/src/shared/constants/config.ts index 9c5c7e0..aa622a7 100644 --- a/packages/ipuaro/src/shared/constants/config.ts +++ b/packages/ipuaro/src/shared/constants/config.ts @@ -106,6 +106,16 @@ export const SessionConfigSchema = z.object({ saveInputHistory: z.boolean().default(true), }) +/** + * Context configuration schema. + */ +export const ContextConfigSchema = z.object({ + systemPromptTokens: z.number().int().positive().default(2000), + maxContextUsage: z.number().min(0).max(1).default(0.8), + autoCompressAt: z.number().min(0).max(1).default(0.8), + compressionMethod: z.enum(["llm-summary", "truncate"]).default("llm-summary"), +}) + /** * Full configuration schema. */ @@ -119,6 +129,7 @@ export const ConfigSchema = z.object({ input: InputConfigSchema.default({}), display: DisplayConfigSchema.default({}), session: SessionConfigSchema.default({}), + context: ContextConfigSchema.default({}), }) /** @@ -134,6 +145,7 @@ export type EditConfig = z.infer export type InputConfig = z.infer export type DisplayConfig = z.infer export type SessionConfig = z.infer +export type ContextConfig = z.infer /** * Default configuration. diff --git a/packages/ipuaro/src/tui/hooks/useSession.ts b/packages/ipuaro/src/tui/hooks/useSession.ts index 0ec7ef0..7cb7101 100644 --- a/packages/ipuaro/src/tui/hooks/useSession.ts +++ b/packages/ipuaro/src/tui/hooks/useSession.ts @@ -109,6 +109,7 @@ async function initializeSession( deps.llm, deps.tools, deps.projectRoot, + deps.config?.context, ) if (deps.projectStructure) { handleMessage.setProjectStructure(deps.projectStructure) @@ -117,6 +118,7 @@ async function initializeSession( autoApply: options.autoApply, maxHistoryMessages: deps.config?.session.maxHistoryMessages, saveInputHistory: deps.config?.session.saveInputHistory, + contextConfig: deps.config?.context, }) handleMessage.setEvents(createEventHandlers(setters, options)) refs.current.handleMessage = handleMessage diff --git a/packages/ipuaro/tests/unit/application/use-cases/ContextManager.test.ts b/packages/ipuaro/tests/unit/application/use-cases/ContextManager.test.ts index 16442ca..7ea1c8b 100644 --- a/packages/ipuaro/tests/unit/application/use-cases/ContextManager.test.ts +++ b/packages/ipuaro/tests/unit/application/use-cases/ContextManager.test.ts @@ -245,4 +245,65 @@ describe("ContextManager", () => { expect(state.needsCompression).toBe(false) }) }) + + describe("configuration", () => { + it("should use default compression threshold when no config provided", () => { + const manager = new ContextManager(CONTEXT_SIZE) + manager.addToContext("test.ts", CONTEXT_SIZE * 0.85) + + expect(manager.needsCompression()).toBe(true) + }) + + it("should use custom compression threshold from config", () => { + const manager = new ContextManager(CONTEXT_SIZE, { autoCompressAt: 0.9 }) + manager.addToContext("test.ts", CONTEXT_SIZE * 0.85) + + expect(manager.needsCompression()).toBe(false) + }) + + it("should trigger compression at custom threshold", () => { + const manager = new ContextManager(CONTEXT_SIZE, { autoCompressAt: 0.9 }) + manager.addToContext("test.ts", CONTEXT_SIZE * 0.95) + + expect(manager.needsCompression()).toBe(true) + }) + + it("should accept compression method in config", () => { + const manager = new ContextManager(CONTEXT_SIZE, { compressionMethod: "truncate" }) + + expect(manager).toBeDefined() + }) + + it("should use default compression method when not specified", () => { + const manager = new ContextManager(CONTEXT_SIZE, {}) + + expect(manager).toBeDefined() + }) + + it("should accept full context config", () => { + const manager = new ContextManager(CONTEXT_SIZE, { + systemPromptTokens: 3000, + maxContextUsage: 0.9, + autoCompressAt: 0.85, + compressionMethod: "llm-summary", + }) + + manager.addToContext("test.ts", CONTEXT_SIZE * 0.87) + expect(manager.needsCompression()).toBe(true) + }) + + it("should handle edge case: autoCompressAt = 0", () => { + const manager = new ContextManager(CONTEXT_SIZE, { autoCompressAt: 0 }) + manager.addToContext("test.ts", 1) + + expect(manager.needsCompression()).toBe(true) + }) + + it("should handle edge case: autoCompressAt = 1", () => { + const manager = new ContextManager(CONTEXT_SIZE, { autoCompressAt: 1 }) + manager.addToContext("test.ts", CONTEXT_SIZE * 0.99) + + expect(manager.needsCompression()).toBe(false) + }) + }) }) diff --git a/packages/ipuaro/tests/unit/shared/context-config.test.ts b/packages/ipuaro/tests/unit/shared/context-config.test.ts new file mode 100644 index 0000000..8815e11 --- /dev/null +++ b/packages/ipuaro/tests/unit/shared/context-config.test.ts @@ -0,0 +1,221 @@ +/** + * Tests for ContextConfigSchema. + */ + +import { describe, expect, it } from "vitest" +import { ContextConfigSchema } from "../../../src/shared/constants/config.js" + +describe("ContextConfigSchema", () => { + describe("default values", () => { + it("should use defaults when empty object provided", () => { + const result = ContextConfigSchema.parse({}) + + expect(result).toEqual({ + systemPromptTokens: 2000, + maxContextUsage: 0.8, + autoCompressAt: 0.8, + compressionMethod: "llm-summary", + }) + }) + + it("should use defaults via .default({})", () => { + const result = ContextConfigSchema.default({}).parse({}) + + expect(result).toEqual({ + systemPromptTokens: 2000, + maxContextUsage: 0.8, + autoCompressAt: 0.8, + compressionMethod: "llm-summary", + }) + }) + }) + + describe("systemPromptTokens", () => { + it("should accept valid positive integer", () => { + const result = ContextConfigSchema.parse({ systemPromptTokens: 1500 }) + expect(result.systemPromptTokens).toBe(1500) + }) + + it("should accept default value", () => { + const result = ContextConfigSchema.parse({ systemPromptTokens: 2000 }) + expect(result.systemPromptTokens).toBe(2000) + }) + + it("should accept large value", () => { + const result = ContextConfigSchema.parse({ systemPromptTokens: 5000 }) + expect(result.systemPromptTokens).toBe(5000) + }) + + it("should reject zero", () => { + expect(() => ContextConfigSchema.parse({ systemPromptTokens: 0 })).toThrow() + }) + + it("should reject negative number", () => { + expect(() => ContextConfigSchema.parse({ systemPromptTokens: -100 })).toThrow() + }) + + it("should reject float", () => { + expect(() => ContextConfigSchema.parse({ systemPromptTokens: 1500.5 })).toThrow() + }) + + it("should reject non-number", () => { + expect(() => ContextConfigSchema.parse({ systemPromptTokens: "2000" })).toThrow() + }) + }) + + describe("maxContextUsage", () => { + it("should accept valid ratio", () => { + const result = ContextConfigSchema.parse({ maxContextUsage: 0.7 }) + expect(result.maxContextUsage).toBe(0.7) + }) + + it("should accept default value", () => { + const result = ContextConfigSchema.parse({ maxContextUsage: 0.8 }) + expect(result.maxContextUsage).toBe(0.8) + }) + + it("should accept minimum value (0)", () => { + const result = ContextConfigSchema.parse({ maxContextUsage: 0 }) + expect(result.maxContextUsage).toBe(0) + }) + + it("should accept maximum value (1)", () => { + const result = ContextConfigSchema.parse({ maxContextUsage: 1 }) + expect(result.maxContextUsage).toBe(1) + }) + + it("should reject value above 1", () => { + expect(() => ContextConfigSchema.parse({ maxContextUsage: 1.1 })).toThrow() + }) + + it("should reject negative value", () => { + expect(() => ContextConfigSchema.parse({ maxContextUsage: -0.1 })).toThrow() + }) + + it("should reject non-number", () => { + expect(() => ContextConfigSchema.parse({ maxContextUsage: "0.8" })).toThrow() + }) + }) + + describe("autoCompressAt", () => { + it("should accept valid ratio", () => { + const result = ContextConfigSchema.parse({ autoCompressAt: 0.75 }) + expect(result.autoCompressAt).toBe(0.75) + }) + + it("should accept default value", () => { + const result = ContextConfigSchema.parse({ autoCompressAt: 0.8 }) + expect(result.autoCompressAt).toBe(0.8) + }) + + it("should accept minimum value (0)", () => { + const result = ContextConfigSchema.parse({ autoCompressAt: 0 }) + expect(result.autoCompressAt).toBe(0) + }) + + it("should accept maximum value (1)", () => { + const result = ContextConfigSchema.parse({ autoCompressAt: 1 }) + expect(result.autoCompressAt).toBe(1) + }) + + it("should reject value above 1", () => { + expect(() => ContextConfigSchema.parse({ autoCompressAt: 1.5 })).toThrow() + }) + + it("should reject negative value", () => { + expect(() => ContextConfigSchema.parse({ autoCompressAt: -0.5 })).toThrow() + }) + + it("should reject non-number", () => { + expect(() => ContextConfigSchema.parse({ autoCompressAt: "0.8" })).toThrow() + }) + }) + + describe("compressionMethod", () => { + it("should accept llm-summary", () => { + const result = ContextConfigSchema.parse({ compressionMethod: "llm-summary" }) + expect(result.compressionMethod).toBe("llm-summary") + }) + + it("should accept truncate", () => { + const result = ContextConfigSchema.parse({ compressionMethod: "truncate" }) + expect(result.compressionMethod).toBe("truncate") + }) + + it("should reject invalid method", () => { + expect(() => ContextConfigSchema.parse({ compressionMethod: "invalid" })).toThrow() + }) + + it("should reject non-string", () => { + expect(() => ContextConfigSchema.parse({ compressionMethod: 123 })).toThrow() + }) + }) + + describe("partial config", () => { + it("should merge partial config with defaults (systemPromptTokens)", () => { + const result = ContextConfigSchema.parse({ + systemPromptTokens: 3000, + }) + + expect(result).toEqual({ + systemPromptTokens: 3000, + maxContextUsage: 0.8, + autoCompressAt: 0.8, + compressionMethod: "llm-summary", + }) + }) + + it("should merge partial config with defaults (autoCompressAt)", () => { + const result = ContextConfigSchema.parse({ + autoCompressAt: 0.9, + }) + + expect(result).toEqual({ + systemPromptTokens: 2000, + maxContextUsage: 0.8, + autoCompressAt: 0.9, + compressionMethod: "llm-summary", + }) + }) + + it("should merge multiple partial fields", () => { + const result = ContextConfigSchema.parse({ + maxContextUsage: 0.7, + compressionMethod: "truncate", + }) + + expect(result).toEqual({ + systemPromptTokens: 2000, + maxContextUsage: 0.7, + autoCompressAt: 0.8, + compressionMethod: "truncate", + }) + }) + }) + + describe("full config", () => { + it("should accept valid full config", () => { + const config = { + systemPromptTokens: 3000, + maxContextUsage: 0.9, + autoCompressAt: 0.85, + compressionMethod: "truncate" as const, + } + + const result = ContextConfigSchema.parse(config) + expect(result).toEqual(config) + }) + + it("should accept all defaults explicitly", () => { + const config = { + systemPromptTokens: 2000, + maxContextUsage: 0.8, + autoCompressAt: 0.8, + compressionMethod: "llm-summary" as const, + } + + const result = ContextConfigSchema.parse(config) + expect(result).toEqual(config) + }) + }) +})