mirror of
https://github.com/samiyev/puaros.git
synced 2025-12-27 23:06:54 +05:00
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
This commit is contained in:
@@ -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/),
|
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).
|
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
|
## [0.9.0] - 2025-12-01 - Git & Run Tools
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -1,40 +1,95 @@
|
|||||||
# ipuaro TODO
|
# 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
|
## In Progress
|
||||||
|
|
||||||
### Version 0.2.0 - Redis Storage
|
### Version 0.11.0 - TUI Basic
|
||||||
- [ ] RedisClient with AOF config
|
- [ ] App shell (Ink/React)
|
||||||
- [ ] Redis schema implementation
|
- [ ] StatusBar component
|
||||||
- [ ] RedisStorage class
|
- [ ] Chat component
|
||||||
|
- [ ] Input component
|
||||||
|
|
||||||
## Planned
|
## Planned
|
||||||
|
|
||||||
### Version 0.3.0 - Indexer
|
### Version 0.12.0 - TUI Advanced
|
||||||
- [ ] FileScanner with gitignore support
|
- [ ] DiffView component
|
||||||
- [ ] ASTParser with tree-sitter
|
- [ ] ConfirmDialog component
|
||||||
- [ ] MetaAnalyzer for complexity
|
- [ ] ErrorDialog component
|
||||||
- [ ] IndexBuilder for symbols
|
- [ ] Progress component
|
||||||
- [ ] Watchdog for file changes
|
|
||||||
|
|
||||||
### Version 0.4.0 - LLM Integration
|
### Version 0.13.0+ - Commands & Polish
|
||||||
- [ ] OllamaClient implementation
|
- [ ] Slash commands (/help, /clear, /undo, /sessions, /status)
|
||||||
- [ ] System prompt design
|
- [ ] Hotkeys (Ctrl+C, Ctrl+D, Ctrl+Z)
|
||||||
- [ ] Tool definitions (XML format)
|
- [ ] Auto-compression at 80% context
|
||||||
- [ ] Response parser
|
|
||||||
|
|
||||||
### Version 0.5.0+ - Tools
|
### Version 0.14.0 - CLI Entry Point
|
||||||
- [ ] Read tools (get_lines, get_function, get_class, get_structure)
|
- [ ] Full CLI commands (start, init, index)
|
||||||
- [ ] Edit tools (edit_lines, create_file, delete_file)
|
- [ ] Onboarding flow (Redis check, Ollama check, model pull)
|
||||||
- [ ] 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.)
|
|
||||||
|
|
||||||
## Technical Debt
|
## Technical Debt
|
||||||
|
|
||||||
@@ -51,4 +106,4 @@ _None at this time._
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Last Updated:** 2025-01-29
|
**Last Updated:** 2025-12-01
|
||||||
229
packages/ipuaro/src/application/use-cases/ContextManager.ts
Normal file
229
packages/ipuaro/src/application/use-cases/ContextManager.ts
Normal file
@@ -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<string, FileContext>()
|
||||||
|
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<CompressionResult> {
|
||||||
|
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<string> {
|
||||||
|
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<number> {
|
||||||
|
let total = 0
|
||||||
|
for (const message of messages) {
|
||||||
|
total += await llm.countTokens(message.content)
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
}
|
||||||
383
packages/ipuaro/src/application/use-cases/HandleMessage.ts
Normal file
383
packages/ipuaro/src/application/use-cases/HandleMessage.ts
Normal file
@@ -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<boolean>
|
||||||
|
onError?: (error: IpuaroError) => Promise<ErrorChoice>
|
||||||
|
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<void> {
|
||||||
|
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<ChatMessage[]> {
|
||||||
|
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<ToolResult> {
|
||||||
|
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<boolean> {
|
||||||
|
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<void> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
62
packages/ipuaro/src/application/use-cases/StartSession.ts
Normal file
62
packages/ipuaro/src/application/use-cases/StartSession.ts
Normal file
@@ -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<StartSessionResult> {
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
119
packages/ipuaro/src/application/use-cases/UndoChange.ts
Normal file
119
packages/ipuaro/src/application/use-cases/UndoChange.ts
Normal file
@@ -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<UndoResult> {
|
||||||
|
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<boolean> {
|
||||||
|
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<UndoEntry | null> {
|
||||||
|
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<string[]> {
|
||||||
|
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<void> {
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
/*
|
// Application Use Cases
|
||||||
* Application Use Cases
|
|
||||||
* Will be implemented in version 0.10.0+
|
export * from "./StartSession.js"
|
||||||
*/
|
export * from "./HandleMessage.js"
|
||||||
|
export * from "./UndoChange.js"
|
||||||
|
export * from "./ContextManager.js"
|
||||||
|
|||||||
88
packages/ipuaro/src/domain/services/ISessionStorage.ts
Normal file
88
packages/ipuaro/src/domain/services/ISessionStorage.ts
Normal file
@@ -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<void>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load a session by ID.
|
||||||
|
*/
|
||||||
|
loadSession(sessionId: string): Promise<Session | null>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a session.
|
||||||
|
*/
|
||||||
|
deleteSession(sessionId: string): Promise<void>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get list of all sessions for a project.
|
||||||
|
*/
|
||||||
|
listSessions(projectName?: string): Promise<SessionListItem[]>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the latest session for a project.
|
||||||
|
*/
|
||||||
|
getLatestSession(projectName: string): Promise<Session | null>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a session exists.
|
||||||
|
*/
|
||||||
|
sessionExists(sessionId: string): Promise<boolean>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add undo entry to session's undo stack.
|
||||||
|
*/
|
||||||
|
pushUndoEntry(sessionId: string, entry: UndoEntry): Promise<void>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pop undo entry from session's undo stack.
|
||||||
|
*/
|
||||||
|
popUndoEntry(sessionId: string): Promise<UndoEntry | null>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get undo stack for a session.
|
||||||
|
*/
|
||||||
|
getUndoStack(sessionId: string): Promise<UndoEntry[]>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update session's last activity timestamp.
|
||||||
|
*/
|
||||||
|
touchSession(sessionId: string): Promise<void>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all sessions.
|
||||||
|
*/
|
||||||
|
clearAllSessions(): Promise<void>
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
// Domain Service Interfaces (Ports)
|
// Domain Service Interfaces (Ports)
|
||||||
export * from "./IStorage.js"
|
export * from "./IStorage.js"
|
||||||
|
export * from "./ISessionStorage.js"
|
||||||
export * from "./ILLMClient.js"
|
export * from "./ILLMClient.js"
|
||||||
export * from "./ITool.js"
|
export * from "./ITool.js"
|
||||||
export * from "./IIndexer.js"
|
export * from "./IIndexer.js"
|
||||||
|
|||||||
@@ -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<void> {
|
||||||
|
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<Session | null> {
|
||||||
|
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<void> {
|
||||||
|
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<SessionListItem[]> {
|
||||||
|
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<Session | null> {
|
||||||
|
const sessions = await this.listSessions(projectName)
|
||||||
|
if (sessions.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.loadSession(sessions[0].id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async sessionExists(sessionId: string): Promise<boolean> {
|
||||||
|
const redis = this.getRedis()
|
||||||
|
const exists = await redis.exists(SessionKeys.data(sessionId))
|
||||||
|
return exists === 1
|
||||||
|
}
|
||||||
|
|
||||||
|
async pushUndoEntry(sessionId: string, entry: UndoEntry): Promise<void> {
|
||||||
|
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<UndoEntry | null> {
|
||||||
|
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<UndoEntry[]> {
|
||||||
|
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<void> {
|
||||||
|
const redis = this.getRedis()
|
||||||
|
await redis.hset(
|
||||||
|
SessionKeys.data(sessionId),
|
||||||
|
SessionFields.lastActivityAt,
|
||||||
|
String(Date.now()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearAllSessions(): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
const redis = this.getRedis()
|
||||||
|
|
||||||
|
const exists = await redis.lpos(SessionKeys.list, sessionId)
|
||||||
|
if (exists === null) {
|
||||||
|
await redis.lpush(SessionKeys.list, sessionId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getRedis(): ReturnType<RedisClient["getClient"]> {
|
||||||
|
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}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
// Storage module exports
|
// Storage module exports
|
||||||
export { RedisClient } from "./RedisClient.js"
|
export { RedisClient } from "./RedisClient.js"
|
||||||
export { RedisStorage } from "./RedisStorage.js"
|
export { RedisStorage } from "./RedisStorage.js"
|
||||||
|
export { RedisSessionStorage } from "./RedisSessionStorage.js"
|
||||||
export {
|
export {
|
||||||
ProjectKeys,
|
ProjectKeys,
|
||||||
SessionKeys,
|
SessionKeys,
|
||||||
|
|||||||
@@ -150,12 +150,9 @@ export class RunTestsTool implements ITool {
|
|||||||
return createSuccessResult(callId, result, Date.now() - startTime)
|
return createSuccessResult(callId, result, Date.now() - startTime)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return this.handleExecError(
|
return this.handleExecError(
|
||||||
callId,
|
{ callId, runner, command, startTime },
|
||||||
runner,
|
|
||||||
command,
|
|
||||||
error,
|
error,
|
||||||
execStartTime,
|
execStartTime,
|
||||||
startTime,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -168,25 +165,37 @@ export class RunTestsTool implements ITool {
|
|||||||
* Detect which test runner is available in the project.
|
* Detect which test runner is available in the project.
|
||||||
*/
|
*/
|
||||||
async detectTestRunner(projectRoot: string): Promise<TestRunner | null> {
|
async detectTestRunner(projectRoot: string): Promise<TestRunner | null> {
|
||||||
if (await this.hasFile(projectRoot, "vitest.config.ts")) {
|
const configRunner = await this.detectByConfigFile(projectRoot)
|
||||||
return "vitest"
|
if (configRunner) {
|
||||||
}
|
return configRunner
|
||||||
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"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return this.detectByPackageJson(projectRoot)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async detectByConfigFile(projectRoot: string): Promise<TestRunner | null> {
|
||||||
|
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<TestRunner | null> {
|
||||||
const packageJsonPath = path.join(projectRoot, "package.json")
|
const packageJsonPath = path.join(projectRoot, "package.json")
|
||||||
try {
|
try {
|
||||||
const content = await this.fsReadFile(packageJsonPath, "utf-8")
|
const content = await this.fsReadFile(packageJsonPath, "utf-8")
|
||||||
@@ -196,23 +205,22 @@ export class RunTestsTool implements ITool {
|
|||||||
dependencies?: Record<string, string>
|
dependencies?: Record<string, string>
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pkg.devDependencies?.vitest || pkg.dependencies?.vitest) {
|
const deps = { ...pkg.devDependencies, ...pkg.dependencies }
|
||||||
|
if (deps.vitest) {
|
||||||
return "vitest"
|
return "vitest"
|
||||||
}
|
}
|
||||||
if (pkg.devDependencies?.jest || pkg.dependencies?.jest) {
|
if (deps.jest) {
|
||||||
return "jest"
|
return "jest"
|
||||||
}
|
}
|
||||||
if (pkg.devDependencies?.mocha || pkg.dependencies?.mocha) {
|
if (deps.mocha) {
|
||||||
return "mocha"
|
return "mocha"
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pkg.scripts?.test) {
|
if (pkg.scripts?.test) {
|
||||||
return "npm"
|
return "npm"
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// package.json doesn't exist or is invalid
|
// package.json doesn't exist or is invalid
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,63 +228,69 @@ export class RunTestsTool implements ITool {
|
|||||||
* Build the test command based on runner and options.
|
* Build the test command based on runner and options.
|
||||||
*/
|
*/
|
||||||
buildCommand(runner: TestRunner, testPath?: string, filter?: string, watch?: boolean): string {
|
buildCommand(runner: TestRunner, testPath?: string, filter?: string, watch?: boolean): string {
|
||||||
const parts: string[] = []
|
const builders: Record<TestRunner, () => string[]> = {
|
||||||
|
vitest: () => this.buildVitestCommand(testPath, filter, watch),
|
||||||
switch (runner) {
|
jest: () => this.buildJestCommand(testPath, filter, watch),
|
||||||
case "vitest":
|
mocha: () => this.buildMochaCommand(testPath, filter, watch),
|
||||||
parts.push("npx vitest")
|
npm: () => this.buildNpmCommand(testPath, filter),
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
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.
|
* Handle exec errors and return appropriate result.
|
||||||
*/
|
*/
|
||||||
private handleExecError(
|
private handleExecError(
|
||||||
callId: string,
|
ctx: { callId: string; runner: TestRunner; command: string; startTime: number },
|
||||||
runner: TestRunner,
|
|
||||||
command: string,
|
|
||||||
error: unknown,
|
error: unknown,
|
||||||
execStartTime: number,
|
execStartTime: number,
|
||||||
startTime: number,
|
|
||||||
): ToolResult {
|
): ToolResult {
|
||||||
|
const { callId, runner, command, startTime } = ctx
|
||||||
const durationMs = Date.now() - execStartTime
|
const durationMs = Date.now() - execStartTime
|
||||||
|
|
||||||
if (this.isExecError(error)) {
|
if (this.isExecError(error)) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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(
|
||||||
|
'<tool_call name="get_lines"><path>test.ts</path></tool_call>',
|
||||||
|
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(
|
||||||
|
'<tool_call name="get_lines"><path>test.ts</path></tool_call>',
|
||||||
|
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(
|
||||||
|
'<tool_call name="unknown_tool"><param>value</param></tool_call>',
|
||||||
|
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(
|
||||||
|
'<tool_call name="get_lines"><path>a.ts</path></tool_call>' +
|
||||||
|
'<tool_call name="get_lines"><path>b.ts</path></tool_call>' +
|
||||||
|
'<tool_call name="get_lines"><path>c.ts</path></tool_call>',
|
||||||
|
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(
|
||||||
|
'<tool_call name="get_lines"><path>test.ts</path></tool_call>',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.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<string, unknown>, 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(
|
||||||
|
'<tool_call name="edit_lines"><path>test.ts</path></tool_call>',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.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(
|
||||||
|
'<tool_call name="edit_lines"><path>test.ts</path></tool_call>',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.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(
|
||||||
|
'<tool_call name="edit_lines"><path>test.ts</path></tool_call>',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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> = {}): 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<ReturnType<typeof fs.stat>>)
|
||||||
|
})
|
||||||
|
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<typeof vi.fn>
|
||||||
|
hget: ReturnType<typeof vi.fn>
|
||||||
|
hgetall: ReturnType<typeof vi.fn>
|
||||||
|
del: ReturnType<typeof vi.fn>
|
||||||
|
lrange: ReturnType<typeof vi.fn>
|
||||||
|
lpush: ReturnType<typeof vi.fn>
|
||||||
|
lpos: ReturnType<typeof vi.fn>
|
||||||
|
lrem: ReturnType<typeof vi.fn>
|
||||||
|
rpush: ReturnType<typeof vi.fn>
|
||||||
|
rpop: ReturnType<typeof vi.fn>
|
||||||
|
llen: ReturnType<typeof vi.fn>
|
||||||
|
lpop: ReturnType<typeof vi.fn>
|
||||||
|
exists: ReturnType<typeof vi.fn>
|
||||||
|
pipeline: ReturnType<typeof vi.fn>
|
||||||
|
}
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -35,10 +35,7 @@ function createMockStorage(): IStorage {
|
|||||||
} as unknown as IStorage
|
} as unknown as IStorage
|
||||||
}
|
}
|
||||||
|
|
||||||
function createMockContext(
|
function createMockContext(storage?: IStorage, confirmResult: boolean = true): ToolContext {
|
||||||
storage?: IStorage,
|
|
||||||
confirmResult: boolean = true,
|
|
||||||
): ToolContext {
|
|
||||||
return {
|
return {
|
||||||
projectRoot: "/test/project",
|
projectRoot: "/test/project",
|
||||||
storage: storage ?? createMockStorage(),
|
storage: storage ?? createMockStorage(),
|
||||||
@@ -47,9 +44,7 @@ function createMockContext(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createMockStatusResult(
|
function createMockStatusResult(overrides: Partial<StatusResult> = {}): StatusResult {
|
||||||
overrides: Partial<StatusResult> = {},
|
|
||||||
): StatusResult {
|
|
||||||
return {
|
return {
|
||||||
not_added: [],
|
not_added: [],
|
||||||
conflicted: [],
|
conflicted: [],
|
||||||
@@ -70,9 +65,7 @@ function createMockStatusResult(
|
|||||||
} as StatusResult
|
} as StatusResult
|
||||||
}
|
}
|
||||||
|
|
||||||
function createMockCommitResult(
|
function createMockCommitResult(overrides: Partial<CommitResult> = {}): CommitResult {
|
||||||
overrides: Partial<CommitResult> = {},
|
|
||||||
): CommitResult {
|
|
||||||
return {
|
return {
|
||||||
commit: "abc1234",
|
commit: "abc1234",
|
||||||
branch: "main",
|
branch: "main",
|
||||||
@@ -96,9 +89,7 @@ function createMockGit(options: {
|
|||||||
}): SimpleGit {
|
}): SimpleGit {
|
||||||
const mockGit = {
|
const mockGit = {
|
||||||
checkIsRepo: vi.fn().mockResolvedValue(options.isRepo ?? true),
|
checkIsRepo: vi.fn().mockResolvedValue(options.isRepo ?? true),
|
||||||
status: vi.fn().mockResolvedValue(
|
status: vi.fn().mockResolvedValue(options.status ?? createMockStatusResult()),
|
||||||
options.status ?? createMockStatusResult(),
|
|
||||||
),
|
|
||||||
add: vi.fn(),
|
add: vi.fn(),
|
||||||
commit: vi.fn(),
|
commit: vi.fn(),
|
||||||
}
|
}
|
||||||
@@ -112,9 +103,7 @@ function createMockGit(options: {
|
|||||||
if (options.error) {
|
if (options.error) {
|
||||||
mockGit.commit.mockRejectedValue(options.error)
|
mockGit.commit.mockRejectedValue(options.error)
|
||||||
} else {
|
} else {
|
||||||
mockGit.commit.mockResolvedValue(
|
mockGit.commit.mockResolvedValue(options.commitResult ?? createMockCommitResult())
|
||||||
options.commitResult ?? createMockCommitResult(),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return mockGit as unknown as SimpleGit
|
return mockGit as unknown as SimpleGit
|
||||||
@@ -175,21 +164,15 @@ describe("GitCommitTool", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it("should return null for valid message with files", () => {
|
it("should return null for valid message with files", () => {
|
||||||
expect(
|
expect(tool.validateParams({ message: "fix: bug", files: ["a.ts", "b.ts"] })).toBeNull()
|
||||||
tool.validateParams({ message: "fix: bug", files: ["a.ts", "b.ts"] }),
|
|
||||||
).toBeNull()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it("should return error for non-array files", () => {
|
it("should return error for non-array files", () => {
|
||||||
expect(
|
expect(tool.validateParams({ message: "fix: bug", files: "a.ts" })).toContain("array")
|
||||||
tool.validateParams({ message: "fix: bug", files: "a.ts" }),
|
|
||||||
).toContain("array")
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it("should return error for non-string in files array", () => {
|
it("should return error for non-string in files array", () => {
|
||||||
expect(
|
expect(tool.validateParams({ message: "fix: bug", files: [1, 2] })).toContain("strings")
|
||||||
tool.validateParams({ message: "fix: bug", files: [1, 2] }),
|
|
||||||
).toContain("strings")
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -200,10 +183,7 @@ describe("GitCommitTool", () => {
|
|||||||
const toolWithMock = new GitCommitTool(() => mockGit)
|
const toolWithMock = new GitCommitTool(() => mockGit)
|
||||||
const ctx = createMockContext()
|
const ctx = createMockContext()
|
||||||
|
|
||||||
const result = await toolWithMock.execute(
|
const result = await toolWithMock.execute({ message: "test commit" }, ctx)
|
||||||
{ message: "test commit" },
|
|
||||||
ctx,
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(result.success).toBe(false)
|
expect(result.success).toBe(false)
|
||||||
expect(result.error).toContain("Not a git repository")
|
expect(result.error).toContain("Not a git repository")
|
||||||
@@ -218,10 +198,7 @@ describe("GitCommitTool", () => {
|
|||||||
const toolWithMock = new GitCommitTool(() => mockGit)
|
const toolWithMock = new GitCommitTool(() => mockGit)
|
||||||
const ctx = createMockContext()
|
const ctx = createMockContext()
|
||||||
|
|
||||||
const result = await toolWithMock.execute(
|
const result = await toolWithMock.execute({ message: "test commit" }, ctx)
|
||||||
{ message: "test commit" },
|
|
||||||
ctx,
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(result.success).toBe(false)
|
expect(result.success).toBe(false)
|
||||||
expect(result.error).toContain("Nothing to commit")
|
expect(result.error).toContain("Nothing to commit")
|
||||||
@@ -241,10 +218,7 @@ describe("GitCommitTool", () => {
|
|||||||
const toolWithMock = new GitCommitTool(() => mockGit)
|
const toolWithMock = new GitCommitTool(() => mockGit)
|
||||||
const ctx = createMockContext()
|
const ctx = createMockContext()
|
||||||
|
|
||||||
const result = await toolWithMock.execute(
|
const result = await toolWithMock.execute({ message: "feat: new feature" }, ctx)
|
||||||
{ message: "feat: new feature" },
|
|
||||||
ctx,
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(result.success).toBe(true)
|
expect(result.success).toBe(true)
|
||||||
const data = result.data as GitCommitResult
|
const data = result.data as GitCommitResult
|
||||||
@@ -268,10 +242,7 @@ describe("GitCommitTool", () => {
|
|||||||
const toolWithMock = new GitCommitTool(() => mockGit)
|
const toolWithMock = new GitCommitTool(() => mockGit)
|
||||||
const ctx = createMockContext()
|
const ctx = createMockContext()
|
||||||
|
|
||||||
const result = await toolWithMock.execute(
|
const result = await toolWithMock.execute({ message: "test commit" }, ctx)
|
||||||
{ message: "test commit" },
|
|
||||||
ctx,
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(result.success).toBe(true)
|
expect(result.success).toBe(true)
|
||||||
const data = result.data as GitCommitResult
|
const data = result.data as GitCommitResult
|
||||||
@@ -290,10 +261,7 @@ describe("GitCommitTool", () => {
|
|||||||
const toolWithMock = new GitCommitTool(() => mockGit)
|
const toolWithMock = new GitCommitTool(() => mockGit)
|
||||||
const ctx = createMockContext()
|
const ctx = createMockContext()
|
||||||
|
|
||||||
await toolWithMock.execute(
|
await toolWithMock.execute({ message: "test", files: ["a.ts", "b.ts"] }, ctx)
|
||||||
{ message: "test", files: ["a.ts", "b.ts"] },
|
|
||||||
ctx,
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(mockGit.add).toHaveBeenCalledWith(["a.ts", "b.ts"])
|
expect(mockGit.add).toHaveBeenCalledWith(["a.ts", "b.ts"])
|
||||||
})
|
})
|
||||||
@@ -303,10 +271,7 @@ describe("GitCommitTool", () => {
|
|||||||
const toolWithMock = new GitCommitTool(() => mockGit)
|
const toolWithMock = new GitCommitTool(() => mockGit)
|
||||||
const ctx = createMockContext()
|
const ctx = createMockContext()
|
||||||
|
|
||||||
await toolWithMock.execute(
|
await toolWithMock.execute({ message: "test", files: [] }, ctx)
|
||||||
{ message: "test", files: [] },
|
|
||||||
ctx,
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(mockGit.add).not.toHaveBeenCalled()
|
expect(mockGit.add).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
@@ -337,8 +302,8 @@ describe("GitCommitTool", () => {
|
|||||||
await toolWithMock.execute({ message: "test commit" }, ctx)
|
await toolWithMock.execute({ message: "test commit" }, ctx)
|
||||||
|
|
||||||
expect(ctx.requestConfirmation).toHaveBeenCalled()
|
expect(ctx.requestConfirmation).toHaveBeenCalled()
|
||||||
const confirmMessage = (ctx.requestConfirmation as ReturnType<typeof vi.fn>)
|
const confirmMessage = (ctx.requestConfirmation as ReturnType<typeof vi.fn>).mock
|
||||||
.mock.calls[0][0] as string
|
.calls[0][0] as string
|
||||||
expect(confirmMessage).toContain("Committing")
|
expect(confirmMessage).toContain("Committing")
|
||||||
expect(confirmMessage).toContain("test commit")
|
expect(confirmMessage).toContain("test commit")
|
||||||
})
|
})
|
||||||
@@ -348,10 +313,7 @@ describe("GitCommitTool", () => {
|
|||||||
const toolWithMock = new GitCommitTool(() => mockGit)
|
const toolWithMock = new GitCommitTool(() => mockGit)
|
||||||
const ctx = createMockContext(undefined, false)
|
const ctx = createMockContext(undefined, false)
|
||||||
|
|
||||||
const result = await toolWithMock.execute(
|
const result = await toolWithMock.execute({ message: "test commit" }, ctx)
|
||||||
{ message: "test commit" },
|
|
||||||
ctx,
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(result.success).toBe(false)
|
expect(result.success).toBe(false)
|
||||||
expect(result.error).toContain("cancelled")
|
expect(result.error).toContain("cancelled")
|
||||||
@@ -363,10 +325,7 @@ describe("GitCommitTool", () => {
|
|||||||
const toolWithMock = new GitCommitTool(() => mockGit)
|
const toolWithMock = new GitCommitTool(() => mockGit)
|
||||||
const ctx = createMockContext(undefined, true)
|
const ctx = createMockContext(undefined, true)
|
||||||
|
|
||||||
const result = await toolWithMock.execute(
|
const result = await toolWithMock.execute({ message: "test commit" }, ctx)
|
||||||
{ message: "test commit" },
|
|
||||||
ctx,
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(result.success).toBe(true)
|
expect(result.success).toBe(true)
|
||||||
expect(mockGit.commit).toHaveBeenCalledWith("test commit")
|
expect(mockGit.commit).toHaveBeenCalledWith("test commit")
|
||||||
@@ -381,10 +340,7 @@ describe("GitCommitTool", () => {
|
|||||||
const toolWithMock = new GitCommitTool(() => mockGit)
|
const toolWithMock = new GitCommitTool(() => mockGit)
|
||||||
const ctx = createMockContext()
|
const ctx = createMockContext()
|
||||||
|
|
||||||
const result = await toolWithMock.execute(
|
const result = await toolWithMock.execute({ message: "test commit" }, ctx)
|
||||||
{ message: "test commit" },
|
|
||||||
ctx,
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(result.success).toBe(false)
|
expect(result.success).toBe(false)
|
||||||
expect(result.error).toContain("Git commit failed")
|
expect(result.error).toContain("Git commit failed")
|
||||||
@@ -400,10 +356,7 @@ describe("GitCommitTool", () => {
|
|||||||
const toolWithMock = new GitCommitTool(() => mockGit)
|
const toolWithMock = new GitCommitTool(() => mockGit)
|
||||||
const ctx = createMockContext()
|
const ctx = createMockContext()
|
||||||
|
|
||||||
const result = await toolWithMock.execute(
|
const result = await toolWithMock.execute({ message: "test commit" }, ctx)
|
||||||
{ message: "test commit" },
|
|
||||||
ctx,
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(result.success).toBe(false)
|
expect(result.success).toBe(false)
|
||||||
expect(result.error).toBe("string error")
|
expect(result.error).toBe("string error")
|
||||||
@@ -416,10 +369,7 @@ describe("GitCommitTool", () => {
|
|||||||
const toolWithMock = new GitCommitTool(() => mockGit)
|
const toolWithMock = new GitCommitTool(() => mockGit)
|
||||||
const ctx = createMockContext()
|
const ctx = createMockContext()
|
||||||
|
|
||||||
const result = await toolWithMock.execute(
|
const result = await toolWithMock.execute({ message: "test commit" }, ctx)
|
||||||
{ message: "test commit" },
|
|
||||||
ctx,
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(result.executionTimeMs).toBeGreaterThanOrEqual(0)
|
expect(result.executionTimeMs).toBeGreaterThanOrEqual(0)
|
||||||
})
|
})
|
||||||
@@ -431,10 +381,7 @@ describe("GitCommitTool", () => {
|
|||||||
const toolWithMock = new GitCommitTool(() => mockGit)
|
const toolWithMock = new GitCommitTool(() => mockGit)
|
||||||
const ctx = createMockContext()
|
const ctx = createMockContext()
|
||||||
|
|
||||||
const result = await toolWithMock.execute(
|
const result = await toolWithMock.execute({ message: "test commit" }, ctx)
|
||||||
{ message: "test commit" },
|
|
||||||
ctx,
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(result.callId).toMatch(/^git_commit-\d+$/)
|
expect(result.callId).toMatch(/^git_commit-\d+$/)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -69,9 +69,7 @@ function createMockGit(options: {
|
|||||||
if (options.error) {
|
if (options.error) {
|
||||||
mockGit.diffSummary.mockRejectedValue(options.error)
|
mockGit.diffSummary.mockRejectedValue(options.error)
|
||||||
} else {
|
} else {
|
||||||
mockGit.diffSummary.mockResolvedValue(
|
mockGit.diffSummary.mockResolvedValue(options.diffSummary ?? createMockDiffSummary())
|
||||||
options.diffSummary ?? createMockDiffSummary(),
|
|
||||||
)
|
|
||||||
mockGit.diff.mockResolvedValue(options.diff ?? "")
|
mockGit.diff.mockResolvedValue(options.diff ?? "")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,9 +222,7 @@ describe("GitDiffTool", () => {
|
|||||||
it("should handle binary files", async () => {
|
it("should handle binary files", async () => {
|
||||||
const mockGit = createMockGit({
|
const mockGit = createMockGit({
|
||||||
diffSummary: createMockDiffSummary({
|
diffSummary: createMockDiffSummary({
|
||||||
files: [
|
files: [{ file: "image.png", insertions: 0, deletions: 0, binary: true }],
|
||||||
{ file: "image.png", insertions: 0, deletions: 0, binary: true },
|
|
||||||
],
|
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
const toolWithMock = new GitDiffTool(() => mockGit)
|
const toolWithMock = new GitDiffTool(() => mockGit)
|
||||||
@@ -293,11 +289,7 @@ describe("GitDiffTool", () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
expect(result.success).toBe(true)
|
expect(result.success).toBe(true)
|
||||||
expect(mockGit.diffSummary).toHaveBeenCalledWith([
|
expect(mockGit.diffSummary).toHaveBeenCalledWith(["--cached", "--", "src/index.ts"])
|
||||||
"--cached",
|
|
||||||
"--",
|
|
||||||
"src/index.ts",
|
|
||||||
])
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it("should return null pathFilter when not provided", async () => {
|
it("should return null pathFilter when not provided", async () => {
|
||||||
|
|||||||
@@ -14,12 +14,8 @@ describe("CommandSecurity", () => {
|
|||||||
|
|
||||||
describe("constructor", () => {
|
describe("constructor", () => {
|
||||||
it("should use default blacklist and whitelist", () => {
|
it("should use default blacklist and whitelist", () => {
|
||||||
expect(security.getBlacklist()).toEqual(
|
expect(security.getBlacklist()).toEqual(DEFAULT_BLACKLIST.map((c) => c.toLowerCase()))
|
||||||
DEFAULT_BLACKLIST.map((c) => c.toLowerCase()),
|
expect(security.getWhitelist()).toEqual(DEFAULT_WHITELIST.map((c) => c.toLowerCase()))
|
||||||
)
|
|
||||||
expect(security.getWhitelist()).toEqual(
|
|
||||||
DEFAULT_WHITELIST.map((c) => c.toLowerCase()),
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it("should accept custom blacklist and whitelist", () => {
|
it("should accept custom blacklist and whitelist", () => {
|
||||||
|
|||||||
@@ -35,10 +35,7 @@ function createMockStorage(): IStorage {
|
|||||||
} as unknown as IStorage
|
} as unknown as IStorage
|
||||||
}
|
}
|
||||||
|
|
||||||
function createMockContext(
|
function createMockContext(storage?: IStorage, confirmResult: boolean = true): ToolContext {
|
||||||
storage?: IStorage,
|
|
||||||
confirmResult: boolean = true,
|
|
||||||
): ToolContext {
|
|
||||||
return {
|
return {
|
||||||
projectRoot: "/test/project",
|
projectRoot: "/test/project",
|
||||||
storage: storage ?? createMockStorage(),
|
storage: storage ?? createMockStorage(),
|
||||||
@@ -48,10 +45,7 @@ function createMockContext(
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ExecResult = { stdout: string; stderr: string }
|
type ExecResult = { stdout: string; stderr: string }
|
||||||
type ExecFn = (
|
type ExecFn = (command: string, options: Record<string, unknown>) => Promise<ExecResult>
|
||||||
command: string,
|
|
||||||
options: Record<string, unknown>,
|
|
||||||
) => Promise<ExecResult>
|
|
||||||
|
|
||||||
function createMockExec(options: {
|
function createMockExec(options: {
|
||||||
stdout?: string
|
stdout?: string
|
||||||
@@ -123,27 +117,19 @@ describe("RunCommandTool", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it("should return error for non-number timeout", () => {
|
it("should return error for non-number timeout", () => {
|
||||||
expect(
|
expect(tool.validateParams({ command: "ls", timeout: "5000" })).toContain("number")
|
||||||
tool.validateParams({ command: "ls", timeout: "5000" }),
|
|
||||||
).toContain("number")
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it("should return error for negative timeout", () => {
|
it("should return error for negative timeout", () => {
|
||||||
expect(tool.validateParams({ command: "ls", timeout: -1 })).toContain(
|
expect(tool.validateParams({ command: "ls", timeout: -1 })).toContain("positive")
|
||||||
"positive",
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it("should return error for zero timeout", () => {
|
it("should return error for zero timeout", () => {
|
||||||
expect(tool.validateParams({ command: "ls", timeout: 0 })).toContain(
|
expect(tool.validateParams({ command: "ls", timeout: 0 })).toContain("positive")
|
||||||
"positive",
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it("should return error for timeout > 10 minutes", () => {
|
it("should return error for timeout > 10 minutes", () => {
|
||||||
expect(
|
expect(tool.validateParams({ command: "ls", timeout: 600001 })).toContain("600000")
|
||||||
tool.validateParams({ command: "ls", timeout: 600001 }),
|
|
||||||
).toContain("600000")
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it("should return null for valid timeout", () => {
|
it("should return null for valid timeout", () => {
|
||||||
@@ -180,10 +166,7 @@ describe("RunCommandTool", () => {
|
|||||||
const toolWithMock = new RunCommandTool(undefined, execFn)
|
const toolWithMock = new RunCommandTool(undefined, execFn)
|
||||||
const ctx = createMockContext()
|
const ctx = createMockContext()
|
||||||
|
|
||||||
const result = await toolWithMock.execute(
|
const result = await toolWithMock.execute({ command: "git push --force" }, ctx)
|
||||||
{ command: "git push --force" },
|
|
||||||
ctx,
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(result.success).toBe(false)
|
expect(result.success).toBe(false)
|
||||||
expect(result.error).toContain("blocked")
|
expect(result.error).toContain("blocked")
|
||||||
@@ -250,10 +233,7 @@ describe("RunCommandTool", () => {
|
|||||||
const toolWithMock = new RunCommandTool(undefined, execFn)
|
const toolWithMock = new RunCommandTool(undefined, execFn)
|
||||||
const ctx = createMockContext(undefined, true)
|
const ctx = createMockContext(undefined, true)
|
||||||
|
|
||||||
const result = await toolWithMock.execute(
|
const result = await toolWithMock.execute({ command: "custom-script" }, ctx)
|
||||||
{ command: "custom-script" },
|
|
||||||
ctx,
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(result.success).toBe(true)
|
expect(result.success).toBe(true)
|
||||||
const data = result.data as RunCommandResult
|
const data = result.data as RunCommandResult
|
||||||
@@ -266,10 +246,7 @@ describe("RunCommandTool", () => {
|
|||||||
const toolWithMock = new RunCommandTool(undefined, execFn)
|
const toolWithMock = new RunCommandTool(undefined, execFn)
|
||||||
const ctx = createMockContext(undefined, false)
|
const ctx = createMockContext(undefined, false)
|
||||||
|
|
||||||
const result = await toolWithMock.execute(
|
const result = await toolWithMock.execute({ command: "custom-script" }, ctx)
|
||||||
{ command: "custom-script" },
|
|
||||||
ctx,
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(result.success).toBe(false)
|
expect(result.success).toBe(false)
|
||||||
expect(result.error).toContain("cancelled")
|
expect(result.error).toContain("cancelled")
|
||||||
@@ -364,10 +341,7 @@ describe("RunCommandTool", () => {
|
|||||||
|
|
||||||
await toolWithMock.execute({ command: "ls" }, ctx)
|
await toolWithMock.execute({ command: "ls" }, ctx)
|
||||||
|
|
||||||
expect(execFn).toHaveBeenCalledWith(
|
expect(execFn).toHaveBeenCalledWith("ls", expect.objectContaining({ timeout: 30000 }))
|
||||||
"ls",
|
|
||||||
expect.objectContaining({ timeout: 30000 }),
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it("should use custom timeout", async () => {
|
it("should use custom timeout", async () => {
|
||||||
@@ -377,10 +351,7 @@ describe("RunCommandTool", () => {
|
|||||||
|
|
||||||
await toolWithMock.execute({ command: "ls", timeout: 5000 }, ctx)
|
await toolWithMock.execute({ command: "ls", timeout: 5000 }, ctx)
|
||||||
|
|
||||||
expect(execFn).toHaveBeenCalledWith(
|
expect(execFn).toHaveBeenCalledWith("ls", expect.objectContaining({ timeout: 5000 }))
|
||||||
"ls",
|
|
||||||
expect.objectContaining({ timeout: 5000 }),
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it("should execute in project root", async () => {
|
it("should execute in project root", async () => {
|
||||||
@@ -493,10 +464,7 @@ describe("RunCommandTool", () => {
|
|||||||
|
|
||||||
toolWithMock.getSecurity().addToWhitelist(["custom-safe"])
|
toolWithMock.getSecurity().addToWhitelist(["custom-safe"])
|
||||||
|
|
||||||
const result = await toolWithMock.execute(
|
const result = await toolWithMock.execute({ command: "custom-safe arg" }, ctx)
|
||||||
{ command: "custom-safe arg" },
|
|
||||||
ctx,
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(result.success).toBe(true)
|
expect(result.success).toBe(true)
|
||||||
expect(ctx.requestConfirmation).not.toHaveBeenCalled()
|
expect(ctx.requestConfirmation).not.toHaveBeenCalled()
|
||||||
|
|||||||
@@ -45,10 +45,7 @@ function createMockContext(storage?: IStorage): ToolContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ExecResult = { stdout: string; stderr: string }
|
type ExecResult = { stdout: string; stderr: string }
|
||||||
type ExecFn = (
|
type ExecFn = (command: string, options: Record<string, unknown>) => Promise<ExecResult>
|
||||||
command: string,
|
|
||||||
options: Record<string, unknown>,
|
|
||||||
) => Promise<ExecResult>
|
|
||||||
|
|
||||||
function createMockExec(options: {
|
function createMockExec(options: {
|
||||||
stdout?: string
|
stdout?: string
|
||||||
@@ -127,9 +124,7 @@ describe("RunTestsTool", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it("should return null for valid params", () => {
|
it("should return null for valid params", () => {
|
||||||
expect(
|
expect(tool.validateParams({ path: "src", filter: "login", watch: true })).toBeNull()
|
||||||
tool.validateParams({ path: "src", filter: "login", watch: true }),
|
|
||||||
).toBeNull()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it("should return error for invalid path", () => {
|
it("should return error for invalid path", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user