mirror of
https://github.com/samiyev/puaros.git
synced 2025-12-27 23:06:54 +05:00
- Add ISessionStorage interface and RedisSessionStorage implementation - Add ContextManager for token budget and compression - Add StartSession, HandleMessage, UndoChange use cases - Update CHANGELOG and TODO documentation - 88 new tests (1174 total), 97.73% coverage
249 lines
8.4 KiB
TypeScript
249 lines
8.4 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest"
|
|
import { ContextManager } from "../../../../src/application/use-cases/ContextManager.js"
|
|
import { Session } from "../../../../src/domain/entities/Session.js"
|
|
import type { ILLMClient, LLMResponse } from "../../../../src/domain/services/ILLMClient.js"
|
|
import {
|
|
createUserMessage,
|
|
createAssistantMessage,
|
|
} from "../../../../src/domain/value-objects/ChatMessage.js"
|
|
|
|
describe("ContextManager", () => {
|
|
let manager: ContextManager
|
|
const CONTEXT_SIZE = 128_000
|
|
|
|
beforeEach(() => {
|
|
manager = new ContextManager(CONTEXT_SIZE)
|
|
})
|
|
|
|
describe("addToContext", () => {
|
|
it("should add file to context", () => {
|
|
manager.addToContext("test.ts", 100)
|
|
|
|
expect(manager.getFilesInContext()).toContain("test.ts")
|
|
expect(manager.getTokenCount()).toBe(100)
|
|
})
|
|
|
|
it("should update token count when same file added", () => {
|
|
manager.addToContext("test.ts", 100)
|
|
manager.addToContext("test.ts", 200)
|
|
|
|
expect(manager.getFilesInContext()).toHaveLength(1)
|
|
expect(manager.getTokenCount()).toBe(200)
|
|
})
|
|
|
|
it("should accumulate tokens for different files", () => {
|
|
manager.addToContext("a.ts", 100)
|
|
manager.addToContext("b.ts", 200)
|
|
|
|
expect(manager.getFilesInContext()).toHaveLength(2)
|
|
expect(manager.getTokenCount()).toBe(300)
|
|
})
|
|
})
|
|
|
|
describe("removeFromContext", () => {
|
|
it("should remove file from context", () => {
|
|
manager.addToContext("test.ts", 100)
|
|
manager.removeFromContext("test.ts")
|
|
|
|
expect(manager.getFilesInContext()).not.toContain("test.ts")
|
|
expect(manager.getTokenCount()).toBe(0)
|
|
})
|
|
|
|
it("should handle removing non-existent file", () => {
|
|
manager.removeFromContext("non-existent.ts")
|
|
|
|
expect(manager.getTokenCount()).toBe(0)
|
|
})
|
|
})
|
|
|
|
describe("getUsage", () => {
|
|
it("should return 0 for empty context", () => {
|
|
expect(manager.getUsage()).toBe(0)
|
|
})
|
|
|
|
it("should calculate usage ratio correctly", () => {
|
|
manager.addToContext("test.ts", CONTEXT_SIZE / 2)
|
|
|
|
expect(manager.getUsage()).toBe(0.5)
|
|
})
|
|
})
|
|
|
|
describe("getAvailableTokens", () => {
|
|
it("should return full context when empty", () => {
|
|
expect(manager.getAvailableTokens()).toBe(CONTEXT_SIZE)
|
|
})
|
|
|
|
it("should calculate available tokens correctly", () => {
|
|
manager.addToContext("test.ts", 1000)
|
|
|
|
expect(manager.getAvailableTokens()).toBe(CONTEXT_SIZE - 1000)
|
|
})
|
|
})
|
|
|
|
describe("needsCompression", () => {
|
|
it("should return false when under threshold", () => {
|
|
manager.addToContext("test.ts", CONTEXT_SIZE * 0.5)
|
|
|
|
expect(manager.needsCompression()).toBe(false)
|
|
})
|
|
|
|
it("should return true when over threshold", () => {
|
|
manager.addToContext("test.ts", CONTEXT_SIZE * 0.85)
|
|
|
|
expect(manager.needsCompression()).toBe(true)
|
|
})
|
|
|
|
it("should return false at exactly threshold", () => {
|
|
manager.addToContext("test.ts", CONTEXT_SIZE * 0.8)
|
|
|
|
expect(manager.needsCompression()).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe("addTokens", () => {
|
|
it("should add tokens to current count", () => {
|
|
manager.addTokens(500)
|
|
|
|
expect(manager.getTokenCount()).toBe(500)
|
|
})
|
|
|
|
it("should accumulate tokens", () => {
|
|
manager.addTokens(100)
|
|
manager.addTokens(200)
|
|
|
|
expect(manager.getTokenCount()).toBe(300)
|
|
})
|
|
})
|
|
|
|
describe("syncFromSession", () => {
|
|
it("should sync files from session context", () => {
|
|
const session = new Session("test", "project")
|
|
session.context.filesInContext = ["a.ts", "b.ts"]
|
|
session.context.tokenUsage = 0.5
|
|
|
|
manager.syncFromSession(session)
|
|
|
|
expect(manager.getFilesInContext()).toContain("a.ts")
|
|
expect(manager.getFilesInContext()).toContain("b.ts")
|
|
expect(manager.getTokenCount()).toBe(Math.floor(0.5 * CONTEXT_SIZE))
|
|
})
|
|
|
|
it("should clear previous state on sync", () => {
|
|
manager.addToContext("old.ts", 1000)
|
|
|
|
const session = new Session("test", "project")
|
|
session.context.filesInContext = ["new.ts"]
|
|
session.context.tokenUsage = 0.1
|
|
|
|
manager.syncFromSession(session)
|
|
|
|
expect(manager.getFilesInContext()).not.toContain("old.ts")
|
|
expect(manager.getFilesInContext()).toContain("new.ts")
|
|
})
|
|
})
|
|
|
|
describe("updateSession", () => {
|
|
it("should update session with current context state", () => {
|
|
const session = new Session("test", "project")
|
|
|
|
manager.addToContext("test.ts", 1000)
|
|
manager.updateSession(session)
|
|
|
|
expect(session.context.filesInContext).toContain("test.ts")
|
|
expect(session.context.tokenUsage).toBeCloseTo(1000 / CONTEXT_SIZE)
|
|
})
|
|
|
|
it("should set needsCompression flag", () => {
|
|
const session = new Session("test", "project")
|
|
|
|
manager.addToContext("large.ts", CONTEXT_SIZE * 0.9)
|
|
manager.updateSession(session)
|
|
|
|
expect(session.context.needsCompression).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe("compress", () => {
|
|
let mockLLM: ILLMClient
|
|
let session: Session
|
|
|
|
beforeEach(() => {
|
|
mockLLM = {
|
|
chat: vi.fn().mockResolvedValue({
|
|
content: "Summary of previous conversation",
|
|
toolCalls: [],
|
|
tokens: 50,
|
|
timeMs: 100,
|
|
truncated: false,
|
|
stopReason: "end",
|
|
} as LLMResponse),
|
|
countTokens: vi.fn().mockResolvedValue(10),
|
|
isAvailable: vi.fn().mockResolvedValue(true),
|
|
getModelName: vi.fn().mockReturnValue("test-model"),
|
|
getContextWindowSize: vi.fn().mockReturnValue(CONTEXT_SIZE),
|
|
pullModel: vi.fn().mockResolvedValue(undefined),
|
|
abort: vi.fn(),
|
|
}
|
|
|
|
session = new Session("test", "project")
|
|
})
|
|
|
|
it("should not compress when history is short", async () => {
|
|
for (let i = 0; i < 5; i++) {
|
|
session.addMessage(createUserMessage(`Message ${String(i)}`))
|
|
}
|
|
|
|
const result = await manager.compress(session, mockLLM)
|
|
|
|
expect(result.compressed).toBe(false)
|
|
expect(result.removedMessages).toBe(0)
|
|
})
|
|
|
|
it("should compress when history is long enough", async () => {
|
|
for (let i = 0; i < 15; i++) {
|
|
session.addMessage(createUserMessage(`Message ${String(i)}`))
|
|
session.addMessage(createAssistantMessage(`Response ${String(i)}`))
|
|
}
|
|
manager.addToContext("test.ts", 10000)
|
|
|
|
const result = await manager.compress(session, mockLLM)
|
|
|
|
expect(result.compressed).toBe(true)
|
|
expect(result.removedMessages).toBeGreaterThan(0)
|
|
expect(result.summary).toBeDefined()
|
|
})
|
|
|
|
it("should keep recent messages after compression", async () => {
|
|
for (let i = 0; i < 15; i++) {
|
|
session.addMessage(createUserMessage(`Message ${String(i)}`))
|
|
}
|
|
|
|
await manager.compress(session, mockLLM)
|
|
|
|
expect(session.history.length).toBeLessThan(15)
|
|
expect(session.history[session.history.length - 1].content).toContain("Message 14")
|
|
})
|
|
|
|
it("should add summary as system message", async () => {
|
|
for (let i = 0; i < 15; i++) {
|
|
session.addMessage(createUserMessage(`Message ${String(i)}`))
|
|
}
|
|
|
|
await manager.compress(session, mockLLM)
|
|
|
|
expect(session.history[0].role).toBe("system")
|
|
expect(session.history[0].content).toContain("Summary")
|
|
})
|
|
})
|
|
|
|
describe("createInitialState", () => {
|
|
it("should create empty initial state", () => {
|
|
const state = ContextManager.createInitialState()
|
|
|
|
expect(state.filesInContext).toEqual([])
|
|
expect(state.tokenUsage).toBe(0)
|
|
expect(state.needsCompression).toBe(false)
|
|
})
|
|
})
|
|
})
|