From 0f2ed5b301f98074f56a8d601e94a65acd19c5a6 Mon Sep 17 00:00:00 2001 From: imfozilbek Date: Mon, 1 Dec 2025 12:27:22 +0500 Subject: [PATCH] feat(ipuaro): add session management (v0.10.0) - 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 --- packages/ipuaro/CHANGELOG.md | 57 +++ packages/ipuaro/TODO.md | 113 +++-- .../application/use-cases/ContextManager.ts | 229 ++++++++++ .../application/use-cases/HandleMessage.ts | 383 ++++++++++++++++ .../src/application/use-cases/StartSession.ts | 62 +++ .../src/application/use-cases/UndoChange.ts | 119 +++++ .../ipuaro/src/application/use-cases/index.ts | 10 +- .../src/domain/services/ISessionStorage.ts | 88 ++++ packages/ipuaro/src/domain/services/index.ts | 1 + .../storage/RedisSessionStorage.ts | 225 ++++++++++ .../src/infrastructure/storage/index.ts | 1 + .../infrastructure/tools/run/RunTestsTool.ts | 182 ++++---- .../use-cases/ContextManager.test.ts | 248 +++++++++++ .../use-cases/HandleMessage.test.ts | 421 ++++++++++++++++++ .../use-cases/StartSession.test.ts | 112 +++++ .../application/use-cases/UndoChange.test.ts | 234 ++++++++++ .../storage/RedisSessionStorage.test.ts | 390 ++++++++++++++++ .../tools/git/GitCommitTool.test.ts | 97 +--- .../tools/git/GitDiffTool.test.ts | 14 +- .../tools/run/CommandSecurity.test.ts | 8 +- .../tools/run/RunCommandTool.test.ts | 56 +-- .../tools/run/RunTestsTool.test.ts | 9 +- 22 files changed, 2798 insertions(+), 261 deletions(-) create mode 100644 packages/ipuaro/src/application/use-cases/ContextManager.ts create mode 100644 packages/ipuaro/src/application/use-cases/HandleMessage.ts create mode 100644 packages/ipuaro/src/application/use-cases/StartSession.ts create mode 100644 packages/ipuaro/src/application/use-cases/UndoChange.ts create mode 100644 packages/ipuaro/src/domain/services/ISessionStorage.ts create mode 100644 packages/ipuaro/src/infrastructure/storage/RedisSessionStorage.ts create mode 100644 packages/ipuaro/tests/unit/application/use-cases/ContextManager.test.ts create mode 100644 packages/ipuaro/tests/unit/application/use-cases/HandleMessage.test.ts create mode 100644 packages/ipuaro/tests/unit/application/use-cases/StartSession.test.ts create mode 100644 packages/ipuaro/tests/unit/application/use-cases/UndoChange.test.ts create mode 100644 packages/ipuaro/tests/unit/infrastructure/storage/RedisSessionStorage.test.ts diff --git a/packages/ipuaro/CHANGELOG.md b/packages/ipuaro/CHANGELOG.md index 4dbd1ba..27cd598 100644 --- a/packages/ipuaro/CHANGELOG.md +++ b/packages/ipuaro/CHANGELOG.md @@ -5,6 +5,63 @@ 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.10.0] - 2025-12-01 - Session Management + +### Added + +- **ISessionStorage (0.10.1)** + - Session storage service interface + - Methods: saveSession, loadSession, deleteSession, listSessions + - Undo stack management: pushUndoEntry, popUndoEntry, getUndoStack + - Session lifecycle: getLatestSession, sessionExists, touchSession + +- **RedisSessionStorage (0.10.2)** + - Redis implementation of ISessionStorage + - Session data in Redis hashes (project, history, context, stats) + - Undo stack in Redis lists (max 10 entries) + - Sessions list for project-wide queries + - 22 unit tests + +- **ContextManager (0.10.3)** + - Manages context window token budget + - File context tracking with addToContext/removeFromContext + - Usage monitoring: getUsage, getAvailableTokens, getRemainingTokens + - Auto-compression at 80% threshold via LLM summarization + - Context state export for session persistence + - 23 unit tests + +- **StartSession (0.10.4)** + - Use case for session initialization + - Creates new session or loads latest for project + - Optional sessionId for specific session loading + - forceNew option to always create fresh session + - 10 unit tests + +- **HandleMessage (0.10.5)** + - Main orchestrator use case for message handling + - LLM interaction with tool calling support + - Edit confirmation flow with diff preview + - Error handling with retry/skip/abort choices + - Status tracking: ready, thinking, tool_call, awaiting_confirmation, error + - Event callbacks: onMessage, onToolCall, onToolResult, onConfirmation, onError + - 21 unit tests + +- **UndoChange (0.10.6)** + - Use case for reverting file changes + - Validates file hasn't changed since edit + - Restores original content from undo entry + - Updates storage after successful undo + - 12 unit tests + +### Changed + +- Total tests: 1174 (was 1086) +- Coverage: 97.73% lines, 92.21% branches +- Application layer now has 4 use cases implemented +- All planned session management features complete + +--- + ## [0.9.0] - 2025-12-01 - Git & Run Tools ### Added diff --git a/packages/ipuaro/TODO.md b/packages/ipuaro/TODO.md index 7cb54fa..630ea51 100644 --- a/packages/ipuaro/TODO.md +++ b/packages/ipuaro/TODO.md @@ -1,40 +1,95 @@ # ipuaro TODO +## Completed + +### Version 0.1.0 - Foundation +- [x] Project setup (package.json, tsconfig, vitest) +- [x] Domain entities (Session, Project) +- [x] Domain value objects (FileData, FileAST, FileMeta, ChatMessage, etc.) +- [x] Domain service interfaces (IStorage, ILLMClient, ITool, IIndexer) +- [x] Shared config loader with Zod validation +- [x] IpuaroError class + +### Version 0.2.0 - Redis Storage +- [x] RedisClient with AOF config +- [x] Redis schema implementation +- [x] RedisStorage class + +### Version 0.3.0 - Indexer +- [x] FileScanner with gitignore support +- [x] ASTParser with tree-sitter +- [x] MetaAnalyzer for complexity +- [x] IndexBuilder for symbols +- [x] Watchdog for file changes + +### Version 0.4.0 - LLM Integration +- [x] OllamaClient implementation +- [x] System prompt design +- [x] Tool definitions (18 tools) +- [x] Response parser (XML format) + +### Version 0.5.0 - Read Tools +- [x] ToolRegistry implementation +- [x] get_lines tool +- [x] get_function tool +- [x] get_class tool +- [x] get_structure tool + +### Version 0.6.0 - Edit Tools +- [x] edit_lines tool +- [x] create_file tool +- [x] delete_file tool + +### Version 0.7.0 - Search Tools +- [x] find_references tool +- [x] find_definition tool + +### Version 0.8.0 - Analysis Tools +- [x] get_dependencies tool +- [x] get_dependents tool +- [x] get_complexity tool +- [x] get_todos tool + +### Version 0.9.0 - Git & Run Tools +- [x] git_status tool +- [x] git_diff tool +- [x] git_commit tool +- [x] CommandSecurity (blacklist/whitelist) +- [x] run_command tool +- [x] run_tests tool + +### Version 0.10.0 - Session Management +- [x] ISessionStorage interface +- [x] RedisSessionStorage implementation +- [x] ContextManager use case +- [x] StartSession use case +- [x] HandleMessage use case +- [x] UndoChange use case + ## In Progress -### Version 0.2.0 - Redis Storage -- [ ] RedisClient with AOF config -- [ ] Redis schema implementation -- [ ] RedisStorage class +### Version 0.11.0 - TUI Basic +- [ ] App shell (Ink/React) +- [ ] StatusBar component +- [ ] Chat component +- [ ] Input component ## Planned -### Version 0.3.0 - Indexer -- [ ] FileScanner with gitignore support -- [ ] ASTParser with tree-sitter -- [ ] MetaAnalyzer for complexity -- [ ] IndexBuilder for symbols -- [ ] Watchdog for file changes +### Version 0.12.0 - TUI Advanced +- [ ] DiffView component +- [ ] ConfirmDialog component +- [ ] ErrorDialog component +- [ ] Progress component -### Version 0.4.0 - LLM Integration -- [ ] OllamaClient implementation -- [ ] System prompt design -- [ ] Tool definitions (XML format) -- [ ] Response parser +### Version 0.13.0+ - Commands & Polish +- [ ] Slash commands (/help, /clear, /undo, /sessions, /status) +- [ ] Hotkeys (Ctrl+C, Ctrl+D, Ctrl+Z) +- [ ] Auto-compression at 80% context -### Version 0.5.0+ - Tools -- [ ] Read tools (get_lines, get_function, get_class, get_structure) -- [ ] Edit tools (edit_lines, create_file, delete_file) -- [ ] Search tools (find_references, find_definition) -- [ ] Analysis tools (get_dependencies, get_dependents, get_complexity, get_todos) -- [ ] Git tools (git_status, git_diff, git_commit) -- [ ] Run tools (run_command, run_tests) - -### Version 0.10.0+ - Session & TUI -- [ ] Session management -- [ ] Context compression -- [ ] TUI components (StatusBar, Chat, Input, DiffView) -- [ ] Slash commands (/help, /clear, /undo, etc.) +### Version 0.14.0 - CLI Entry Point +- [ ] Full CLI commands (start, init, index) +- [ ] Onboarding flow (Redis check, Ollama check, model pull) ## Technical Debt @@ -51,4 +106,4 @@ _None at this time._ --- -**Last Updated:** 2025-01-29 +**Last Updated:** 2025-12-01 \ No newline at end of file diff --git a/packages/ipuaro/src/application/use-cases/ContextManager.ts b/packages/ipuaro/src/application/use-cases/ContextManager.ts new file mode 100644 index 0000000..1742885 --- /dev/null +++ b/packages/ipuaro/src/application/use-cases/ContextManager.ts @@ -0,0 +1,229 @@ +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" + +/** + * File in context with token count. + */ +export interface FileContext { + path: string + tokens: number + addedAt: number +} + +/** + * Compression result. + */ +export interface CompressionResult { + compressed: boolean + removedMessages: number + tokensSaved: number + summary?: string +} + +const COMPRESSION_PROMPT = `Summarize the following conversation history in a concise way, +preserving key information about: +- What files were discussed or modified +- What changes were made +- Important decisions or context +Keep the summary under 500 tokens.` + +const MESSAGES_TO_KEEP = 5 +const MIN_MESSAGES_FOR_COMPRESSION = 10 + +/** + * Manages context window token budget and compression. + */ +export class ContextManager { + private readonly filesInContext = new Map() + private currentTokens = 0 + private readonly contextWindowSize: number + + constructor(contextWindowSize: number = CONTEXT_WINDOW_SIZE) { + this.contextWindowSize = contextWindowSize + } + + /** + * Add a file to the context. + */ + addToContext(file: string, tokens: number): void { + const existing = this.filesInContext.get(file) + if (existing) { + this.currentTokens -= existing.tokens + } + + this.filesInContext.set(file, { + path: file, + tokens, + addedAt: Date.now(), + }) + this.currentTokens += tokens + } + + /** + * Remove a file from the context. + */ + removeFromContext(file: string): void { + const existing = this.filesInContext.get(file) + if (existing) { + this.currentTokens -= existing.tokens + this.filesInContext.delete(file) + } + } + + /** + * Get current token usage ratio (0-1). + */ + getUsage(): number { + return this.currentTokens / this.contextWindowSize + } + + /** + * Get current token count. + */ + getTokenCount(): number { + return this.currentTokens + } + + /** + * Get available tokens. + */ + getAvailableTokens(): number { + return this.contextWindowSize - this.currentTokens + } + + /** + * Check if compression is needed. + */ + needsCompression(): boolean { + return this.getUsage() > CONTEXT_COMPRESSION_THRESHOLD + } + + /** + * Update token count (e.g., after receiving a message). + */ + addTokens(tokens: number): void { + this.currentTokens += tokens + } + + /** + * Get files in context. + */ + getFilesInContext(): string[] { + return Array.from(this.filesInContext.keys()) + } + + /** + * Sync context state from session. + */ + syncFromSession(session: Session): void { + this.filesInContext.clear() + this.currentTokens = 0 + + for (const file of session.context.filesInContext) { + this.filesInContext.set(file, { + path: file, + tokens: 0, + addedAt: Date.now(), + }) + } + + this.currentTokens = Math.floor(session.context.tokenUsage * this.contextWindowSize) + } + + /** + * Update session context state. + */ + updateSession(session: Session): void { + session.context.filesInContext = this.getFilesInContext() + session.context.tokenUsage = this.getUsage() + session.context.needsCompression = this.needsCompression() + } + + /** + * Compress context using LLM to summarize old messages. + */ + async compress(session: Session, llm: ILLMClient): Promise { + const history = session.history + if (history.length < MIN_MESSAGES_FOR_COMPRESSION) { + return { + compressed: false, + removedMessages: 0, + tokensSaved: 0, + } + } + + const messagesToCompress = history.slice(0, -MESSAGES_TO_KEEP) + const messagesToKeep = history.slice(-MESSAGES_TO_KEEP) + + const tokensBeforeCompression = await this.countHistoryTokens(messagesToCompress, llm) + + const summary = await this.summarizeMessages(messagesToCompress, llm) + const summaryTokens = await llm.countTokens(summary) + + const summaryMessage = createSystemMessage(`[Previous conversation summary]\n${summary}`) + + session.history = [summaryMessage, ...messagesToKeep] + + const tokensSaved = tokensBeforeCompression - summaryTokens + this.currentTokens -= tokensSaved + + this.updateSession(session) + + return { + compressed: true, + removedMessages: messagesToCompress.length, + tokensSaved, + summary, + } + } + + /** + * Create a new context state. + */ + static createInitialState(): ContextState { + return { + filesInContext: [], + tokenUsage: 0, + needsCompression: false, + } + } + + private async summarizeMessages(messages: ChatMessage[], llm: ILLMClient): Promise { + const conversation = this.formatMessagesForSummary(messages) + + const response = await llm.chat([ + createSystemMessage(COMPRESSION_PROMPT), + createSystemMessage(conversation), + ]) + + return response.content + } + + private formatMessagesForSummary(messages: ChatMessage[]): string { + return messages + .filter((m) => m.role !== "tool") + .map((m) => { + const role = m.role === "user" ? "User" : "Assistant" + const content = this.truncateContent(m.content, 500) + return `${role}: ${content}` + }) + .join("\n\n") + } + + private truncateContent(content: string, maxLength: number): string { + if (content.length <= maxLength) { + return content + } + return `${content.slice(0, maxLength)}...` + } + + private async countHistoryTokens(messages: ChatMessage[], llm: ILLMClient): Promise { + let total = 0 + for (const message of messages) { + total += await llm.countTokens(message.content) + } + return total + } +} diff --git a/packages/ipuaro/src/application/use-cases/HandleMessage.ts b/packages/ipuaro/src/application/use-cases/HandleMessage.ts new file mode 100644 index 0000000..b611bb0 --- /dev/null +++ b/packages/ipuaro/src/application/use-cases/HandleMessage.ts @@ -0,0 +1,383 @@ +import { randomUUID } from "node:crypto" +import type { Session } from "../../domain/entities/Session.js" +import type { ILLMClient } from "../../domain/services/ILLMClient.js" +import type { ISessionStorage } from "../../domain/services/ISessionStorage.js" +import type { IStorage } from "../../domain/services/IStorage.js" +import type { DiffInfo, ToolContext } from "../../domain/services/ITool.js" +import { + type ChatMessage, + createAssistantMessage, + createSystemMessage, + createToolMessage, + createUserMessage, +} from "../../domain/value-objects/ChatMessage.js" +import type { ToolCall } from "../../domain/value-objects/ToolCall.js" +import { createErrorResult, type ToolResult } from "../../domain/value-objects/ToolResult.js" +import { createUndoEntry, type UndoEntry } from "../../domain/value-objects/UndoEntry.js" +import { IpuaroError } from "../../shared/errors/IpuaroError.js" +import type { ErrorChoice } from "../../shared/types/index.js" +import { + buildInitialContext, + type ProjectStructure, + SYSTEM_PROMPT, +} from "../../infrastructure/llm/prompts.js" +import { parseToolCalls } from "../../infrastructure/llm/ResponseParser.js" +import type { IToolRegistry } from "../interfaces/IToolRegistry.js" +import { ContextManager } from "./ContextManager.js" + +/** + * Status during message handling. + */ +export type HandleMessageStatus = + | "ready" + | "thinking" + | "tool_call" + | "awaiting_confirmation" + | "error" + +/** + * Edit request for confirmation. + */ +export interface EditRequest { + toolCall: ToolCall + filePath: string + description: string + diff?: DiffInfo +} + +/** + * User's choice for edit confirmation. + */ +export type EditChoice = "apply" | "skip" | "edit" | "abort" + +/** + * Event callbacks for HandleMessage. + */ +export interface HandleMessageEvents { + onMessage?: (message: ChatMessage) => void + onToolCall?: (call: ToolCall) => void + onToolResult?: (result: ToolResult) => void + onConfirmation?: (message: string, diff?: DiffInfo) => Promise + onError?: (error: IpuaroError) => Promise + onStatusChange?: (status: HandleMessageStatus) => void + onUndoEntry?: (entry: UndoEntry) => void +} + +/** + * Options for HandleMessage. + */ +export interface HandleMessageOptions { + autoApply?: boolean + maxToolCalls?: number +} + +const DEFAULT_MAX_TOOL_CALLS = 20 + +/** + * Use case for handling a user message. + * Main orchestrator for the LLM interaction loop. + */ +export class HandleMessage { + private readonly storage: IStorage + private readonly sessionStorage: ISessionStorage + private readonly llm: ILLMClient + private readonly tools: IToolRegistry + private readonly contextManager: ContextManager + private readonly projectRoot: string + private projectStructure?: ProjectStructure + + private events: HandleMessageEvents = {} + private options: HandleMessageOptions = {} + private aborted = false + + constructor( + storage: IStorage, + sessionStorage: ISessionStorage, + llm: ILLMClient, + tools: IToolRegistry, + projectRoot: string, + ) { + this.storage = storage + this.sessionStorage = sessionStorage + this.llm = llm + this.tools = tools + this.projectRoot = projectRoot + this.contextManager = new ContextManager(llm.getContextWindowSize()) + } + + /** + * Set event callbacks. + */ + setEvents(events: HandleMessageEvents): void { + this.events = events + } + + /** + * Set options. + */ + setOptions(options: HandleMessageOptions): void { + this.options = options + } + + /** + * Set project structure for context building. + */ + setProjectStructure(structure: ProjectStructure): void { + this.projectStructure = structure + } + + /** + * Abort current processing. + */ + abort(): void { + this.aborted = true + this.llm.abort() + } + + /** + * Execute the message handling flow. + */ + async execute(session: Session, message: string): Promise { + this.aborted = false + this.contextManager.syncFromSession(session) + + if (message.trim()) { + const userMessage = createUserMessage(message) + session.addMessage(userMessage) + session.addInputToHistory(message) + this.emitMessage(userMessage) + } + + await this.sessionStorage.saveSession(session) + + this.emitStatus("thinking") + + let toolCallCount = 0 + const maxToolCalls = this.options.maxToolCalls ?? DEFAULT_MAX_TOOL_CALLS + + while (!this.aborted) { + const messages = await this.buildMessages(session) + + const startTime = Date.now() + let response + + try { + response = await this.llm.chat(messages) + } catch (error) { + await this.handleLLMError(error, session) + return + } + + if (this.aborted) { + return + } + + const parsed = parseToolCalls(response.content) + const timeMs = Date.now() - startTime + + if (parsed.toolCalls.length === 0) { + const assistantMessage = createAssistantMessage(parsed.content, undefined, { + tokens: response.tokens, + timeMs, + toolCalls: 0, + }) + session.addMessage(assistantMessage) + this.emitMessage(assistantMessage) + this.contextManager.addTokens(response.tokens) + this.contextManager.updateSession(session) + await this.sessionStorage.saveSession(session) + this.emitStatus("ready") + return + } + + const assistantMessage = createAssistantMessage(parsed.content, parsed.toolCalls, { + tokens: response.tokens, + timeMs, + toolCalls: parsed.toolCalls.length, + }) + session.addMessage(assistantMessage) + this.emitMessage(assistantMessage) + + toolCallCount += parsed.toolCalls.length + if (toolCallCount > maxToolCalls) { + const errorMsg = `Maximum tool calls (${String(maxToolCalls)}) exceeded` + const errorMessage = createSystemMessage(errorMsg) + session.addMessage(errorMessage) + this.emitMessage(errorMessage) + this.emitStatus("ready") + return + } + + this.emitStatus("tool_call") + + const results: ToolResult[] = [] + + for (const toolCall of parsed.toolCalls) { + if (this.aborted) { + return + } + + this.emitToolCall(toolCall) + + const result = await this.executeToolCall(toolCall, session) + results.push(result) + this.emitToolResult(result) + } + + const toolMessage = createToolMessage(results) + session.addMessage(toolMessage) + + this.contextManager.addTokens(response.tokens) + + if (this.contextManager.needsCompression()) { + await this.contextManager.compress(session, this.llm) + } + + this.contextManager.updateSession(session) + await this.sessionStorage.saveSession(session) + + this.emitStatus("thinking") + } + } + + private async buildMessages(session: Session): Promise { + const messages: ChatMessage[] = [] + + messages.push(createSystemMessage(SYSTEM_PROMPT)) + + if (this.projectStructure) { + const asts = await this.storage.getAllASTs() + const metas = await this.storage.getAllMetas() + const context = buildInitialContext(this.projectStructure, asts, metas) + messages.push(createSystemMessage(context)) + } + + messages.push(...session.history) + + return messages + } + + private async executeToolCall(toolCall: ToolCall, session: Session): Promise { + const startTime = Date.now() + const tool = this.tools.get(toolCall.name) + + if (!tool) { + return createErrorResult( + toolCall.id, + `Unknown tool: ${toolCall.name}`, + Date.now() - startTime, + ) + } + + const context: ToolContext = { + projectRoot: this.projectRoot, + storage: this.storage, + requestConfirmation: async (msg: string, diff?: DiffInfo) => { + return this.handleConfirmation(msg, diff, toolCall, session) + }, + onProgress: (_msg: string) => { + this.events.onStatusChange?.("tool_call") + }, + } + + try { + const validationError = tool.validateParams(toolCall.params) + if (validationError) { + return createErrorResult(toolCall.id, validationError, Date.now() - startTime) + } + + const result = await tool.execute(toolCall.params, context) + return result + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + return createErrorResult(toolCall.id, errorMessage, Date.now() - startTime) + } + } + + private async handleConfirmation( + msg: string, + diff: DiffInfo | undefined, + toolCall: ToolCall, + session: Session, + ): Promise { + if (this.options.autoApply) { + if (diff) { + this.createUndoEntryFromDiff(diff, toolCall, session) + } + return true + } + + this.emitStatus("awaiting_confirmation") + + if (this.events.onConfirmation) { + const confirmed = await this.events.onConfirmation(msg, diff) + + if (confirmed && diff) { + this.createUndoEntryFromDiff(diff, toolCall, session) + } + + return confirmed + } + + if (diff) { + this.createUndoEntryFromDiff(diff, toolCall, session) + } + return true + } + + private createUndoEntryFromDiff(diff: DiffInfo, toolCall: ToolCall, session: Session): void { + const entry = createUndoEntry( + randomUUID(), + diff.filePath, + diff.oldLines, + diff.newLines, + `${toolCall.name}: ${diff.filePath}`, + toolCall.id, + ) + + session.addUndoEntry(entry) + void this.sessionStorage.pushUndoEntry(session.id, entry) + session.stats.editsApplied++ + this.events.onUndoEntry?.(entry) + } + + private async handleLLMError(error: unknown, session: Session): Promise { + this.emitStatus("error") + + const ipuaroError = + error instanceof IpuaroError + ? error + : IpuaroError.llm(error instanceof Error ? error.message : String(error)) + + if (this.events.onError) { + const choice = await this.events.onError(ipuaroError) + + if (choice === "retry") { + this.emitStatus("thinking") + return this.execute(session, "") + } + } + + const errorMessage = createSystemMessage(`Error: ${ipuaroError.message}`) + session.addMessage(errorMessage) + this.emitMessage(errorMessage) + + this.emitStatus("ready") + } + + private emitMessage(message: ChatMessage): void { + this.events.onMessage?.(message) + } + + private emitToolCall(call: ToolCall): void { + this.events.onToolCall?.(call) + } + + private emitToolResult(result: ToolResult): void { + this.events.onToolResult?.(result) + } + + private emitStatus(status: HandleMessageStatus): void { + this.events.onStatusChange?.(status) + } +} diff --git a/packages/ipuaro/src/application/use-cases/StartSession.ts b/packages/ipuaro/src/application/use-cases/StartSession.ts new file mode 100644 index 0000000..80f7872 --- /dev/null +++ b/packages/ipuaro/src/application/use-cases/StartSession.ts @@ -0,0 +1,62 @@ +import { randomUUID } from "node:crypto" +import { Session } from "../../domain/entities/Session.js" +import type { ISessionStorage } from "../../domain/services/ISessionStorage.js" + +/** + * Options for starting a session. + */ +export interface StartSessionOptions { + /** Force creation of a new session even if one exists */ + forceNew?: boolean + /** Specific session ID to load */ + sessionId?: string +} + +/** + * Result of starting a session. + */ +export interface StartSessionResult { + session: Session + isNew: boolean +} + +/** + * Use case for starting a session. + * Creates a new session or loads the latest one for a project. + */ +export class StartSession { + constructor(private readonly sessionStorage: ISessionStorage) {} + + /** + * Execute the use case. + * + * @param projectName - The project name to start a session for + * @param options - Optional configuration + * @returns The session and whether it was newly created + */ + async execute( + projectName: string, + options: StartSessionOptions = {}, + ): Promise { + if (options.sessionId) { + const session = await this.sessionStorage.loadSession(options.sessionId) + if (session) { + await this.sessionStorage.touchSession(session.id) + return { session, isNew: false } + } + } + + if (!options.forceNew) { + const latestSession = await this.sessionStorage.getLatestSession(projectName) + if (latestSession) { + await this.sessionStorage.touchSession(latestSession.id) + return { session: latestSession, isNew: false } + } + } + + const session = new Session(randomUUID(), projectName) + await this.sessionStorage.saveSession(session) + + return { session, isNew: true } + } +} diff --git a/packages/ipuaro/src/application/use-cases/UndoChange.ts b/packages/ipuaro/src/application/use-cases/UndoChange.ts new file mode 100644 index 0000000..a1466a0 --- /dev/null +++ b/packages/ipuaro/src/application/use-cases/UndoChange.ts @@ -0,0 +1,119 @@ +import { promises as fs } from "node:fs" +import type { Session } from "../../domain/entities/Session.js" +import type { ISessionStorage } from "../../domain/services/ISessionStorage.js" +import type { IStorage } from "../../domain/services/IStorage.js" +import { canUndo, type UndoEntry } from "../../domain/value-objects/UndoEntry.js" +import { md5 } from "../../shared/utils/hash.js" + +/** + * Result of undo operation. + */ +export interface UndoResult { + success: boolean + entry?: UndoEntry + error?: string +} + +/** + * Use case for undoing the last file change. + */ +export class UndoChange { + constructor( + private readonly sessionStorage: ISessionStorage, + private readonly storage: IStorage, + ) {} + + /** + * Execute undo operation. + * + * @param session - The current session + * @returns Result of the undo operation + */ + async execute(session: Session): Promise { + const entry = await this.sessionStorage.popUndoEntry(session.id) + if (!entry) { + return { + success: false, + error: "No changes to undo", + } + } + + try { + const currentContent = await this.readCurrentContent(entry.filePath) + + if (!canUndo(entry, currentContent)) { + await this.sessionStorage.pushUndoEntry(session.id, entry) + return { + success: false, + entry, + error: "File has been modified since the change was made", + } + } + + await this.restoreContent(entry.filePath, entry.previousContent) + + session.popUndoEntry() + session.stats.editsApplied-- + + return { + success: true, + entry, + } + } catch (error) { + await this.sessionStorage.pushUndoEntry(session.id, entry) + + const message = error instanceof Error ? error.message : "Unknown error" + return { + success: false, + entry, + error: `Failed to undo: ${message}`, + } + } + } + + /** + * Check if undo is available. + */ + async canUndo(session: Session): Promise { + const stack = await this.sessionStorage.getUndoStack(session.id) + return stack.length > 0 + } + + /** + * Get the next undo entry without removing it. + */ + async peekUndoEntry(session: Session): Promise { + const stack = await this.sessionStorage.getUndoStack(session.id) + if (stack.length === 0) { + return null + } + return stack[stack.length - 1] + } + + private async readCurrentContent(filePath: string): Promise { + try { + const content = await fs.readFile(filePath, "utf-8") + return content.split("\n") + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return [] + } + throw error + } + } + + private async restoreContent(filePath: string, content: string[]): Promise { + const fileContent = content.join("\n") + await fs.writeFile(filePath, fileContent, "utf-8") + + const hash = md5(fileContent) + const stats = await fs.stat(filePath) + + await this.storage.setFile(filePath, { + lines: content, + hash, + size: stats.size, + lastModified: stats.mtimeMs, + }) + } +} diff --git a/packages/ipuaro/src/application/use-cases/index.ts b/packages/ipuaro/src/application/use-cases/index.ts index 25ebb14..c9968a7 100644 --- a/packages/ipuaro/src/application/use-cases/index.ts +++ b/packages/ipuaro/src/application/use-cases/index.ts @@ -1,4 +1,6 @@ -/* - * Application Use Cases - * Will be implemented in version 0.10.0+ - */ +// Application Use Cases + +export * from "./StartSession.js" +export * from "./HandleMessage.js" +export * from "./UndoChange.js" +export * from "./ContextManager.js" diff --git a/packages/ipuaro/src/domain/services/ISessionStorage.ts b/packages/ipuaro/src/domain/services/ISessionStorage.ts new file mode 100644 index 0000000..2914535 --- /dev/null +++ b/packages/ipuaro/src/domain/services/ISessionStorage.ts @@ -0,0 +1,88 @@ +import type { ContextState, Session, SessionStats } from "../entities/Session.js" +import type { ChatMessage } from "../value-objects/ChatMessage.js" +import type { UndoEntry } from "../value-objects/UndoEntry.js" + +/** + * Session data stored in persistence layer. + */ +export interface SessionData { + id: string + projectName: string + createdAt: number + lastActivityAt: number + history: ChatMessage[] + context: ContextState + stats: SessionStats + inputHistory: string[] +} + +/** + * Session list item (minimal info for listing). + */ +export interface SessionListItem { + id: string + projectName: string + createdAt: number + lastActivityAt: number + messageCount: number +} + +/** + * Storage service interface for session persistence. + */ +export interface ISessionStorage { + /** + * Save a session to storage. + */ + saveSession(session: Session): Promise + + /** + * Load a session by ID. + */ + loadSession(sessionId: string): Promise + + /** + * Delete a session. + */ + deleteSession(sessionId: string): Promise + + /** + * Get list of all sessions for a project. + */ + listSessions(projectName?: string): Promise + + /** + * Get the latest session for a project. + */ + getLatestSession(projectName: string): Promise + + /** + * Check if a session exists. + */ + sessionExists(sessionId: string): Promise + + /** + * Add undo entry to session's undo stack. + */ + pushUndoEntry(sessionId: string, entry: UndoEntry): Promise + + /** + * Pop undo entry from session's undo stack. + */ + popUndoEntry(sessionId: string): Promise + + /** + * Get undo stack for a session. + */ + getUndoStack(sessionId: string): Promise + + /** + * Update session's last activity timestamp. + */ + touchSession(sessionId: string): Promise + + /** + * Clear all sessions. + */ + clearAllSessions(): Promise +} diff --git a/packages/ipuaro/src/domain/services/index.ts b/packages/ipuaro/src/domain/services/index.ts index 3802656..964cebe 100644 --- a/packages/ipuaro/src/domain/services/index.ts +++ b/packages/ipuaro/src/domain/services/index.ts @@ -1,5 +1,6 @@ // Domain Service Interfaces (Ports) export * from "./IStorage.js" +export * from "./ISessionStorage.js" export * from "./ILLMClient.js" export * from "./ITool.js" export * from "./IIndexer.js" diff --git a/packages/ipuaro/src/infrastructure/storage/RedisSessionStorage.ts b/packages/ipuaro/src/infrastructure/storage/RedisSessionStorage.ts new file mode 100644 index 0000000..6d42b47 --- /dev/null +++ b/packages/ipuaro/src/infrastructure/storage/RedisSessionStorage.ts @@ -0,0 +1,225 @@ +import type { ISessionStorage, SessionListItem } from "../../domain/services/ISessionStorage.js" +import { type ContextState, Session, type SessionStats } from "../../domain/entities/Session.js" +import type { ChatMessage } from "../../domain/value-objects/ChatMessage.js" +import type { UndoEntry } from "../../domain/value-objects/UndoEntry.js" +import { MAX_UNDO_STACK_SIZE } from "../../domain/constants/index.js" +import { IpuaroError } from "../../shared/errors/IpuaroError.js" +import { RedisClient } from "./RedisClient.js" +import { SessionFields, SessionKeys } from "./schema.js" + +/** + * Redis implementation of ISessionStorage. + * Stores session data in Redis hashes and lists. + */ +export class RedisSessionStorage implements ISessionStorage { + private readonly client: RedisClient + + constructor(client: RedisClient) { + this.client = client + } + + async saveSession(session: Session): Promise { + const redis = this.getRedis() + const dataKey = SessionKeys.data(session.id) + + const pipeline = redis.pipeline() + + pipeline.hset(dataKey, SessionFields.projectName, session.projectName) + pipeline.hset(dataKey, SessionFields.createdAt, String(session.createdAt)) + pipeline.hset(dataKey, SessionFields.lastActivityAt, String(session.lastActivityAt)) + pipeline.hset(dataKey, SessionFields.history, JSON.stringify(session.history)) + pipeline.hset(dataKey, SessionFields.context, JSON.stringify(session.context)) + pipeline.hset(dataKey, SessionFields.stats, JSON.stringify(session.stats)) + pipeline.hset(dataKey, SessionFields.inputHistory, JSON.stringify(session.inputHistory)) + + await this.addToSessionsList(session.id) + + await pipeline.exec() + } + + async loadSession(sessionId: string): Promise { + const redis = this.getRedis() + const dataKey = SessionKeys.data(sessionId) + + const data = await redis.hgetall(dataKey) + if (!data || Object.keys(data).length === 0) { + return null + } + + const session = new Session( + sessionId, + data[SessionFields.projectName], + Number(data[SessionFields.createdAt]), + ) + + session.lastActivityAt = Number(data[SessionFields.lastActivityAt]) + session.history = this.parseJSON(data[SessionFields.history], "history") as ChatMessage[] + session.context = this.parseJSON(data[SessionFields.context], "context") as ContextState + session.stats = this.parseJSON(data[SessionFields.stats], "stats") as SessionStats + session.inputHistory = this.parseJSON( + data[SessionFields.inputHistory], + "inputHistory", + ) as string[] + + const undoStack = await this.getUndoStack(sessionId) + for (const entry of undoStack) { + session.undoStack.push(entry) + } + + return session + } + + async deleteSession(sessionId: string): Promise { + const redis = this.getRedis() + + await Promise.all([ + redis.del(SessionKeys.data(sessionId)), + redis.del(SessionKeys.undo(sessionId)), + redis.lrem(SessionKeys.list, 0, sessionId), + ]) + } + + async listSessions(projectName?: string): Promise { + const redis = this.getRedis() + const sessionIds = await redis.lrange(SessionKeys.list, 0, -1) + + const sessions: SessionListItem[] = [] + + for (const id of sessionIds) { + const data = await redis.hgetall(SessionKeys.data(id)) + if (!data || Object.keys(data).length === 0) { + continue + } + + const sessionProjectName = data[SessionFields.projectName] + if (projectName && sessionProjectName !== projectName) { + continue + } + + const history = this.parseJSON(data[SessionFields.history], "history") as ChatMessage[] + + sessions.push({ + id, + projectName: sessionProjectName, + createdAt: Number(data[SessionFields.createdAt]), + lastActivityAt: Number(data[SessionFields.lastActivityAt]), + messageCount: history.length, + }) + } + + sessions.sort((a, b) => b.lastActivityAt - a.lastActivityAt) + + return sessions + } + + async getLatestSession(projectName: string): Promise { + const sessions = await this.listSessions(projectName) + if (sessions.length === 0) { + return null + } + + return this.loadSession(sessions[0].id) + } + + async sessionExists(sessionId: string): Promise { + const redis = this.getRedis() + const exists = await redis.exists(SessionKeys.data(sessionId)) + return exists === 1 + } + + async pushUndoEntry(sessionId: string, entry: UndoEntry): Promise { + const redis = this.getRedis() + const undoKey = SessionKeys.undo(sessionId) + + await redis.rpush(undoKey, JSON.stringify(entry)) + + const length = await redis.llen(undoKey) + if (length > MAX_UNDO_STACK_SIZE) { + await redis.lpop(undoKey) + } + } + + async popUndoEntry(sessionId: string): Promise { + const redis = this.getRedis() + const undoKey = SessionKeys.undo(sessionId) + + const data = await redis.rpop(undoKey) + if (!data) { + return null + } + + return this.parseJSON(data, "UndoEntry") as UndoEntry + } + + async getUndoStack(sessionId: string): Promise { + const redis = this.getRedis() + const undoKey = SessionKeys.undo(sessionId) + + const entries = await redis.lrange(undoKey, 0, -1) + return entries.map((entry) => this.parseJSON(entry, "UndoEntry") as UndoEntry) + } + + async touchSession(sessionId: string): Promise { + const redis = this.getRedis() + await redis.hset( + SessionKeys.data(sessionId), + SessionFields.lastActivityAt, + String(Date.now()), + ) + } + + async clearAllSessions(): Promise { + const redis = this.getRedis() + const sessionIds = await redis.lrange(SessionKeys.list, 0, -1) + + const pipeline = redis.pipeline() + for (const id of sessionIds) { + pipeline.del(SessionKeys.data(id)) + pipeline.del(SessionKeys.undo(id)) + } + pipeline.del(SessionKeys.list) + + await pipeline.exec() + } + + private async addToSessionsList(sessionId: string): Promise { + const redis = this.getRedis() + + const exists = await redis.lpos(SessionKeys.list, sessionId) + if (exists === null) { + await redis.lpush(SessionKeys.list, sessionId) + } + } + + private getRedis(): ReturnType { + return this.client.getClient() + } + + private parseJSON(data: string | undefined, type: string): unknown { + if (!data) { + if (type === "history" || type === "inputHistory") { + return [] + } + if (type === "context") { + return { filesInContext: [], tokenUsage: 0, needsCompression: false } + } + if (type === "stats") { + return { + totalTokens: 0, + totalTimeMs: 0, + toolCalls: 0, + editsApplied: 0, + editsRejected: 0, + } + } + return null + } + + try { + return JSON.parse(data) as unknown + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error" + throw IpuaroError.parse(`Failed to parse ${type}: ${message}`) + } + } +} diff --git a/packages/ipuaro/src/infrastructure/storage/index.ts b/packages/ipuaro/src/infrastructure/storage/index.ts index 09bf014..90cdca4 100644 --- a/packages/ipuaro/src/infrastructure/storage/index.ts +++ b/packages/ipuaro/src/infrastructure/storage/index.ts @@ -1,6 +1,7 @@ // Storage module exports export { RedisClient } from "./RedisClient.js" export { RedisStorage } from "./RedisStorage.js" +export { RedisSessionStorage } from "./RedisSessionStorage.js" export { ProjectKeys, SessionKeys, diff --git a/packages/ipuaro/src/infrastructure/tools/run/RunTestsTool.ts b/packages/ipuaro/src/infrastructure/tools/run/RunTestsTool.ts index 03070dd..6654327 100644 --- a/packages/ipuaro/src/infrastructure/tools/run/RunTestsTool.ts +++ b/packages/ipuaro/src/infrastructure/tools/run/RunTestsTool.ts @@ -150,12 +150,9 @@ export class RunTestsTool implements ITool { return createSuccessResult(callId, result, Date.now() - startTime) } catch (error) { return this.handleExecError( - callId, - runner, - command, + { callId, runner, command, startTime }, error, execStartTime, - startTime, ) } } catch (error) { @@ -168,25 +165,37 @@ export class RunTestsTool implements ITool { * Detect which test runner is available in the project. */ async detectTestRunner(projectRoot: string): Promise { - if (await this.hasFile(projectRoot, "vitest.config.ts")) { - return "vitest" - } - if (await this.hasFile(projectRoot, "vitest.config.js")) { - return "vitest" - } - if (await this.hasFile(projectRoot, "vitest.config.mts")) { - return "vitest" - } - if (await this.hasFile(projectRoot, "jest.config.js")) { - return "jest" - } - if (await this.hasFile(projectRoot, "jest.config.ts")) { - return "jest" - } - if (await this.hasFile(projectRoot, "jest.config.json")) { - return "jest" + const configRunner = await this.detectByConfigFile(projectRoot) + if (configRunner) { + return configRunner } + return this.detectByPackageJson(projectRoot) + } + + private async detectByConfigFile(projectRoot: string): Promise { + const configFiles: { files: string[]; runner: TestRunner }[] = [ + { + files: ["vitest.config.ts", "vitest.config.js", "vitest.config.mts"], + runner: "vitest", + }, + { + files: ["jest.config.js", "jest.config.ts", "jest.config.json"], + runner: "jest", + }, + ] + + for (const { files, runner } of configFiles) { + for (const file of files) { + if (await this.hasFile(projectRoot, file)) { + return runner + } + } + } + return null + } + + private async detectByPackageJson(projectRoot: string): Promise { const packageJsonPath = path.join(projectRoot, "package.json") try { const content = await this.fsReadFile(packageJsonPath, "utf-8") @@ -196,23 +205,22 @@ export class RunTestsTool implements ITool { dependencies?: Record } - if (pkg.devDependencies?.vitest || pkg.dependencies?.vitest) { + const deps = { ...pkg.devDependencies, ...pkg.dependencies } + if (deps.vitest) { return "vitest" } - if (pkg.devDependencies?.jest || pkg.dependencies?.jest) { + if (deps.jest) { return "jest" } - if (pkg.devDependencies?.mocha || pkg.dependencies?.mocha) { + if (deps.mocha) { return "mocha" } - if (pkg.scripts?.test) { return "npm" } } catch { // package.json doesn't exist or is invalid } - return null } @@ -220,63 +228,69 @@ export class RunTestsTool implements ITool { * Build the test command based on runner and options. */ buildCommand(runner: TestRunner, testPath?: string, filter?: string, watch?: boolean): string { - const parts: string[] = [] - - switch (runner) { - case "vitest": - parts.push("npx vitest") - if (!watch) { - parts.push("run") - } - if (testPath) { - parts.push(testPath) - } - if (filter) { - parts.push("-t", `"${filter}"`) - } - break - - case "jest": - parts.push("npx jest") - if (testPath) { - parts.push(testPath) - } - if (filter) { - parts.push("-t", `"${filter}"`) - } - if (watch) { - parts.push("--watch") - } - break - - case "mocha": - parts.push("npx mocha") - if (testPath) { - parts.push(testPath) - } - if (filter) { - parts.push("--grep", `"${filter}"`) - } - if (watch) { - parts.push("--watch") - } - break - - case "npm": - parts.push("npm test") - if (testPath || filter) { - parts.push("--") - if (testPath) { - parts.push(testPath) - } - if (filter) { - parts.push(`"${filter}"`) - } - } - break + const builders: Record string[]> = { + vitest: () => this.buildVitestCommand(testPath, filter, watch), + jest: () => this.buildJestCommand(testPath, filter, watch), + mocha: () => this.buildMochaCommand(testPath, filter, watch), + npm: () => this.buildNpmCommand(testPath, filter), } + return builders[runner]().join(" ") + } - return parts.join(" ") + private buildVitestCommand(testPath?: string, filter?: string, watch?: boolean): string[] { + const parts = ["npx vitest"] + if (!watch) { + parts.push("run") + } + if (testPath) { + parts.push(testPath) + } + if (filter) { + parts.push("-t", `"${filter}"`) + } + return parts + } + + private buildJestCommand(testPath?: string, filter?: string, watch?: boolean): string[] { + const parts = ["npx jest"] + if (testPath) { + parts.push(testPath) + } + if (filter) { + parts.push("-t", `"${filter}"`) + } + if (watch) { + parts.push("--watch") + } + return parts + } + + private buildMochaCommand(testPath?: string, filter?: string, watch?: boolean): string[] { + const parts = ["npx mocha"] + if (testPath) { + parts.push(testPath) + } + if (filter) { + parts.push("--grep", `"${filter}"`) + } + if (watch) { + parts.push("--watch") + } + return parts + } + + private buildNpmCommand(testPath?: string, filter?: string): string[] { + const parts = ["npm test"] + if (testPath || filter) { + parts.push("--") + if (testPath) { + parts.push(testPath) + } + if (filter) { + parts.push(`"${filter}"`) + } + } + return parts } /** @@ -295,13 +309,11 @@ export class RunTestsTool implements ITool { * Handle exec errors and return appropriate result. */ private handleExecError( - callId: string, - runner: TestRunner, - command: string, + ctx: { callId: string; runner: TestRunner; command: string; startTime: number }, error: unknown, execStartTime: number, - startTime: number, ): ToolResult { + const { callId, runner, command, startTime } = ctx const durationMs = Date.now() - execStartTime if (this.isExecError(error)) { diff --git a/packages/ipuaro/tests/unit/application/use-cases/ContextManager.test.ts b/packages/ipuaro/tests/unit/application/use-cases/ContextManager.test.ts new file mode 100644 index 0000000..16442ca --- /dev/null +++ b/packages/ipuaro/tests/unit/application/use-cases/ContextManager.test.ts @@ -0,0 +1,248 @@ +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) + }) + }) +}) diff --git a/packages/ipuaro/tests/unit/application/use-cases/HandleMessage.test.ts b/packages/ipuaro/tests/unit/application/use-cases/HandleMessage.test.ts new file mode 100644 index 0000000..e66022e --- /dev/null +++ b/packages/ipuaro/tests/unit/application/use-cases/HandleMessage.test.ts @@ -0,0 +1,421 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { HandleMessage } from "../../../../src/application/use-cases/HandleMessage.js" +import type { IStorage } from "../../../../src/domain/services/IStorage.js" +import type { ISessionStorage } from "../../../../src/domain/services/ISessionStorage.js" +import type { ILLMClient, LLMResponse } from "../../../../src/domain/services/ILLMClient.js" +import type { IToolRegistry } from "../../../../src/application/interfaces/IToolRegistry.js" +import type { ITool, ToolContext } from "../../../../src/domain/services/ITool.js" +import { Session } from "../../../../src/domain/entities/Session.js" +import { createSuccessResult } from "../../../../src/domain/value-objects/ToolResult.js" + +describe("HandleMessage", () => { + let useCase: HandleMessage + let mockStorage: IStorage + let mockSessionStorage: ISessionStorage + let mockLLM: ILLMClient + let mockTools: IToolRegistry + let session: Session + + const createMockLLMResponse = (content: string, toolCalls = false): LLMResponse => ({ + content, + toolCalls: [], + tokens: 100, + timeMs: 50, + truncated: false, + stopReason: toolCalls ? "tool_use" : "end", + }) + + beforeEach(() => { + mockStorage = { + getFile: vi.fn().mockResolvedValue(null), + setFile: vi.fn().mockResolvedValue(undefined), + deleteFile: vi.fn().mockResolvedValue(undefined), + getAllFiles: vi.fn().mockResolvedValue(new Map()), + getFileCount: vi.fn().mockResolvedValue(0), + getAST: vi.fn().mockResolvedValue(null), + setAST: vi.fn().mockResolvedValue(undefined), + deleteAST: vi.fn().mockResolvedValue(undefined), + getAllASTs: vi.fn().mockResolvedValue(new Map()), + getMeta: vi.fn().mockResolvedValue(null), + setMeta: vi.fn().mockResolvedValue(undefined), + deleteMeta: vi.fn().mockResolvedValue(undefined), + getAllMetas: vi.fn().mockResolvedValue(new Map()), + getSymbolIndex: vi.fn().mockResolvedValue(new Map()), + setSymbolIndex: vi.fn().mockResolvedValue(undefined), + getDepsGraph: vi.fn().mockResolvedValue({ imports: new Map(), importedBy: new Map() }), + setDepsGraph: vi.fn().mockResolvedValue(undefined), + getProjectConfig: vi.fn().mockResolvedValue(null), + setProjectConfig: vi.fn().mockResolvedValue(undefined), + connect: vi.fn().mockResolvedValue(undefined), + disconnect: vi.fn().mockResolvedValue(undefined), + isConnected: vi.fn().mockReturnValue(true), + clear: vi.fn().mockResolvedValue(undefined), + } + + mockSessionStorage = { + saveSession: vi.fn().mockResolvedValue(undefined), + loadSession: vi.fn().mockResolvedValue(null), + deleteSession: vi.fn().mockResolvedValue(undefined), + listSessions: vi.fn().mockResolvedValue([]), + getLatestSession: vi.fn().mockResolvedValue(null), + sessionExists: vi.fn().mockResolvedValue(false), + pushUndoEntry: vi.fn().mockResolvedValue(undefined), + popUndoEntry: vi.fn().mockResolvedValue(null), + getUndoStack: vi.fn().mockResolvedValue([]), + touchSession: vi.fn().mockResolvedValue(undefined), + clearAllSessions: vi.fn().mockResolvedValue(undefined), + } + + mockLLM = { + chat: vi.fn().mockResolvedValue(createMockLLMResponse("Hello!")), + countTokens: vi.fn().mockResolvedValue(10), + isAvailable: vi.fn().mockResolvedValue(true), + getModelName: vi.fn().mockReturnValue("test-model"), + getContextWindowSize: vi.fn().mockReturnValue(128_000), + pullModel: vi.fn().mockResolvedValue(undefined), + abort: vi.fn(), + } + + mockTools = { + register: vi.fn(), + get: vi.fn().mockReturnValue(undefined), + getAll: vi.fn().mockReturnValue([]), + getByCategory: vi.fn().mockReturnValue([]), + has: vi.fn().mockReturnValue(false), + execute: vi.fn(), + getToolDefinitions: vi.fn().mockReturnValue([]), + } + + session = new Session("test-session", "test-project") + useCase = new HandleMessage(mockStorage, mockSessionStorage, mockLLM, mockTools, "/project") + }) + + describe("execute", () => { + it("should add user message to session history", async () => { + await useCase.execute(session, "Hello, assistant!") + + expect(session.history.length).toBeGreaterThan(0) + expect(session.history[0].role).toBe("user") + expect(session.history[0].content).toBe("Hello, assistant!") + }) + + it("should add user input to input history", async () => { + await useCase.execute(session, "Test command") + + expect(session.inputHistory).toContain("Test command") + }) + + it("should save session after user message", async () => { + await useCase.execute(session, "Hello") + + expect(mockSessionStorage.saveSession).toHaveBeenCalled() + }) + + it("should send messages to LLM", async () => { + await useCase.execute(session, "What is 2+2?") + + expect(mockLLM.chat).toHaveBeenCalled() + }) + + it("should add assistant response to history", async () => { + vi.mocked(mockLLM.chat).mockResolvedValue(createMockLLMResponse("The answer is 4!")) + + await useCase.execute(session, "What is 2+2?") + + const assistantMessages = session.history.filter((m) => m.role === "assistant") + expect(assistantMessages.length).toBeGreaterThan(0) + expect(assistantMessages[0].content).toBe("The answer is 4!") + }) + + it("should not add empty user messages", async () => { + await useCase.execute(session, " ") + + const userMessages = session.history.filter((m) => m.role === "user") + expect(userMessages.length).toBe(0) + }) + + it("should track token usage in message stats", async () => { + vi.mocked(mockLLM.chat).mockResolvedValue({ + content: "Response", + toolCalls: [], + tokens: 150, + timeMs: 200, + truncated: false, + stopReason: "end", + }) + + await useCase.execute(session, "Hello") + + const assistantMessage = session.history.find((m) => m.role === "assistant") + expect(assistantMessage?.stats?.tokens).toBe(150) + expect(assistantMessage?.stats?.timeMs).toBeGreaterThanOrEqual(0) + }) + }) + + describe("tool execution", () => { + const mockTool: ITool = { + name: "get_lines", + description: "Get lines from file", + parameters: [], + requiresConfirmation: false, + category: "read", + validateParams: vi.fn().mockReturnValue(null), + execute: vi.fn().mockResolvedValue(createSuccessResult("test", { lines: [] }, 10)), + } + + beforeEach(() => { + vi.mocked(mockTools.get).mockReturnValue(mockTool) + }) + + it("should execute tools when LLM returns tool calls", async () => { + vi.mocked(mockLLM.chat) + .mockResolvedValueOnce( + createMockLLMResponse( + 'test.ts', + true, + ), + ) + .mockResolvedValueOnce(createMockLLMResponse("Done!")) + + await useCase.execute(session, "Show me test.ts") + + expect(mockTool.execute).toHaveBeenCalled() + }) + + it("should add tool results to session", async () => { + vi.mocked(mockLLM.chat) + .mockResolvedValueOnce( + createMockLLMResponse( + 'test.ts', + true, + ), + ) + .mockResolvedValueOnce(createMockLLMResponse("Done!")) + + await useCase.execute(session, "Show me test.ts") + + const toolMessages = session.history.filter((m) => m.role === "tool") + expect(toolMessages.length).toBeGreaterThan(0) + }) + + it("should return error for unknown tools", async () => { + vi.mocked(mockTools.get).mockReturnValue(undefined) + vi.mocked(mockLLM.chat) + .mockResolvedValueOnce( + createMockLLMResponse( + 'value', + true, + ), + ) + .mockResolvedValueOnce(createMockLLMResponse("Sorry, that didn't work")) + + await useCase.execute(session, "Do something") + + const toolMessages = session.history.filter((m) => m.role === "tool") + expect(toolMessages[0].content).toContain("Unknown tool") + }) + + it("should stop after max tool calls exceeded", async () => { + useCase.setOptions({ maxToolCalls: 2 }) + + vi.mocked(mockLLM.chat).mockResolvedValue( + createMockLLMResponse( + 'a.ts' + + 'b.ts' + + 'c.ts', + true, + ), + ) + + await useCase.execute(session, "Show many files") + + const systemMessages = session.history.filter((m) => m.role === "system") + const maxExceeded = systemMessages.some((m) => m.content.includes("Maximum tool calls")) + expect(maxExceeded).toBe(true) + }) + }) + + describe("events", () => { + it("should emit message events", async () => { + const onMessage = vi.fn() + useCase.setEvents({ onMessage }) + + await useCase.execute(session, "Hello") + + expect(onMessage).toHaveBeenCalled() + }) + + it("should emit status changes", async () => { + const onStatusChange = vi.fn() + useCase.setEvents({ onStatusChange }) + + await useCase.execute(session, "Hello") + + expect(onStatusChange).toHaveBeenCalledWith("thinking") + expect(onStatusChange).toHaveBeenCalledWith("ready") + }) + + it("should emit tool call events", async () => { + const onToolCall = vi.fn() + useCase.setEvents({ onToolCall }) + + const mockTool: ITool = { + name: "get_lines", + description: "Test", + parameters: [], + requiresConfirmation: false, + category: "read", + validateParams: vi.fn().mockReturnValue(null), + execute: vi.fn().mockResolvedValue(createSuccessResult("test", {}, 10)), + } + vi.mocked(mockTools.get).mockReturnValue(mockTool) + + vi.mocked(mockLLM.chat) + .mockResolvedValueOnce( + createMockLLMResponse( + 'test.ts', + ), + ) + .mockResolvedValueOnce(createMockLLMResponse("Done")) + + await useCase.execute(session, "Show file") + + expect(onToolCall).toHaveBeenCalled() + }) + }) + + describe("confirmation handling", () => { + const mockEditTool: ITool = { + name: "edit_lines", + description: "Edit lines", + parameters: [], + requiresConfirmation: true, + category: "edit", + validateParams: vi.fn().mockReturnValue(null), + execute: vi + .fn() + .mockImplementation(async (_params: Record, ctx: ToolContext) => { + const confirmed = await ctx.requestConfirmation("Apply edit?", { + filePath: "test.ts", + oldLines: ["old"], + newLines: ["new"], + startLine: 1, + }) + if (!confirmed) { + return createSuccessResult("test", { cancelled: true }, 10) + } + return createSuccessResult("test", { applied: true }, 10) + }), + } + + beforeEach(() => { + vi.mocked(mockTools.get).mockReturnValue(mockEditTool) + }) + + it("should auto-apply when autoApply option is true", async () => { + useCase.setOptions({ autoApply: true }) + + vi.mocked(mockLLM.chat) + .mockResolvedValueOnce( + createMockLLMResponse( + 'test.ts', + ), + ) + .mockResolvedValueOnce(createMockLLMResponse("Done")) + + await useCase.execute(session, "Edit file") + + expect(mockEditTool.execute).toHaveBeenCalled() + }) + + it("should ask for confirmation via callback", async () => { + const onConfirmation = vi.fn().mockResolvedValue(true) + useCase.setEvents({ onConfirmation }) + + vi.mocked(mockLLM.chat) + .mockResolvedValueOnce( + createMockLLMResponse( + 'test.ts', + ), + ) + .mockResolvedValueOnce(createMockLLMResponse("Done")) + + await useCase.execute(session, "Edit file") + + expect(onConfirmation).toHaveBeenCalled() + }) + + it("should create undo entry on confirmation", async () => { + const onUndoEntry = vi.fn() + useCase.setEvents({ + onConfirmation: vi.fn().mockResolvedValue(true), + onUndoEntry, + }) + + vi.mocked(mockLLM.chat) + .mockResolvedValueOnce( + createMockLLMResponse( + 'test.ts', + ), + ) + .mockResolvedValueOnce(createMockLLMResponse("Done")) + + await useCase.execute(session, "Edit file") + + expect(onUndoEntry).toHaveBeenCalled() + expect(mockSessionStorage.pushUndoEntry).toHaveBeenCalled() + }) + }) + + describe("abort", () => { + it("should stop processing when aborted", async () => { + vi.mocked(mockLLM.chat).mockImplementation(async () => { + await new Promise((resolve) => setTimeout(resolve, 100)) + return createMockLLMResponse("Response") + }) + + const promise = useCase.execute(session, "Hello") + + setTimeout(() => useCase.abort(), 10) + + await promise + + expect(mockLLM.abort).toHaveBeenCalled() + }) + }) + + describe("error handling", () => { + it("should handle LLM errors gracefully", async () => { + vi.mocked(mockLLM.chat).mockRejectedValue(new Error("LLM unavailable")) + + await useCase.execute(session, "Hello") + + const systemMessages = session.history.filter((m) => m.role === "system") + expect(systemMessages.some((m) => m.content.includes("Error"))).toBe(true) + }) + + it("should emit error status on LLM failure", async () => { + const onStatusChange = vi.fn() + useCase.setEvents({ onStatusChange }) + + vi.mocked(mockLLM.chat).mockRejectedValue(new Error("LLM error")) + + await useCase.execute(session, "Hello") + + expect(onStatusChange).toHaveBeenCalledWith("error") + }) + + it("should allow retry on error", async () => { + const onError = vi.fn().mockResolvedValue("retry") + useCase.setEvents({ onError }) + + vi.mocked(mockLLM.chat) + .mockRejectedValueOnce(new Error("Temporary error")) + .mockResolvedValueOnce(createMockLLMResponse("Success!")) + + await useCase.execute(session, "Hello") + + expect(onError).toHaveBeenCalled() + }) + }) +}) diff --git a/packages/ipuaro/tests/unit/application/use-cases/StartSession.test.ts b/packages/ipuaro/tests/unit/application/use-cases/StartSession.test.ts new file mode 100644 index 0000000..fecc01b --- /dev/null +++ b/packages/ipuaro/tests/unit/application/use-cases/StartSession.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { StartSession } from "../../../../src/application/use-cases/StartSession.js" +import type { ISessionStorage } from "../../../../src/domain/services/ISessionStorage.js" +import { Session } from "../../../../src/domain/entities/Session.js" + +describe("StartSession", () => { + let useCase: StartSession + let mockSessionStorage: ISessionStorage + + beforeEach(() => { + mockSessionStorage = { + saveSession: vi.fn().mockResolvedValue(undefined), + loadSession: vi.fn().mockResolvedValue(null), + deleteSession: vi.fn().mockResolvedValue(undefined), + listSessions: vi.fn().mockResolvedValue([]), + getLatestSession: vi.fn().mockResolvedValue(null), + sessionExists: vi.fn().mockResolvedValue(false), + pushUndoEntry: vi.fn().mockResolvedValue(undefined), + popUndoEntry: vi.fn().mockResolvedValue(null), + getUndoStack: vi.fn().mockResolvedValue([]), + touchSession: vi.fn().mockResolvedValue(undefined), + clearAllSessions: vi.fn().mockResolvedValue(undefined), + } + + useCase = new StartSession(mockSessionStorage) + }) + + describe("execute", () => { + it("should create new session when no existing session", async () => { + const result = await useCase.execute("test-project") + + expect(result.isNew).toBe(true) + expect(result.session.projectName).toBe("test-project") + expect(mockSessionStorage.saveSession).toHaveBeenCalled() + }) + + it("should return latest session when one exists", async () => { + const existingSession = new Session("existing-id", "test-project") + vi.mocked(mockSessionStorage.getLatestSession).mockResolvedValue(existingSession) + + const result = await useCase.execute("test-project") + + expect(result.isNew).toBe(false) + expect(result.session.id).toBe("existing-id") + expect(mockSessionStorage.touchSession).toHaveBeenCalledWith("existing-id") + }) + + it("should load specific session by ID", async () => { + const specificSession = new Session("specific-id", "test-project") + vi.mocked(mockSessionStorage.loadSession).mockResolvedValue(specificSession) + + const result = await useCase.execute("test-project", { sessionId: "specific-id" }) + + expect(result.isNew).toBe(false) + expect(result.session.id).toBe("specific-id") + expect(mockSessionStorage.loadSession).toHaveBeenCalledWith("specific-id") + }) + + it("should create new session when specified session not found", async () => { + vi.mocked(mockSessionStorage.loadSession).mockResolvedValue(null) + + const result = await useCase.execute("test-project", { sessionId: "non-existent" }) + + expect(result.isNew).toBe(true) + expect(mockSessionStorage.saveSession).toHaveBeenCalled() + }) + + it("should force new session when forceNew is true", async () => { + const existingSession = new Session("existing-id", "test-project") + vi.mocked(mockSessionStorage.getLatestSession).mockResolvedValue(existingSession) + + const result = await useCase.execute("test-project", { forceNew: true }) + + expect(result.isNew).toBe(true) + expect(result.session.id).not.toBe("existing-id") + expect(mockSessionStorage.saveSession).toHaveBeenCalled() + }) + + it("should generate unique session IDs", async () => { + const result1 = await useCase.execute("test-project", { forceNew: true }) + const result2 = await useCase.execute("test-project", { forceNew: true }) + + expect(result1.session.id).not.toBe(result2.session.id) + }) + + it("should set correct project name on new session", async () => { + const result = await useCase.execute("my-special-project") + + expect(result.session.projectName).toBe("my-special-project") + }) + + it("should initialize new session with empty history", async () => { + const result = await useCase.execute("test-project") + + expect(result.session.history).toEqual([]) + }) + + it("should initialize new session with empty undo stack", async () => { + const result = await useCase.execute("test-project") + + expect(result.session.undoStack).toEqual([]) + }) + + it("should initialize new session with zero stats", async () => { + const result = await useCase.execute("test-project") + + expect(result.session.stats.totalTokens).toBe(0) + expect(result.session.stats.toolCalls).toBe(0) + expect(result.session.stats.editsApplied).toBe(0) + }) + }) +}) diff --git a/packages/ipuaro/tests/unit/application/use-cases/UndoChange.test.ts b/packages/ipuaro/tests/unit/application/use-cases/UndoChange.test.ts new file mode 100644 index 0000000..d08c949 --- /dev/null +++ b/packages/ipuaro/tests/unit/application/use-cases/UndoChange.test.ts @@ -0,0 +1,234 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" +import { promises as fs } from "node:fs" +import { UndoChange } from "../../../../src/application/use-cases/UndoChange.js" +import type { ISessionStorage } from "../../../../src/domain/services/ISessionStorage.js" +import type { IStorage } from "../../../../src/domain/services/IStorage.js" +import { Session } from "../../../../src/domain/entities/Session.js" +import type { UndoEntry } from "../../../../src/domain/value-objects/UndoEntry.js" + +vi.mock("node:fs", () => ({ + promises: { + readFile: vi.fn(), + writeFile: vi.fn(), + stat: vi.fn(), + }, +})) + +describe("UndoChange", () => { + let useCase: UndoChange + let mockSessionStorage: ISessionStorage + let mockStorage: IStorage + let session: Session + + const createUndoEntry = (overrides: Partial = {}): UndoEntry => ({ + id: "undo-1", + timestamp: Date.now(), + filePath: "/project/test.ts", + previousContent: ["const a = 1"], + newContent: ["const a = 2"], + description: "Edit test.ts", + ...overrides, + }) + + beforeEach(() => { + mockSessionStorage = { + saveSession: vi.fn().mockResolvedValue(undefined), + loadSession: vi.fn().mockResolvedValue(null), + deleteSession: vi.fn().mockResolvedValue(undefined), + listSessions: vi.fn().mockResolvedValue([]), + getLatestSession: vi.fn().mockResolvedValue(null), + sessionExists: vi.fn().mockResolvedValue(false), + pushUndoEntry: vi.fn().mockResolvedValue(undefined), + popUndoEntry: vi.fn().mockResolvedValue(null), + getUndoStack: vi.fn().mockResolvedValue([]), + touchSession: vi.fn().mockResolvedValue(undefined), + clearAllSessions: vi.fn().mockResolvedValue(undefined), + } + + mockStorage = { + getFile: vi.fn().mockResolvedValue(null), + setFile: vi.fn().mockResolvedValue(undefined), + deleteFile: vi.fn().mockResolvedValue(undefined), + getAllFiles: vi.fn().mockResolvedValue(new Map()), + getFileCount: vi.fn().mockResolvedValue(0), + getAST: vi.fn().mockResolvedValue(null), + setAST: vi.fn().mockResolvedValue(undefined), + deleteAST: vi.fn().mockResolvedValue(undefined), + getAllASTs: vi.fn().mockResolvedValue(new Map()), + getMeta: vi.fn().mockResolvedValue(null), + setMeta: vi.fn().mockResolvedValue(undefined), + deleteMeta: vi.fn().mockResolvedValue(undefined), + getAllMetas: vi.fn().mockResolvedValue(new Map()), + getSymbolIndex: vi.fn().mockResolvedValue(new Map()), + setSymbolIndex: vi.fn().mockResolvedValue(undefined), + getDepsGraph: vi.fn().mockResolvedValue({ imports: new Map(), importedBy: new Map() }), + setDepsGraph: vi.fn().mockResolvedValue(undefined), + getProjectConfig: vi.fn().mockResolvedValue(null), + setProjectConfig: vi.fn().mockResolvedValue(undefined), + connect: vi.fn().mockResolvedValue(undefined), + disconnect: vi.fn().mockResolvedValue(undefined), + isConnected: vi.fn().mockReturnValue(true), + clear: vi.fn().mockResolvedValue(undefined), + } + + session = new Session("test-session", "test-project") + session.stats.editsApplied = 1 + + useCase = new UndoChange(mockSessionStorage, mockStorage) + + vi.mocked(fs.stat).mockResolvedValue({ + size: 100, + mtimeMs: Date.now(), + } as unknown as Awaited>) + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + describe("execute", () => { + it("should return error when no undo entries", async () => { + vi.mocked(mockSessionStorage.popUndoEntry).mockResolvedValue(null) + + const result = await useCase.execute(session) + + expect(result.success).toBe(false) + expect(result.error).toBe("No changes to undo") + }) + + it("should restore previous content when file matches", async () => { + const entry = createUndoEntry() + vi.mocked(mockSessionStorage.popUndoEntry).mockResolvedValue(entry) + vi.mocked(fs.readFile).mockResolvedValue("const a = 2") + vi.mocked(fs.writeFile).mockResolvedValue(undefined) + + session.addUndoEntry(entry) + + const result = await useCase.execute(session) + + expect(result.success).toBe(true) + expect(result.entry).toBe(entry) + expect(fs.writeFile).toHaveBeenCalledWith(entry.filePath, "const a = 1", "utf-8") + }) + + it("should update storage after undo", async () => { + const entry = createUndoEntry() + vi.mocked(mockSessionStorage.popUndoEntry).mockResolvedValue(entry) + vi.mocked(fs.readFile).mockResolvedValue("const a = 2") + vi.mocked(fs.writeFile).mockResolvedValue(undefined) + + session.addUndoEntry(entry) + + await useCase.execute(session) + + expect(mockStorage.setFile).toHaveBeenCalledWith( + entry.filePath, + expect.objectContaining({ + lines: entry.previousContent, + }), + ) + }) + + it("should decrement editsApplied counter", async () => { + const entry = createUndoEntry() + vi.mocked(mockSessionStorage.popUndoEntry).mockResolvedValue(entry) + vi.mocked(fs.readFile).mockResolvedValue("const a = 2") + vi.mocked(fs.writeFile).mockResolvedValue(undefined) + + session.addUndoEntry(entry) + const initialEdits = session.stats.editsApplied + + await useCase.execute(session) + + expect(session.stats.editsApplied).toBe(initialEdits - 1) + }) + + it("should fail when file has been modified externally", async () => { + const entry = createUndoEntry() + vi.mocked(mockSessionStorage.popUndoEntry).mockResolvedValue(entry) + vi.mocked(fs.readFile).mockResolvedValue("const a = 999") + + const result = await useCase.execute(session) + + expect(result.success).toBe(false) + expect(result.error).toContain("modified since the change") + }) + + it("should re-push undo entry on conflict", async () => { + const entry = createUndoEntry() + vi.mocked(mockSessionStorage.popUndoEntry).mockResolvedValue(entry) + vi.mocked(fs.readFile).mockResolvedValue("const a = 999") + + await useCase.execute(session) + + expect(mockSessionStorage.pushUndoEntry).toHaveBeenCalledWith(session.id, entry) + }) + + it("should handle empty file for undo", async () => { + const entry = createUndoEntry({ + previousContent: [], + newContent: ["new content"], + }) + vi.mocked(mockSessionStorage.popUndoEntry).mockResolvedValue(entry) + vi.mocked(fs.readFile).mockResolvedValue("new content") + vi.mocked(fs.writeFile).mockResolvedValue(undefined) + + session.addUndoEntry(entry) + + const result = await useCase.execute(session) + + expect(result.success).toBe(true) + expect(fs.writeFile).toHaveBeenCalledWith(entry.filePath, "", "utf-8") + }) + + it("should handle file not found during undo", async () => { + const entry = createUndoEntry() + vi.mocked(mockSessionStorage.popUndoEntry).mockResolvedValue(entry) + const error = new Error("ENOENT") as NodeJS.ErrnoException + error.code = "ENOENT" + vi.mocked(fs.readFile).mockRejectedValue(error) + + const result = await useCase.execute(session) + + expect(result.success).toBe(false) + }) + }) + + describe("canUndo", () => { + it("should return false when stack is empty", async () => { + vi.mocked(mockSessionStorage.getUndoStack).mockResolvedValue([]) + + const result = await useCase.canUndo(session) + + expect(result).toBe(false) + }) + + it("should return true when stack has entries", async () => { + vi.mocked(mockSessionStorage.getUndoStack).mockResolvedValue([createUndoEntry()]) + + const result = await useCase.canUndo(session) + + expect(result).toBe(true) + }) + }) + + describe("peekUndoEntry", () => { + it("should return null when stack is empty", async () => { + vi.mocked(mockSessionStorage.getUndoStack).mockResolvedValue([]) + + const result = await useCase.peekUndoEntry(session) + + expect(result).toBeNull() + }) + + it("should return last entry without removing", async () => { + const entry = createUndoEntry() + vi.mocked(mockSessionStorage.getUndoStack).mockResolvedValue([entry]) + + const result = await useCase.peekUndoEntry(session) + + expect(result).toBe(entry) + expect(mockSessionStorage.popUndoEntry).not.toHaveBeenCalled() + }) + }) +}) diff --git a/packages/ipuaro/tests/unit/infrastructure/storage/RedisSessionStorage.test.ts b/packages/ipuaro/tests/unit/infrastructure/storage/RedisSessionStorage.test.ts new file mode 100644 index 0000000..c5168e6 --- /dev/null +++ b/packages/ipuaro/tests/unit/infrastructure/storage/RedisSessionStorage.test.ts @@ -0,0 +1,390 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { RedisSessionStorage } from "../../../../src/infrastructure/storage/RedisSessionStorage.js" +import { RedisClient } from "../../../../src/infrastructure/storage/RedisClient.js" +import { Session } from "../../../../src/domain/entities/Session.js" +import type { UndoEntry } from "../../../../src/domain/value-objects/UndoEntry.js" +import { SessionKeys, SessionFields } from "../../../../src/infrastructure/storage/schema.js" + +describe("RedisSessionStorage", () => { + let storage: RedisSessionStorage + let mockRedis: { + hset: ReturnType + hget: ReturnType + hgetall: ReturnType + del: ReturnType + lrange: ReturnType + lpush: ReturnType + lpos: ReturnType + lrem: ReturnType + rpush: ReturnType + rpop: ReturnType + llen: ReturnType + lpop: ReturnType + exists: ReturnType + pipeline: ReturnType + } + let mockClient: RedisClient + + beforeEach(() => { + mockRedis = { + hset: vi.fn().mockResolvedValue(1), + hget: vi.fn().mockResolvedValue(null), + hgetall: vi.fn().mockResolvedValue({}), + del: vi.fn().mockResolvedValue(1), + lrange: vi.fn().mockResolvedValue([]), + lpush: vi.fn().mockResolvedValue(1), + lpos: vi.fn().mockResolvedValue(null), + lrem: vi.fn().mockResolvedValue(1), + rpush: vi.fn().mockResolvedValue(1), + rpop: vi.fn().mockResolvedValue(null), + llen: vi.fn().mockResolvedValue(0), + lpop: vi.fn().mockResolvedValue(null), + exists: vi.fn().mockResolvedValue(0), + pipeline: vi.fn().mockReturnValue({ + hset: vi.fn().mockReturnThis(), + del: vi.fn().mockReturnThis(), + exec: vi.fn().mockResolvedValue([]), + }), + } + + mockClient = { + getClient: () => mockRedis, + connect: vi.fn(), + disconnect: vi.fn(), + isConnected: vi.fn().mockReturnValue(true), + } as unknown as RedisClient + + storage = new RedisSessionStorage(mockClient) + }) + + describe("saveSession", () => { + it("should save session data to Redis", async () => { + const session = new Session("test-session-1", "test-project") + session.history = [{ role: "user", content: "Hello", timestamp: Date.now() }] + + await storage.saveSession(session) + + const pipeline = mockRedis.pipeline() + expect(pipeline.hset).toHaveBeenCalled() + expect(pipeline.exec).toHaveBeenCalled() + }) + + it("should add session to list if not exists", async () => { + const session = new Session("test-session-2", "test-project") + + await storage.saveSession(session) + + expect(mockRedis.lpos).toHaveBeenCalledWith(SessionKeys.list, "test-session-2") + expect(mockRedis.lpush).toHaveBeenCalledWith(SessionKeys.list, "test-session-2") + }) + + it("should not add session to list if already exists", async () => { + const session = new Session("existing-session", "test-project") + mockRedis.lpos.mockResolvedValue(0) + + await storage.saveSession(session) + + expect(mockRedis.lpush).not.toHaveBeenCalled() + }) + }) + + describe("loadSession", () => { + it("should return null for non-existent session", async () => { + mockRedis.hgetall.mockResolvedValue({}) + + const result = await storage.loadSession("non-existent") + + expect(result).toBeNull() + }) + + it("should load session from Redis", async () => { + const sessionData = { + [SessionFields.projectName]: "test-project", + [SessionFields.createdAt]: "1700000000000", + [SessionFields.lastActivityAt]: "1700001000000", + [SessionFields.history]: "[]", + [SessionFields.context]: JSON.stringify({ + filesInContext: [], + tokenUsage: 0, + needsCompression: false, + }), + [SessionFields.stats]: JSON.stringify({ + totalTokens: 0, + totalTimeMs: 0, + toolCalls: 0, + editsApplied: 0, + editsRejected: 0, + }), + [SessionFields.inputHistory]: "[]", + } + mockRedis.hgetall.mockResolvedValue(sessionData) + mockRedis.lrange.mockResolvedValue([]) + + const result = await storage.loadSession("test-session") + + expect(result).not.toBeNull() + expect(result?.id).toBe("test-session") + expect(result?.projectName).toBe("test-project") + expect(result?.createdAt).toBe(1700000000000) + }) + + it("should load undo stack with session", async () => { + const sessionData = { + [SessionFields.projectName]: "test-project", + [SessionFields.createdAt]: "1700000000000", + [SessionFields.lastActivityAt]: "1700001000000", + [SessionFields.history]: "[]", + [SessionFields.context]: "{}", + [SessionFields.stats]: "{}", + [SessionFields.inputHistory]: "[]", + } + const undoEntry: UndoEntry = { + id: "undo-1", + timestamp: Date.now(), + filePath: "test.ts", + previousContent: ["old"], + newContent: ["new"], + description: "Edit", + } + mockRedis.hgetall.mockResolvedValue(sessionData) + mockRedis.lrange.mockResolvedValue([JSON.stringify(undoEntry)]) + + const result = await storage.loadSession("test-session") + + expect(result?.undoStack).toHaveLength(1) + expect(result?.undoStack[0].id).toBe("undo-1") + }) + }) + + describe("deleteSession", () => { + it("should delete session data and undo stack", async () => { + await storage.deleteSession("test-session") + + expect(mockRedis.del).toHaveBeenCalledWith(SessionKeys.data("test-session")) + expect(mockRedis.del).toHaveBeenCalledWith(SessionKeys.undo("test-session")) + expect(mockRedis.lrem).toHaveBeenCalledWith(SessionKeys.list, 0, "test-session") + }) + }) + + describe("listSessions", () => { + it("should return empty array when no sessions", async () => { + mockRedis.lrange.mockResolvedValue([]) + + const result = await storage.listSessions() + + expect(result).toEqual([]) + }) + + it("should list all sessions", async () => { + mockRedis.lrange.mockResolvedValue(["session-1", "session-2"]) + mockRedis.hgetall.mockImplementation((key: string) => { + if (key.includes("session-1")) { + return Promise.resolve({ + [SessionFields.projectName]: "project-1", + [SessionFields.createdAt]: "1700000000000", + [SessionFields.lastActivityAt]: "1700001000000", + [SessionFields.history]: "[]", + }) + } + if (key.includes("session-2")) { + return Promise.resolve({ + [SessionFields.projectName]: "project-2", + [SessionFields.createdAt]: "1700002000000", + [SessionFields.lastActivityAt]: "1700003000000", + [SessionFields.history]: '[{"role":"user","content":"Hi"}]', + }) + } + return Promise.resolve({}) + }) + + const result = await storage.listSessions() + + expect(result).toHaveLength(2) + expect(result[0].id).toBe("session-2") + expect(result[1].id).toBe("session-1") + }) + + it("should filter by project name", async () => { + mockRedis.lrange.mockResolvedValue(["session-1", "session-2"]) + mockRedis.hgetall.mockImplementation((key: string) => { + if (key.includes("session-1")) { + return Promise.resolve({ + [SessionFields.projectName]: "project-1", + [SessionFields.createdAt]: "1700000000000", + [SessionFields.lastActivityAt]: "1700001000000", + [SessionFields.history]: "[]", + }) + } + if (key.includes("session-2")) { + return Promise.resolve({ + [SessionFields.projectName]: "project-2", + [SessionFields.createdAt]: "1700002000000", + [SessionFields.lastActivityAt]: "1700003000000", + [SessionFields.history]: "[]", + }) + } + return Promise.resolve({}) + }) + + const result = await storage.listSessions("project-1") + + expect(result).toHaveLength(1) + expect(result[0].projectName).toBe("project-1") + }) + }) + + describe("getLatestSession", () => { + it("should return null when no sessions", async () => { + mockRedis.lrange.mockResolvedValue([]) + + const result = await storage.getLatestSession("test-project") + + expect(result).toBeNull() + }) + + it("should return the most recent session", async () => { + mockRedis.lrange.mockImplementation((key: string) => { + if (key === SessionKeys.list) { + return Promise.resolve(["session-1"]) + } + return Promise.resolve([]) + }) + mockRedis.hgetall.mockResolvedValue({ + [SessionFields.projectName]: "test-project", + [SessionFields.createdAt]: "1700000000000", + [SessionFields.lastActivityAt]: "1700001000000", + [SessionFields.history]: "[]", + [SessionFields.context]: "{}", + [SessionFields.stats]: "{}", + [SessionFields.inputHistory]: "[]", + }) + + const result = await storage.getLatestSession("test-project") + + expect(result).not.toBeNull() + expect(result?.id).toBe("session-1") + }) + }) + + describe("sessionExists", () => { + it("should return false for non-existent session", async () => { + mockRedis.exists.mockResolvedValue(0) + + const result = await storage.sessionExists("non-existent") + + expect(result).toBe(false) + }) + + it("should return true for existing session", async () => { + mockRedis.exists.mockResolvedValue(1) + + const result = await storage.sessionExists("existing") + + expect(result).toBe(true) + }) + }) + + describe("undo stack operations", () => { + const undoEntry: UndoEntry = { + id: "undo-1", + timestamp: Date.now(), + filePath: "test.ts", + previousContent: ["old"], + newContent: ["new"], + description: "Edit", + } + + describe("pushUndoEntry", () => { + it("should push undo entry to stack", async () => { + mockRedis.llen.mockResolvedValue(1) + + await storage.pushUndoEntry("session-1", undoEntry) + + expect(mockRedis.rpush).toHaveBeenCalledWith( + SessionKeys.undo("session-1"), + JSON.stringify(undoEntry), + ) + }) + + it("should remove oldest entry when stack exceeds limit", async () => { + mockRedis.llen.mockResolvedValue(11) + + await storage.pushUndoEntry("session-1", undoEntry) + + expect(mockRedis.lpop).toHaveBeenCalledWith(SessionKeys.undo("session-1")) + }) + }) + + describe("popUndoEntry", () => { + it("should return null for empty stack", async () => { + mockRedis.rpop.mockResolvedValue(null) + + const result = await storage.popUndoEntry("session-1") + + expect(result).toBeNull() + }) + + it("should pop and return undo entry", async () => { + mockRedis.rpop.mockResolvedValue(JSON.stringify(undoEntry)) + + const result = await storage.popUndoEntry("session-1") + + expect(result).not.toBeNull() + expect(result?.id).toBe("undo-1") + }) + }) + + describe("getUndoStack", () => { + it("should return empty array for empty stack", async () => { + mockRedis.lrange.mockResolvedValue([]) + + const result = await storage.getUndoStack("session-1") + + expect(result).toEqual([]) + }) + + it("should return all undo entries", async () => { + mockRedis.lrange.mockResolvedValue([ + JSON.stringify({ ...undoEntry, id: "undo-1" }), + JSON.stringify({ ...undoEntry, id: "undo-2" }), + ]) + + const result = await storage.getUndoStack("session-1") + + expect(result).toHaveLength(2) + expect(result[0].id).toBe("undo-1") + expect(result[1].id).toBe("undo-2") + }) + }) + }) + + describe("touchSession", () => { + it("should update last activity timestamp", async () => { + const beforeTouch = Date.now() + + await storage.touchSession("session-1") + + expect(mockRedis.hset).toHaveBeenCalledWith( + SessionKeys.data("session-1"), + SessionFields.lastActivityAt, + expect.any(String), + ) + + const callArgs = mockRedis.hset.mock.calls[0] + const timestamp = Number(callArgs[2]) + expect(timestamp).toBeGreaterThanOrEqual(beforeTouch) + }) + }) + + describe("clearAllSessions", () => { + it("should clear all session data", async () => { + mockRedis.lrange.mockResolvedValue(["session-1", "session-2"]) + + await storage.clearAllSessions() + + const pipeline = mockRedis.pipeline() + expect(pipeline.del).toHaveBeenCalled() + expect(pipeline.exec).toHaveBeenCalled() + }) + }) +}) diff --git a/packages/ipuaro/tests/unit/infrastructure/tools/git/GitCommitTool.test.ts b/packages/ipuaro/tests/unit/infrastructure/tools/git/GitCommitTool.test.ts index 7116e2a..d3614fd 100644 --- a/packages/ipuaro/tests/unit/infrastructure/tools/git/GitCommitTool.test.ts +++ b/packages/ipuaro/tests/unit/infrastructure/tools/git/GitCommitTool.test.ts @@ -35,10 +35,7 @@ function createMockStorage(): IStorage { } as unknown as IStorage } -function createMockContext( - storage?: IStorage, - confirmResult: boolean = true, -): ToolContext { +function createMockContext(storage?: IStorage, confirmResult: boolean = true): ToolContext { return { projectRoot: "/test/project", storage: storage ?? createMockStorage(), @@ -47,9 +44,7 @@ function createMockContext( } } -function createMockStatusResult( - overrides: Partial = {}, -): StatusResult { +function createMockStatusResult(overrides: Partial = {}): StatusResult { return { not_added: [], conflicted: [], @@ -70,9 +65,7 @@ function createMockStatusResult( } as StatusResult } -function createMockCommitResult( - overrides: Partial = {}, -): CommitResult { +function createMockCommitResult(overrides: Partial = {}): CommitResult { return { commit: "abc1234", branch: "main", @@ -96,9 +89,7 @@ function createMockGit(options: { }): SimpleGit { const mockGit = { checkIsRepo: vi.fn().mockResolvedValue(options.isRepo ?? true), - status: vi.fn().mockResolvedValue( - options.status ?? createMockStatusResult(), - ), + status: vi.fn().mockResolvedValue(options.status ?? createMockStatusResult()), add: vi.fn(), commit: vi.fn(), } @@ -112,9 +103,7 @@ function createMockGit(options: { if (options.error) { mockGit.commit.mockRejectedValue(options.error) } else { - mockGit.commit.mockResolvedValue( - options.commitResult ?? createMockCommitResult(), - ) + mockGit.commit.mockResolvedValue(options.commitResult ?? createMockCommitResult()) } return mockGit as unknown as SimpleGit @@ -175,21 +164,15 @@ describe("GitCommitTool", () => { }) it("should return null for valid message with files", () => { - expect( - tool.validateParams({ message: "fix: bug", files: ["a.ts", "b.ts"] }), - ).toBeNull() + expect(tool.validateParams({ message: "fix: bug", files: ["a.ts", "b.ts"] })).toBeNull() }) it("should return error for non-array files", () => { - expect( - tool.validateParams({ message: "fix: bug", files: "a.ts" }), - ).toContain("array") + expect(tool.validateParams({ message: "fix: bug", files: "a.ts" })).toContain("array") }) it("should return error for non-string in files array", () => { - expect( - tool.validateParams({ message: "fix: bug", files: [1, 2] }), - ).toContain("strings") + expect(tool.validateParams({ message: "fix: bug", files: [1, 2] })).toContain("strings") }) }) @@ -200,10 +183,7 @@ describe("GitCommitTool", () => { const toolWithMock = new GitCommitTool(() => mockGit) const ctx = createMockContext() - const result = await toolWithMock.execute( - { message: "test commit" }, - ctx, - ) + const result = await toolWithMock.execute({ message: "test commit" }, ctx) expect(result.success).toBe(false) expect(result.error).toContain("Not a git repository") @@ -218,10 +198,7 @@ describe("GitCommitTool", () => { const toolWithMock = new GitCommitTool(() => mockGit) const ctx = createMockContext() - const result = await toolWithMock.execute( - { message: "test commit" }, - ctx, - ) + const result = await toolWithMock.execute({ message: "test commit" }, ctx) expect(result.success).toBe(false) expect(result.error).toContain("Nothing to commit") @@ -241,10 +218,7 @@ describe("GitCommitTool", () => { const toolWithMock = new GitCommitTool(() => mockGit) const ctx = createMockContext() - const result = await toolWithMock.execute( - { message: "feat: new feature" }, - ctx, - ) + const result = await toolWithMock.execute({ message: "feat: new feature" }, ctx) expect(result.success).toBe(true) const data = result.data as GitCommitResult @@ -268,10 +242,7 @@ describe("GitCommitTool", () => { const toolWithMock = new GitCommitTool(() => mockGit) const ctx = createMockContext() - const result = await toolWithMock.execute( - { message: "test commit" }, - ctx, - ) + const result = await toolWithMock.execute({ message: "test commit" }, ctx) expect(result.success).toBe(true) const data = result.data as GitCommitResult @@ -290,10 +261,7 @@ describe("GitCommitTool", () => { const toolWithMock = new GitCommitTool(() => mockGit) const ctx = createMockContext() - await toolWithMock.execute( - { message: "test", files: ["a.ts", "b.ts"] }, - ctx, - ) + await toolWithMock.execute({ message: "test", files: ["a.ts", "b.ts"] }, ctx) expect(mockGit.add).toHaveBeenCalledWith(["a.ts", "b.ts"]) }) @@ -303,10 +271,7 @@ describe("GitCommitTool", () => { const toolWithMock = new GitCommitTool(() => mockGit) const ctx = createMockContext() - await toolWithMock.execute( - { message: "test", files: [] }, - ctx, - ) + await toolWithMock.execute({ message: "test", files: [] }, ctx) expect(mockGit.add).not.toHaveBeenCalled() }) @@ -337,8 +302,8 @@ describe("GitCommitTool", () => { await toolWithMock.execute({ message: "test commit" }, ctx) expect(ctx.requestConfirmation).toHaveBeenCalled() - const confirmMessage = (ctx.requestConfirmation as ReturnType) - .mock.calls[0][0] as string + const confirmMessage = (ctx.requestConfirmation as ReturnType).mock + .calls[0][0] as string expect(confirmMessage).toContain("Committing") expect(confirmMessage).toContain("test commit") }) @@ -348,10 +313,7 @@ describe("GitCommitTool", () => { const toolWithMock = new GitCommitTool(() => mockGit) const ctx = createMockContext(undefined, false) - const result = await toolWithMock.execute( - { message: "test commit" }, - ctx, - ) + const result = await toolWithMock.execute({ message: "test commit" }, ctx) expect(result.success).toBe(false) expect(result.error).toContain("cancelled") @@ -363,10 +325,7 @@ describe("GitCommitTool", () => { const toolWithMock = new GitCommitTool(() => mockGit) const ctx = createMockContext(undefined, true) - const result = await toolWithMock.execute( - { message: "test commit" }, - ctx, - ) + const result = await toolWithMock.execute({ message: "test commit" }, ctx) expect(result.success).toBe(true) expect(mockGit.commit).toHaveBeenCalledWith("test commit") @@ -381,10 +340,7 @@ describe("GitCommitTool", () => { const toolWithMock = new GitCommitTool(() => mockGit) const ctx = createMockContext() - const result = await toolWithMock.execute( - { message: "test commit" }, - ctx, - ) + const result = await toolWithMock.execute({ message: "test commit" }, ctx) expect(result.success).toBe(false) expect(result.error).toContain("Git commit failed") @@ -400,10 +356,7 @@ describe("GitCommitTool", () => { const toolWithMock = new GitCommitTool(() => mockGit) const ctx = createMockContext() - const result = await toolWithMock.execute( - { message: "test commit" }, - ctx, - ) + const result = await toolWithMock.execute({ message: "test commit" }, ctx) expect(result.success).toBe(false) expect(result.error).toBe("string error") @@ -416,10 +369,7 @@ describe("GitCommitTool", () => { const toolWithMock = new GitCommitTool(() => mockGit) const ctx = createMockContext() - const result = await toolWithMock.execute( - { message: "test commit" }, - ctx, - ) + const result = await toolWithMock.execute({ message: "test commit" }, ctx) expect(result.executionTimeMs).toBeGreaterThanOrEqual(0) }) @@ -431,10 +381,7 @@ describe("GitCommitTool", () => { const toolWithMock = new GitCommitTool(() => mockGit) const ctx = createMockContext() - const result = await toolWithMock.execute( - { message: "test commit" }, - ctx, - ) + const result = await toolWithMock.execute({ message: "test commit" }, ctx) expect(result.callId).toMatch(/^git_commit-\d+$/) }) diff --git a/packages/ipuaro/tests/unit/infrastructure/tools/git/GitDiffTool.test.ts b/packages/ipuaro/tests/unit/infrastructure/tools/git/GitDiffTool.test.ts index b946b8c..5ed83af 100644 --- a/packages/ipuaro/tests/unit/infrastructure/tools/git/GitDiffTool.test.ts +++ b/packages/ipuaro/tests/unit/infrastructure/tools/git/GitDiffTool.test.ts @@ -69,9 +69,7 @@ function createMockGit(options: { if (options.error) { mockGit.diffSummary.mockRejectedValue(options.error) } else { - mockGit.diffSummary.mockResolvedValue( - options.diffSummary ?? createMockDiffSummary(), - ) + mockGit.diffSummary.mockResolvedValue(options.diffSummary ?? createMockDiffSummary()) mockGit.diff.mockResolvedValue(options.diff ?? "") } @@ -224,9 +222,7 @@ describe("GitDiffTool", () => { it("should handle binary files", async () => { const mockGit = createMockGit({ diffSummary: createMockDiffSummary({ - files: [ - { file: "image.png", insertions: 0, deletions: 0, binary: true }, - ], + files: [{ file: "image.png", insertions: 0, deletions: 0, binary: true }], }), }) const toolWithMock = new GitDiffTool(() => mockGit) @@ -293,11 +289,7 @@ describe("GitDiffTool", () => { ) expect(result.success).toBe(true) - expect(mockGit.diffSummary).toHaveBeenCalledWith([ - "--cached", - "--", - "src/index.ts", - ]) + expect(mockGit.diffSummary).toHaveBeenCalledWith(["--cached", "--", "src/index.ts"]) }) it("should return null pathFilter when not provided", async () => { diff --git a/packages/ipuaro/tests/unit/infrastructure/tools/run/CommandSecurity.test.ts b/packages/ipuaro/tests/unit/infrastructure/tools/run/CommandSecurity.test.ts index e7d6882..dcc9a91 100644 --- a/packages/ipuaro/tests/unit/infrastructure/tools/run/CommandSecurity.test.ts +++ b/packages/ipuaro/tests/unit/infrastructure/tools/run/CommandSecurity.test.ts @@ -14,12 +14,8 @@ describe("CommandSecurity", () => { describe("constructor", () => { it("should use default blacklist and whitelist", () => { - expect(security.getBlacklist()).toEqual( - DEFAULT_BLACKLIST.map((c) => c.toLowerCase()), - ) - expect(security.getWhitelist()).toEqual( - DEFAULT_WHITELIST.map((c) => c.toLowerCase()), - ) + expect(security.getBlacklist()).toEqual(DEFAULT_BLACKLIST.map((c) => c.toLowerCase())) + expect(security.getWhitelist()).toEqual(DEFAULT_WHITELIST.map((c) => c.toLowerCase())) }) it("should accept custom blacklist and whitelist", () => { diff --git a/packages/ipuaro/tests/unit/infrastructure/tools/run/RunCommandTool.test.ts b/packages/ipuaro/tests/unit/infrastructure/tools/run/RunCommandTool.test.ts index d4a340a..f638659 100644 --- a/packages/ipuaro/tests/unit/infrastructure/tools/run/RunCommandTool.test.ts +++ b/packages/ipuaro/tests/unit/infrastructure/tools/run/RunCommandTool.test.ts @@ -35,10 +35,7 @@ function createMockStorage(): IStorage { } as unknown as IStorage } -function createMockContext( - storage?: IStorage, - confirmResult: boolean = true, -): ToolContext { +function createMockContext(storage?: IStorage, confirmResult: boolean = true): ToolContext { return { projectRoot: "/test/project", storage: storage ?? createMockStorage(), @@ -48,10 +45,7 @@ function createMockContext( } type ExecResult = { stdout: string; stderr: string } -type ExecFn = ( - command: string, - options: Record, -) => Promise +type ExecFn = (command: string, options: Record) => Promise function createMockExec(options: { stdout?: string @@ -123,27 +117,19 @@ describe("RunCommandTool", () => { }) it("should return error for non-number timeout", () => { - expect( - tool.validateParams({ command: "ls", timeout: "5000" }), - ).toContain("number") + expect(tool.validateParams({ command: "ls", timeout: "5000" })).toContain("number") }) it("should return error for negative timeout", () => { - expect(tool.validateParams({ command: "ls", timeout: -1 })).toContain( - "positive", - ) + expect(tool.validateParams({ command: "ls", timeout: -1 })).toContain("positive") }) it("should return error for zero timeout", () => { - expect(tool.validateParams({ command: "ls", timeout: 0 })).toContain( - "positive", - ) + expect(tool.validateParams({ command: "ls", timeout: 0 })).toContain("positive") }) it("should return error for timeout > 10 minutes", () => { - expect( - tool.validateParams({ command: "ls", timeout: 600001 }), - ).toContain("600000") + expect(tool.validateParams({ command: "ls", timeout: 600001 })).toContain("600000") }) it("should return null for valid timeout", () => { @@ -180,10 +166,7 @@ describe("RunCommandTool", () => { const toolWithMock = new RunCommandTool(undefined, execFn) const ctx = createMockContext() - const result = await toolWithMock.execute( - { command: "git push --force" }, - ctx, - ) + const result = await toolWithMock.execute({ command: "git push --force" }, ctx) expect(result.success).toBe(false) expect(result.error).toContain("blocked") @@ -250,10 +233,7 @@ describe("RunCommandTool", () => { const toolWithMock = new RunCommandTool(undefined, execFn) const ctx = createMockContext(undefined, true) - const result = await toolWithMock.execute( - { command: "custom-script" }, - ctx, - ) + const result = await toolWithMock.execute({ command: "custom-script" }, ctx) expect(result.success).toBe(true) const data = result.data as RunCommandResult @@ -266,10 +246,7 @@ describe("RunCommandTool", () => { const toolWithMock = new RunCommandTool(undefined, execFn) const ctx = createMockContext(undefined, false) - const result = await toolWithMock.execute( - { command: "custom-script" }, - ctx, - ) + const result = await toolWithMock.execute({ command: "custom-script" }, ctx) expect(result.success).toBe(false) expect(result.error).toContain("cancelled") @@ -364,10 +341,7 @@ describe("RunCommandTool", () => { await toolWithMock.execute({ command: "ls" }, ctx) - expect(execFn).toHaveBeenCalledWith( - "ls", - expect.objectContaining({ timeout: 30000 }), - ) + expect(execFn).toHaveBeenCalledWith("ls", expect.objectContaining({ timeout: 30000 })) }) it("should use custom timeout", async () => { @@ -377,10 +351,7 @@ describe("RunCommandTool", () => { await toolWithMock.execute({ command: "ls", timeout: 5000 }, ctx) - expect(execFn).toHaveBeenCalledWith( - "ls", - expect.objectContaining({ timeout: 5000 }), - ) + expect(execFn).toHaveBeenCalledWith("ls", expect.objectContaining({ timeout: 5000 })) }) it("should execute in project root", async () => { @@ -493,10 +464,7 @@ describe("RunCommandTool", () => { toolWithMock.getSecurity().addToWhitelist(["custom-safe"]) - const result = await toolWithMock.execute( - { command: "custom-safe arg" }, - ctx, - ) + const result = await toolWithMock.execute({ command: "custom-safe arg" }, ctx) expect(result.success).toBe(true) expect(ctx.requestConfirmation).not.toHaveBeenCalled() diff --git a/packages/ipuaro/tests/unit/infrastructure/tools/run/RunTestsTool.test.ts b/packages/ipuaro/tests/unit/infrastructure/tools/run/RunTestsTool.test.ts index bdf573a..12fe3ce 100644 --- a/packages/ipuaro/tests/unit/infrastructure/tools/run/RunTestsTool.test.ts +++ b/packages/ipuaro/tests/unit/infrastructure/tools/run/RunTestsTool.test.ts @@ -45,10 +45,7 @@ function createMockContext(storage?: IStorage): ToolContext { } type ExecResult = { stdout: string; stderr: string } -type ExecFn = ( - command: string, - options: Record, -) => Promise +type ExecFn = (command: string, options: Record) => Promise function createMockExec(options: { stdout?: string @@ -127,9 +124,7 @@ describe("RunTestsTool", () => { }) it("should return null for valid params", () => { - expect( - tool.validateParams({ path: "src", filter: "login", watch: true }), - ).toBeNull() + expect(tool.validateParams({ path: "src", filter: "login", watch: true })).toBeNull() }) it("should return error for invalid path", () => {