Compare commits

...

6 Commits

Author SHA1 Message Date
imfozilbek
7d18e87423 feat(ipuaro): add TUI advanced components (v0.12.0)
Add DiffView, ConfirmDialog, ErrorDialog, and Progress components
for enhanced terminal UI interactions.
2025-12-01 13:34:17 +05:00
imfozilbek
fd1e6ad86e feat(ipuaro): add TUI components and hooks (v0.11.0) 2025-12-01 13:00:14 +05:00
imfozilbek
259ecc181a chore(ipuaro): bump version to 0.10.0 2025-12-01 12:28:27 +05:00
imfozilbek
0f2ed5b301 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
2025-12-01 12:27:22 +05:00
imfozilbek
56643d903f chore(ipuaro): bump version to 0.9.0 2025-12-01 02:55:42 +05:00
imfozilbek
f5f904a847 feat(ipuaro): add git and run tools (v0.9.0)
Git tools:
- GitStatusTool: repository status (branch, staged, modified, untracked)
- GitDiffTool: uncommitted changes with diff output
- GitCommitTool: create commits with confirmation

Run tools:
- CommandSecurity: blacklist/whitelist shell command validation
- RunCommandTool: execute shell commands with security checks
- RunTestsTool: auto-detect and run vitest/jest/mocha/npm test

All 18 planned tools now implemented.
Tests: 1086 (+233), Coverage: 98.08%
2025-12-01 02:54:29 +05:00
54 changed files with 8928 additions and 35 deletions

View File

@@ -5,6 +5,225 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.12.0] - 2025-12-01 - TUI Advanced
### Added
- **DiffView Component (0.12.1)**
- Inline diff display with green (added) and red (removed) highlighting
- Header with file path and line range: `┌─── path (lines X-Y) ───┐`
- Line numbers with proper padding
- Stats footer showing additions and deletions count
- **ConfirmDialog Component (0.12.2)**
- Confirmation dialog with [Y] Apply / [N] Cancel / [E] Edit options
- Optional diff preview integration
- Keyboard input handling (Y/N/E keys, Escape)
- Visual selection feedback
- **ErrorDialog Component (0.12.3)**
- Error dialog with [R] Retry / [S] Skip / [A] Abort options
- Recoverable vs non-recoverable error handling
- Disabled buttons for non-recoverable errors
- Keyboard input with Escape support
- **Progress Component (0.12.4)**
- Progress bar display: `[=====> ] 45% (120/267 files)`
- Color-coded progress (cyan < 50%, yellow < 100%, green = 100%)
- Configurable width
- Label support for context
### Changed
- Total tests: 1254 (unchanged - TUI components excluded from coverage)
- TUI layer now has 8 components + 2 hooks
- All v0.12.0 roadmap items complete
---
## [0.11.0] - 2025-12-01 - TUI Basic
### Added
- **TUI Types (0.11.0)**
- `TuiStatus`: Status type for TUI display (ready, thinking, tool_call, awaiting_confirmation, error)
- `BranchInfo`: Git branch information (name, isDetached)
- `AppProps`: Main app component props
- `StatusBarData`: Status bar display data
- **App Shell (0.11.1)**
- Main TUI App component with React/Ink
- Session initialization and state management
- Loading and error screens
- Hotkey integration (Ctrl+C, Ctrl+D, Ctrl+Z)
- Session time tracking
- **StatusBar Component (0.11.2)**
- Displays: `[ipuaro] [ctx: 12%] [project] [branch] [time] status`
- Context usage with color warning at >80%
- Git branch with detached HEAD support
- Status indicator with colors (ready=green, thinking=yellow, error=red)
- **Chat Component (0.11.3)**
- Message history display with role-based styling
- User messages (green), Assistant messages (cyan), System messages (gray)
- Tool call display with parameters
- Response stats: time, tokens, tool calls
- Thinking indicator during LLM processing
- **Input Component (0.11.4)**
- Prompt with `> ` prefix
- History navigation with ↑/↓ arrow keys
- Saved input restoration when navigating past history
- Disabled state during processing
- Custom placeholder support
- **useSession Hook (0.11.5)**
- Session state management with React hooks
- Message handling integration
- Status tracking (ready, thinking, tool_call, error)
- Undo support
- Clear history functionality
- Abort/interrupt support
- **useHotkeys Hook (0.11.6)**
- Ctrl+C: Interrupt (1st), Exit (2nd within 1s)
- Ctrl+D: Exit with session save
- Ctrl+Z: Undo last change
### Changed
- Total tests: 1254 (was 1174)
- Coverage: 97.75% lines, 92.22% branches
- TUI layer now has 4 components + 2 hooks
- TUI excluded from coverage thresholds (requires React testing setup)
---
## [0.10.0] - 2025-12-01 - Session Management
### Added
- **ISessionStorage (0.10.1)**
- Session storage service interface
- Methods: saveSession, loadSession, deleteSession, listSessions
- Undo stack management: pushUndoEntry, popUndoEntry, getUndoStack
- Session lifecycle: getLatestSession, sessionExists, touchSession
- **RedisSessionStorage (0.10.2)**
- Redis implementation of ISessionStorage
- Session data in Redis hashes (project, history, context, stats)
- Undo stack in Redis lists (max 10 entries)
- Sessions list for project-wide queries
- 22 unit tests
- **ContextManager (0.10.3)**
- Manages context window token budget
- File context tracking with addToContext/removeFromContext
- Usage monitoring: getUsage, getAvailableTokens, getRemainingTokens
- Auto-compression at 80% threshold via LLM summarization
- Context state export for session persistence
- 23 unit tests
- **StartSession (0.10.4)**
- Use case for session initialization
- Creates new session or loads latest for project
- Optional sessionId for specific session loading
- forceNew option to always create fresh session
- 10 unit tests
- **HandleMessage (0.10.5)**
- Main orchestrator use case for message handling
- LLM interaction with tool calling support
- Edit confirmation flow with diff preview
- Error handling with retry/skip/abort choices
- Status tracking: ready, thinking, tool_call, awaiting_confirmation, error
- Event callbacks: onMessage, onToolCall, onToolResult, onConfirmation, onError
- 21 unit tests
- **UndoChange (0.10.6)**
- Use case for reverting file changes
- Validates file hasn't changed since edit
- Restores original content from undo entry
- Updates storage after successful undo
- 12 unit tests
### Changed
- Total tests: 1174 (was 1086)
- Coverage: 97.73% lines, 92.21% branches
- Application layer now has 4 use cases implemented
- All planned session management features complete
---
## [0.9.0] - 2025-12-01 - Git & Run Tools
### Added
- **GitStatusTool (0.9.1)**
- `git_status()`: Get current git repository status
- Returns branch name, tracking branch, ahead/behind counts
- Lists staged, modified, untracked, and conflicted files
- Detects detached HEAD state
- 29 unit tests
- **GitDiffTool (0.9.2)**
- `git_diff(path?, staged?)`: Get uncommitted changes
- Returns file-by-file diff summary with insertions/deletions
- Full diff text output
- Optional path filter for specific files/directories
- Staged-only mode (`--cached`)
- Handles binary files
- 25 unit tests
- **GitCommitTool (0.9.3)**
- `git_commit(message, files?)`: Create a git commit
- Requires user confirmation before commit
- Optional file staging before commit
- Returns commit hash, summary, author info
- Validates staged files exist
- 26 unit tests
- **CommandSecurity**
- Security module for shell command validation
- Blacklist: dangerous commands always blocked (rm -rf, sudo, git push --force, etc.)
- Whitelist: safe commands allowed without confirmation (npm, node, git status, etc.)
- Classification: `allowed`, `blocked`, `requires_confirmation`
- Git subcommand awareness (safe read operations vs write operations)
- Extensible via `addToBlacklist()` and `addToWhitelist()`
- 65 unit tests
- **RunCommandTool (0.9.4)**
- `run_command(command, timeout?)`: Execute shell commands
- Security-first design with blacklist/whitelist checks
- Blocked commands rejected immediately
- Unknown commands require user confirmation
- Configurable timeout (default 30s, max 10min)
- Output truncation for large outputs
- Returns stdout, stderr, exit code, duration
- 40 unit tests
- **RunTestsTool (0.9.5)**
- `run_tests(path?, filter?, watch?)`: Run project tests
- Auto-detects test runner: vitest, jest, mocha, npm test
- Detects by config files and package.json dependencies
- Path filtering for specific test files/directories
- Name pattern filtering (`-t` / `--grep`)
- Watch mode support
- Returns pass/fail status, exit code, output
- 48 unit tests
### Changed
- Total tests: 1086 (was 853)
- Coverage: 98.08% lines, 92.21% branches
- Git tools category now fully implemented (3/3 tools)
- Run tools category now fully implemented (2/2 tools)
- All 18 planned tools now implemented
---
## [0.8.0] - 2025-12-01 - Analysis Tools
### Added

View File

@@ -1,40 +1,95 @@
# ipuaro TODO
## Completed
### Version 0.1.0 - Foundation
- [x] Project setup (package.json, tsconfig, vitest)
- [x] Domain entities (Session, Project)
- [x] Domain value objects (FileData, FileAST, FileMeta, ChatMessage, etc.)
- [x] Domain service interfaces (IStorage, ILLMClient, ITool, IIndexer)
- [x] Shared config loader with Zod validation
- [x] IpuaroError class
### Version 0.2.0 - Redis Storage
- [x] RedisClient with AOF config
- [x] Redis schema implementation
- [x] RedisStorage class
### Version 0.3.0 - Indexer
- [x] FileScanner with gitignore support
- [x] ASTParser with tree-sitter
- [x] MetaAnalyzer for complexity
- [x] IndexBuilder for symbols
- [x] Watchdog for file changes
### Version 0.4.0 - LLM Integration
- [x] OllamaClient implementation
- [x] System prompt design
- [x] Tool definitions (18 tools)
- [x] Response parser (XML format)
### Version 0.5.0 - Read Tools
- [x] ToolRegistry implementation
- [x] get_lines tool
- [x] get_function tool
- [x] get_class tool
- [x] get_structure tool
### Version 0.6.0 - Edit Tools
- [x] edit_lines tool
- [x] create_file tool
- [x] delete_file tool
### Version 0.7.0 - Search Tools
- [x] find_references tool
- [x] find_definition tool
### Version 0.8.0 - Analysis Tools
- [x] get_dependencies tool
- [x] get_dependents tool
- [x] get_complexity tool
- [x] get_todos tool
### Version 0.9.0 - Git & Run Tools
- [x] git_status tool
- [x] git_diff tool
- [x] git_commit tool
- [x] CommandSecurity (blacklist/whitelist)
- [x] run_command tool
- [x] run_tests tool
### Version 0.10.0 - Session Management
- [x] ISessionStorage interface
- [x] RedisSessionStorage implementation
- [x] ContextManager use case
- [x] StartSession use case
- [x] HandleMessage use case
- [x] UndoChange use case
## In Progress
### Version 0.2.0 - Redis Storage
- [ ] RedisClient with AOF config
- [ ] Redis schema implementation
- [ ] RedisStorage class
### Version 0.11.0 - TUI Basic
- [ ] App shell (Ink/React)
- [ ] StatusBar component
- [ ] Chat component
- [ ] Input component
## Planned
### Version 0.3.0 - Indexer
- [ ] FileScanner with gitignore support
- [ ] ASTParser with tree-sitter
- [ ] MetaAnalyzer for complexity
- [ ] IndexBuilder for symbols
- [ ] Watchdog for file changes
### Version 0.12.0 - TUI Advanced
- [ ] DiffView component
- [ ] ConfirmDialog component
- [ ] ErrorDialog component
- [ ] Progress component
### Version 0.4.0 - LLM Integration
- [ ] OllamaClient implementation
- [ ] System prompt design
- [ ] Tool definitions (XML format)
- [ ] Response parser
### Version 0.13.0+ - Commands & Polish
- [ ] Slash commands (/help, /clear, /undo, /sessions, /status)
- [ ] Hotkeys (Ctrl+C, Ctrl+D, Ctrl+Z)
- [ ] Auto-compression at 80% context
### Version 0.5.0+ - Tools
- [ ] Read tools (get_lines, get_function, get_class, get_structure)
- [ ] Edit tools (edit_lines, create_file, delete_file)
- [ ] Search tools (find_references, find_definition)
- [ ] Analysis tools (get_dependencies, get_dependents, get_complexity, get_todos)
- [ ] Git tools (git_status, git_diff, git_commit)
- [ ] Run tools (run_command, run_tests)
### Version 0.10.0+ - Session & TUI
- [ ] Session management
- [ ] Context compression
- [ ] TUI components (StatusBar, Chat, Input, DiffView)
- [ ] Slash commands (/help, /clear, /undo, etc.)
### Version 0.14.0 - CLI Entry Point
- [ ] Full CLI commands (start, init, index)
- [ ] Onboarding flow (Redis check, Ollama check, model pull)
## Technical Debt
@@ -51,4 +106,4 @@ _None at this time._
---
**Last Updated:** 2025-01-29
**Last Updated:** 2025-12-01

View File

@@ -1,6 +1,6 @@
{
"name": "@samiyev/ipuaro",
"version": "0.8.0",
"version": "0.12.0",
"description": "Local AI agent for codebase operations with infinite context feeling",
"author": "Fozilbek Samiyev <fozilbek.samiyev@gmail.com>",
"license": "MIT",

View 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
}
}

View 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)
}
}

View 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 }
}
}

View 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,
})
}
}

View File

@@ -1,4 +1,6 @@
/*
* Application Use Cases
* Will be implemented in version 0.10.0+
*/
// Application Use Cases
export * from "./StartSession.js"
export * from "./HandleMessage.js"
export * from "./UndoChange.js"
export * from "./ContextManager.js"

View 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>
}

View File

@@ -1,5 +1,6 @@
// Domain Service Interfaces (Ports)
export * from "./IStorage.js"
export * from "./ISessionStorage.js"
export * from "./ILLMClient.js"
export * from "./ITool.js"
export * from "./IIndexer.js"

View File

@@ -21,5 +21,8 @@ export * from "./shared/index.js"
// Infrastructure exports
export * from "./infrastructure/index.js"
// TUI exports
export * from "./tui/index.js"
// Version
export const VERSION = pkg.version

View File

@@ -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}`)
}
}
}

View File

@@ -1,6 +1,7 @@
// Storage module exports
export { RedisClient } from "./RedisClient.js"
export { RedisStorage } from "./RedisStorage.js"
export { RedisSessionStorage } from "./RedisSessionStorage.js"
export {
ProjectKeys,
SessionKeys,

View File

@@ -0,0 +1,155 @@
import { type CommitResult, type SimpleGit, simpleGit } from "simple-git"
import type { ITool, ToolContext, ToolParameterSchema } from "../../../domain/services/ITool.js"
import {
createErrorResult,
createSuccessResult,
type ToolResult,
} from "../../../domain/value-objects/ToolResult.js"
/**
* Author information.
*/
export interface CommitAuthor {
name: string
email: string
}
/**
* Result data from git_commit tool.
*/
export interface GitCommitResult {
/** Commit hash */
hash: string
/** Current branch */
branch: string
/** Commit message */
message: string
/** Number of files changed */
filesChanged: number
/** Number of insertions */
insertions: number
/** Number of deletions */
deletions: number
/** Author information */
author: CommitAuthor | null
}
/**
* Tool for creating git commits.
* Requires confirmation before execution.
*/
export class GitCommitTool implements ITool {
readonly name = "git_commit"
readonly description =
"Create a git commit with the specified message. " +
"Will ask for confirmation. Optionally stage specific files first."
readonly parameters: ToolParameterSchema[] = [
{
name: "message",
type: "string",
description: "Commit message",
required: true,
},
{
name: "files",
type: "array",
description: "Files to stage before commit (optional, defaults to all staged)",
required: false,
},
]
readonly requiresConfirmation = true
readonly category = "git" as const
private readonly gitFactory: (basePath: string) => SimpleGit
constructor(gitFactory?: (basePath: string) => SimpleGit) {
this.gitFactory = gitFactory ?? ((basePath: string) => simpleGit(basePath))
}
validateParams(params: Record<string, unknown>): string | null {
if (params.message === undefined) {
return "Parameter 'message' is required"
}
if (typeof params.message !== "string") {
return "Parameter 'message' must be a string"
}
if (params.message.trim() === "") {
return "Parameter 'message' cannot be empty"
}
if (params.files !== undefined) {
if (!Array.isArray(params.files)) {
return "Parameter 'files' must be an array"
}
for (const file of params.files) {
if (typeof file !== "string") {
return "Parameter 'files' must be an array of strings"
}
}
}
return null
}
async execute(params: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
const startTime = Date.now()
const callId = `${this.name}-${String(startTime)}`
const message = params.message as string
const files = params.files as string[] | undefined
try {
const git = this.gitFactory(ctx.projectRoot)
const isRepo = await git.checkIsRepo()
if (!isRepo) {
return createErrorResult(
callId,
"Not a git repository. Initialize with 'git init' first.",
Date.now() - startTime,
)
}
if (files && files.length > 0) {
await git.add(files)
}
const status = await git.status()
if (status.staged.length === 0 && (!files || files.length === 0)) {
return createErrorResult(
callId,
"Nothing to commit. Stage files first with 'git add' or provide 'files' parameter.",
Date.now() - startTime,
)
}
const commitSummary = `Committing ${String(status.staged.length)} file(s): ${message}`
const confirmed = await ctx.requestConfirmation(commitSummary)
if (!confirmed) {
return createErrorResult(callId, "Commit cancelled by user", Date.now() - startTime)
}
const commitResult = await git.commit(message)
const result = this.formatCommitResult(commitResult, message)
return createSuccessResult(callId, result, Date.now() - startTime)
} catch (error) {
const message_ = error instanceof Error ? error.message : String(error)
return createErrorResult(callId, message_, Date.now() - startTime)
}
}
/**
* Format simple-git CommitResult into our result structure.
*/
private formatCommitResult(commit: CommitResult, message: string): GitCommitResult {
return {
hash: commit.commit,
branch: commit.branch,
message,
filesChanged: commit.summary.changes,
insertions: commit.summary.insertions,
deletions: commit.summary.deletions,
author: commit.author ?? null,
}
}
}

View File

@@ -0,0 +1,155 @@
import { simpleGit, type SimpleGit } from "simple-git"
import type { ITool, ToolContext, ToolParameterSchema } from "../../../domain/services/ITool.js"
import {
createErrorResult,
createSuccessResult,
type ToolResult,
} from "../../../domain/value-objects/ToolResult.js"
/**
* A single file diff entry.
*/
export interface DiffEntry {
/** File path */
file: string
/** Number of insertions */
insertions: number
/** Number of deletions */
deletions: number
/** Whether the file is binary */
binary: boolean
}
/**
* Result data from git_diff tool.
*/
export interface GitDiffResult {
/** Whether showing staged or all changes */
staged: boolean
/** Path filter applied (null if all files) */
pathFilter: string | null
/** Whether there are any changes */
hasChanges: boolean
/** Summary of changes */
summary: {
/** Number of files changed */
filesChanged: number
/** Total insertions */
insertions: number
/** Total deletions */
deletions: number
}
/** List of changed files */
files: DiffEntry[]
/** Full diff text */
diff: string
}
/**
* Tool for getting uncommitted git changes (diff).
* Shows what has changed but not yet committed.
*/
export class GitDiffTool implements ITool {
readonly name = "git_diff"
readonly description =
"Get uncommitted changes (diff). " + "Shows what has changed but not yet committed."
readonly parameters: ToolParameterSchema[] = [
{
name: "path",
type: "string",
description: "Limit diff to specific file or directory",
required: false,
},
{
name: "staged",
type: "boolean",
description: "Show only staged changes (default: false, shows all)",
required: false,
},
]
readonly requiresConfirmation = false
readonly category = "git" as const
private readonly gitFactory: (basePath: string) => SimpleGit
constructor(gitFactory?: (basePath: string) => SimpleGit) {
this.gitFactory = gitFactory ?? ((basePath: string) => simpleGit(basePath))
}
validateParams(params: Record<string, unknown>): string | null {
if (params.path !== undefined && typeof params.path !== "string") {
return "Parameter 'path' must be a string"
}
if (params.staged !== undefined && typeof params.staged !== "boolean") {
return "Parameter 'staged' must be a boolean"
}
return null
}
async execute(params: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
const startTime = Date.now()
const callId = `${this.name}-${String(startTime)}`
const pathFilter = (params.path as string) ?? null
const staged = (params.staged as boolean) ?? false
try {
const git = this.gitFactory(ctx.projectRoot)
const isRepo = await git.checkIsRepo()
if (!isRepo) {
return createErrorResult(
callId,
"Not a git repository. Initialize with 'git init' first.",
Date.now() - startTime,
)
}
const diffArgs = this.buildDiffArgs(staged, pathFilter)
const diffSummary = await git.diffSummary(diffArgs)
const diffText = await git.diff(diffArgs)
const files: DiffEntry[] = diffSummary.files.map((f) => ({
file: f.file,
insertions: "insertions" in f ? f.insertions : 0,
deletions: "deletions" in f ? f.deletions : 0,
binary: f.binary,
}))
const result: GitDiffResult = {
staged,
pathFilter,
hasChanges: diffSummary.files.length > 0,
summary: {
filesChanged: diffSummary.files.length,
insertions: diffSummary.insertions,
deletions: diffSummary.deletions,
},
files,
diff: diffText,
}
return createSuccessResult(callId, result, Date.now() - startTime)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return createErrorResult(callId, message, Date.now() - startTime)
}
}
/**
* Build diff arguments array.
*/
private buildDiffArgs(staged: boolean, pathFilter: string | null): string[] {
const args: string[] = []
if (staged) {
args.push("--cached")
}
if (pathFilter) {
args.push("--", pathFilter)
}
return args
}
}

View File

@@ -0,0 +1,129 @@
import { simpleGit, type SimpleGit, type StatusResult } from "simple-git"
import type { ITool, ToolContext, ToolParameterSchema } from "../../../domain/services/ITool.js"
import {
createErrorResult,
createSuccessResult,
type ToolResult,
} from "../../../domain/value-objects/ToolResult.js"
/**
* File status entry in git status.
*/
export interface FileStatusEntry {
/** Relative file path */
path: string
/** Working directory status (modified, deleted, etc.) */
workingDir: string
/** Index/staging status */
index: string
}
/**
* Result data from git_status tool.
*/
export interface GitStatusResult {
/** Current branch name */
branch: string
/** Tracking branch (e.g., origin/main) */
tracking: string | null
/** Number of commits ahead of tracking */
ahead: number
/** Number of commits behind tracking */
behind: number
/** Files staged for commit */
staged: FileStatusEntry[]
/** Modified files not staged */
modified: FileStatusEntry[]
/** Untracked files */
untracked: string[]
/** Files with merge conflicts */
conflicted: string[]
/** Whether working directory is clean */
isClean: boolean
}
/**
* Tool for getting git repository status.
* Returns branch info, staged/modified/untracked files.
*/
export class GitStatusTool implements ITool {
readonly name = "git_status"
readonly description =
"Get current git repository status. " +
"Returns branch name, staged files, modified files, and untracked files."
readonly parameters: ToolParameterSchema[] = []
readonly requiresConfirmation = false
readonly category = "git" as const
private readonly gitFactory: (basePath: string) => SimpleGit
constructor(gitFactory?: (basePath: string) => SimpleGit) {
this.gitFactory = gitFactory ?? ((basePath: string) => simpleGit(basePath))
}
validateParams(_params: Record<string, unknown>): string | null {
return null
}
async execute(_params: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
const startTime = Date.now()
const callId = `${this.name}-${String(startTime)}`
try {
const git = this.gitFactory(ctx.projectRoot)
const isRepo = await git.checkIsRepo()
if (!isRepo) {
return createErrorResult(
callId,
"Not a git repository. Initialize with 'git init' first.",
Date.now() - startTime,
)
}
const status = await git.status()
const result = this.formatStatus(status)
return createSuccessResult(callId, result, Date.now() - startTime)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return createErrorResult(callId, message, Date.now() - startTime)
}
}
/**
* Format simple-git StatusResult into our result structure.
*/
private formatStatus(status: StatusResult): GitStatusResult {
const staged: FileStatusEntry[] = []
const modified: FileStatusEntry[] = []
for (const file of status.files) {
const entry: FileStatusEntry = {
path: file.path,
workingDir: file.working_dir,
index: file.index,
}
if (file.index !== " " && file.index !== "?") {
staged.push(entry)
}
if (file.working_dir !== " " && file.working_dir !== "?") {
modified.push(entry)
}
}
return {
branch: status.current ?? "HEAD (detached)",
tracking: status.tracking ?? null,
ahead: status.ahead,
behind: status.behind,
staged,
modified,
untracked: status.not_added,
conflicted: status.conflicted,
isClean: status.isClean(),
}
}
}

View File

@@ -0,0 +1,6 @@
// Git tools exports
export { GitStatusTool, type GitStatusResult, type FileStatusEntry } from "./GitStatusTool.js"
export { GitDiffTool, type GitDiffResult, type DiffEntry } from "./GitDiffTool.js"
export { GitCommitTool, type GitCommitResult, type CommitAuthor } from "./GitCommitTool.js"

View File

@@ -53,3 +53,23 @@ export {
type TodoEntry,
type TodoType,
} from "./analysis/GetTodosTool.js"
// Git tools
export { GitStatusTool, type GitStatusResult, type FileStatusEntry } from "./git/GitStatusTool.js"
export { GitDiffTool, type GitDiffResult, type DiffEntry } from "./git/GitDiffTool.js"
export { GitCommitTool, type GitCommitResult, type CommitAuthor } from "./git/GitCommitTool.js"
// Run tools
export {
CommandSecurity,
DEFAULT_BLACKLIST,
DEFAULT_WHITELIST,
type CommandClassification,
type SecurityCheckResult,
} from "./run/CommandSecurity.js"
export { RunCommandTool, type RunCommandResult } from "./run/RunCommandTool.js"
export { RunTestsTool, type RunTestsResult, type TestRunner } from "./run/RunTestsTool.js"

View File

@@ -0,0 +1,257 @@
/**
* Command security classification.
*/
export type CommandClassification = "allowed" | "blocked" | "requires_confirmation"
/**
* Result of command security check.
*/
export interface SecurityCheckResult {
/** Classification of the command */
classification: CommandClassification
/** Reason for the classification */
reason: string
}
/**
* Dangerous commands that are always blocked.
* These commands can cause data loss or security issues.
*/
export const DEFAULT_BLACKLIST: string[] = [
// Destructive file operations
"rm -rf",
"rm -r",
"rm -fr",
"rmdir",
// Dangerous git operations
"git push --force",
"git push -f",
"git reset --hard",
"git clean -fd",
"git clean -f",
// Publishing/deployment
"npm publish",
"yarn publish",
"pnpm publish",
// System commands
"sudo",
"su ",
"chmod",
"chown",
// Network/download commands that could be dangerous
"| sh",
"| bash",
// Environment manipulation
"export ",
"unset ",
// Process control
"kill -9",
"killall",
"pkill",
// Disk operations (require exact command start)
"mkfs",
"fdisk",
// Other dangerous
":(){ :|:& };:",
"eval ",
]
/**
* Safe commands that don't require confirmation.
* Matched by first word (command name).
*/
export const DEFAULT_WHITELIST: string[] = [
// Package managers
"npm",
"pnpm",
"yarn",
"npx",
"bun",
// Node.js
"node",
"tsx",
"ts-node",
// Git (read operations)
"git",
// Build tools
"tsc",
"tsup",
"esbuild",
"vite",
"webpack",
"rollup",
// Testing
"vitest",
"jest",
"mocha",
"playwright",
"cypress",
// Linting/formatting
"eslint",
"prettier",
"biome",
// Utilities
"echo",
"cat",
"ls",
"pwd",
"which",
"head",
"tail",
"grep",
"find",
"wc",
"sort",
"diff",
]
/**
* Git subcommands that are safe and don't need confirmation.
*/
const SAFE_GIT_SUBCOMMANDS: string[] = [
"status",
"log",
"diff",
"show",
"branch",
"remote",
"fetch",
"pull",
"stash",
"tag",
"blame",
"ls-files",
"ls-tree",
"rev-parse",
"describe",
]
/**
* Command security checker.
* Determines if a command is safe to execute, blocked, or requires confirmation.
*/
export class CommandSecurity {
private readonly blacklist: string[]
private readonly whitelist: string[]
constructor(blacklist: string[] = DEFAULT_BLACKLIST, whitelist: string[] = DEFAULT_WHITELIST) {
this.blacklist = blacklist.map((cmd) => cmd.toLowerCase())
this.whitelist = whitelist.map((cmd) => cmd.toLowerCase())
}
/**
* Check if a command is safe to execute.
*/
check(command: string): SecurityCheckResult {
const normalized = command.trim().toLowerCase()
const blacklistMatch = this.isBlacklisted(normalized)
if (blacklistMatch) {
return {
classification: "blocked",
reason: `Command contains blocked pattern: '${blacklistMatch}'`,
}
}
if (this.isWhitelisted(normalized)) {
return {
classification: "allowed",
reason: "Command is in the whitelist",
}
}
return {
classification: "requires_confirmation",
reason: "Command is not in the whitelist and requires user confirmation",
}
}
/**
* Check if command matches any blacklist pattern.
* Returns the matched pattern or null.
*/
private isBlacklisted(command: string): string | null {
for (const pattern of this.blacklist) {
if (command.includes(pattern)) {
return pattern
}
}
return null
}
/**
* Check if command's first word is in the whitelist.
*/
private isWhitelisted(command: string): boolean {
const firstWord = this.getFirstWord(command)
if (!this.whitelist.includes(firstWord)) {
return false
}
if (firstWord === "git") {
return this.isGitCommandSafe(command)
}
return true
}
/**
* Check if git command is safe (read-only operations).
*/
private isGitCommandSafe(command: string): boolean {
const parts = command.split(/\s+/)
if (parts.length < 2) {
return false
}
const subcommand = parts[1]
return SAFE_GIT_SUBCOMMANDS.includes(subcommand)
}
/**
* Get first word from command.
*/
private getFirstWord(command: string): string {
const match = /^(\S+)/.exec(command)
return match ? match[1] : ""
}
/**
* Add patterns to the blacklist.
*/
addToBlacklist(patterns: string[]): void {
for (const pattern of patterns) {
const normalized = pattern.toLowerCase()
if (!this.blacklist.includes(normalized)) {
this.blacklist.push(normalized)
}
}
}
/**
* Add commands to the whitelist.
*/
addToWhitelist(commands: string[]): void {
for (const cmd of commands) {
const normalized = cmd.toLowerCase()
if (!this.whitelist.includes(normalized)) {
this.whitelist.push(normalized)
}
}
}
/**
* Get current blacklist.
*/
getBlacklist(): string[] {
return [...this.blacklist]
}
/**
* Get current whitelist.
*/
getWhitelist(): string[] {
return [...this.whitelist]
}
}

View File

@@ -0,0 +1,227 @@
import { exec } from "node:child_process"
import { promisify } from "node:util"
import type { ITool, ToolContext, ToolParameterSchema } from "../../../domain/services/ITool.js"
import {
createErrorResult,
createSuccessResult,
type ToolResult,
} from "../../../domain/value-objects/ToolResult.js"
import { CommandSecurity } from "./CommandSecurity.js"
const execAsync = promisify(exec)
/**
* Result data from run_command tool.
*/
export interface RunCommandResult {
/** The command that was executed */
command: string
/** Exit code (0 = success) */
exitCode: number
/** Standard output */
stdout: string
/** Standard error output */
stderr: string
/** Whether command was successful (exit code 0) */
success: boolean
/** Execution time in milliseconds */
durationMs: number
/** Whether user confirmation was required */
requiredConfirmation: boolean
}
/**
* Default command timeout in milliseconds.
*/
const DEFAULT_TIMEOUT = 30000
/**
* Maximum output size in characters.
*/
const MAX_OUTPUT_SIZE = 100000
/**
* Tool for executing shell commands.
* Commands are checked against blacklist/whitelist for security.
*/
export class RunCommandTool implements ITool {
readonly name = "run_command"
readonly description =
"Execute a shell command in the project directory. " +
"Commands are checked against blacklist/whitelist for security. " +
"Unknown commands require user confirmation."
readonly parameters: ToolParameterSchema[] = [
{
name: "command",
type: "string",
description: "Shell command to execute",
required: true,
},
{
name: "timeout",
type: "number",
description: "Timeout in milliseconds (default: 30000)",
required: false,
},
]
readonly requiresConfirmation = false
readonly category = "run" as const
private readonly security: CommandSecurity
private readonly execFn: typeof execAsync
constructor(security?: CommandSecurity, execFn?: typeof execAsync) {
this.security = security ?? new CommandSecurity()
this.execFn = execFn ?? execAsync
}
validateParams(params: Record<string, unknown>): string | null {
if (params.command === undefined) {
return "Parameter 'command' is required"
}
if (typeof params.command !== "string") {
return "Parameter 'command' must be a string"
}
if (params.command.trim() === "") {
return "Parameter 'command' cannot be empty"
}
if (params.timeout !== undefined) {
if (typeof params.timeout !== "number") {
return "Parameter 'timeout' must be a number"
}
if (params.timeout <= 0) {
return "Parameter 'timeout' must be positive"
}
if (params.timeout > 600000) {
return "Parameter 'timeout' cannot exceed 600000ms (10 minutes)"
}
}
return null
}
async execute(params: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
const startTime = Date.now()
const callId = `${this.name}-${String(startTime)}`
const command = params.command as string
const timeout = (params.timeout as number) ?? DEFAULT_TIMEOUT
const securityCheck = this.security.check(command)
if (securityCheck.classification === "blocked") {
return createErrorResult(
callId,
`Command blocked for security: ${securityCheck.reason}`,
Date.now() - startTime,
)
}
let requiredConfirmation = false
if (securityCheck.classification === "requires_confirmation") {
requiredConfirmation = true
const confirmed = await ctx.requestConfirmation(
`Execute command: ${command}\n\nReason: ${securityCheck.reason}`,
)
if (!confirmed) {
return createErrorResult(
callId,
"Command execution cancelled by user",
Date.now() - startTime,
)
}
}
try {
const execStartTime = Date.now()
const { stdout, stderr } = await this.execFn(command, {
cwd: ctx.projectRoot,
timeout,
maxBuffer: MAX_OUTPUT_SIZE,
env: { ...process.env, FORCE_COLOR: "0" },
})
const durationMs = Date.now() - execStartTime
const result: RunCommandResult = {
command,
exitCode: 0,
stdout: this.truncateOutput(stdout),
stderr: this.truncateOutput(stderr),
success: true,
durationMs,
requiredConfirmation,
}
return createSuccessResult(callId, result, Date.now() - startTime)
} catch (error) {
return this.handleExecError(callId, command, error, requiredConfirmation, startTime)
}
}
/**
* Handle exec errors and return appropriate result.
*/
private handleExecError(
callId: string,
command: string,
error: unknown,
requiredConfirmation: boolean,
startTime: number,
): ToolResult {
if (this.isExecError(error)) {
const result: RunCommandResult = {
command,
exitCode: error.code ?? 1,
stdout: this.truncateOutput(error.stdout ?? ""),
stderr: this.truncateOutput(error.stderr ?? error.message),
success: false,
durationMs: Date.now() - startTime,
requiredConfirmation,
}
return createSuccessResult(callId, result, Date.now() - startTime)
}
if (error instanceof Error) {
if (error.message.includes("ETIMEDOUT") || error.message.includes("timed out")) {
return createErrorResult(
callId,
`Command timed out: ${command}`,
Date.now() - startTime,
)
}
return createErrorResult(callId, error.message, Date.now() - startTime)
}
return createErrorResult(callId, String(error), Date.now() - startTime)
}
/**
* Type guard for exec error.
*/
private isExecError(
error: unknown,
): error is Error & { code?: number; stdout?: string; stderr?: string } {
return error instanceof Error && "code" in error
}
/**
* Truncate output if too large.
*/
private truncateOutput(output: string): string {
if (output.length <= MAX_OUTPUT_SIZE) {
return output
}
return `${output.slice(0, MAX_OUTPUT_SIZE)}\n... (output truncated)`
}
/**
* Get the security checker instance.
*/
getSecurity(): CommandSecurity {
return this.security
}
}

View File

@@ -0,0 +1,365 @@
import { exec } from "node:child_process"
import { promisify } from "node:util"
import * as path from "node:path"
import * as fs from "node:fs/promises"
import type { ITool, ToolContext, ToolParameterSchema } from "../../../domain/services/ITool.js"
import {
createErrorResult,
createSuccessResult,
type ToolResult,
} from "../../../domain/value-objects/ToolResult.js"
const execAsync = promisify(exec)
/**
* Supported test runners.
*/
export type TestRunner = "vitest" | "jest" | "mocha" | "npm"
/**
* Result data from run_tests tool.
*/
export interface RunTestsResult {
/** Test runner that was used */
runner: TestRunner
/** Command that was executed */
command: string
/** Whether all tests passed */
passed: boolean
/** Exit code */
exitCode: number
/** Standard output */
stdout: string
/** Standard error output */
stderr: string
/** Execution time in milliseconds */
durationMs: number
}
/**
* Default test timeout in milliseconds (5 minutes).
*/
const DEFAULT_TIMEOUT = 300000
/**
* Maximum output size in characters.
*/
const MAX_OUTPUT_SIZE = 200000
/**
* Tool for running project tests.
* Auto-detects test runner (vitest, jest, mocha, npm test).
*/
export class RunTestsTool implements ITool {
readonly name = "run_tests"
readonly description =
"Run the project's test suite. Auto-detects test runner (vitest, jest, npm test). " +
"Returns test results summary."
readonly parameters: ToolParameterSchema[] = [
{
name: "path",
type: "string",
description: "Run tests for specific file or directory",
required: false,
},
{
name: "filter",
type: "string",
description: "Filter tests by name pattern",
required: false,
},
{
name: "watch",
type: "boolean",
description: "Run in watch mode (default: false)",
required: false,
},
]
readonly requiresConfirmation = false
readonly category = "run" as const
private readonly execFn: typeof execAsync
private readonly fsAccess: typeof fs.access
private readonly fsReadFile: typeof fs.readFile
constructor(
execFn?: typeof execAsync,
fsAccess?: typeof fs.access,
fsReadFile?: typeof fs.readFile,
) {
this.execFn = execFn ?? execAsync
this.fsAccess = fsAccess ?? fs.access
this.fsReadFile = fsReadFile ?? fs.readFile
}
validateParams(params: Record<string, unknown>): string | null {
if (params.path !== undefined && typeof params.path !== "string") {
return "Parameter 'path' must be a string"
}
if (params.filter !== undefined && typeof params.filter !== "string") {
return "Parameter 'filter' must be a string"
}
if (params.watch !== undefined && typeof params.watch !== "boolean") {
return "Parameter 'watch' must be a boolean"
}
return null
}
async execute(params: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
const startTime = Date.now()
const callId = `${this.name}-${String(startTime)}`
const testPath = params.path as string | undefined
const filter = params.filter as string | undefined
const watch = (params.watch as boolean) ?? false
try {
const runner = await this.detectTestRunner(ctx.projectRoot)
if (!runner) {
return createErrorResult(
callId,
"No test runner detected. Ensure vitest, jest, or mocha is installed, or 'test' script exists in package.json.",
Date.now() - startTime,
)
}
const command = this.buildCommand(runner, testPath, filter, watch)
const execStartTime = Date.now()
try {
const { stdout, stderr } = await this.execFn(command, {
cwd: ctx.projectRoot,
timeout: DEFAULT_TIMEOUT,
maxBuffer: MAX_OUTPUT_SIZE,
env: { ...process.env, FORCE_COLOR: "0", CI: "true" },
})
const durationMs = Date.now() - execStartTime
const result: RunTestsResult = {
runner,
command,
passed: true,
exitCode: 0,
stdout: this.truncateOutput(stdout),
stderr: this.truncateOutput(stderr),
durationMs,
}
return createSuccessResult(callId, result, Date.now() - startTime)
} catch (error) {
return this.handleExecError(
{ callId, runner, command, startTime },
error,
execStartTime,
)
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return createErrorResult(callId, message, Date.now() - startTime)
}
}
/**
* Detect which test runner is available in the project.
*/
async detectTestRunner(projectRoot: string): Promise<TestRunner | null> {
const configRunner = await this.detectByConfigFile(projectRoot)
if (configRunner) {
return configRunner
}
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")
try {
const content = await this.fsReadFile(packageJsonPath, "utf-8")
const pkg = JSON.parse(content) as {
scripts?: Record<string, string>
devDependencies?: Record<string, string>
dependencies?: Record<string, string>
}
const deps = { ...pkg.devDependencies, ...pkg.dependencies }
if (deps.vitest) {
return "vitest"
}
if (deps.jest) {
return "jest"
}
if (deps.mocha) {
return "mocha"
}
if (pkg.scripts?.test) {
return "npm"
}
} catch {
// package.json doesn't exist or is invalid
}
return null
}
/**
* Build the test command based on runner and options.
*/
buildCommand(runner: TestRunner, testPath?: string, filter?: string, watch?: boolean): string {
const builders: Record<TestRunner, () => string[]> = {
vitest: () => this.buildVitestCommand(testPath, filter, watch),
jest: () => this.buildJestCommand(testPath, filter, watch),
mocha: () => this.buildMochaCommand(testPath, filter, watch),
npm: () => this.buildNpmCommand(testPath, filter),
}
return builders[runner]().join(" ")
}
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
}
/**
* Check if a file exists.
*/
private async hasFile(projectRoot: string, filename: string): Promise<boolean> {
try {
await this.fsAccess(path.join(projectRoot, filename))
return true
} catch {
return false
}
}
/**
* Handle exec errors and return appropriate result.
*/
private handleExecError(
ctx: { callId: string; runner: TestRunner; command: string; startTime: number },
error: unknown,
execStartTime: number,
): ToolResult {
const { callId, runner, command, startTime } = ctx
const durationMs = Date.now() - execStartTime
if (this.isExecError(error)) {
const result: RunTestsResult = {
runner,
command,
passed: false,
exitCode: error.code ?? 1,
stdout: this.truncateOutput(error.stdout ?? ""),
stderr: this.truncateOutput(error.stderr ?? error.message),
durationMs,
}
return createSuccessResult(callId, result, Date.now() - startTime)
}
if (error instanceof Error) {
if (error.message.includes("ETIMEDOUT") || error.message.includes("timed out")) {
return createErrorResult(
callId,
`Tests timed out after ${String(DEFAULT_TIMEOUT / 1000)} seconds`,
Date.now() - startTime,
)
}
return createErrorResult(callId, error.message, Date.now() - startTime)
}
return createErrorResult(callId, String(error), Date.now() - startTime)
}
/**
* Type guard for exec error.
*/
private isExecError(
error: unknown,
): error is Error & { code?: number; stdout?: string; stderr?: string } {
return error instanceof Error && "code" in error
}
/**
* Truncate output if too large.
*/
private truncateOutput(output: string): string {
if (output.length <= MAX_OUTPUT_SIZE) {
return output
}
return `${output.slice(0, MAX_OUTPUT_SIZE)}\n... (output truncated)`
}
}

View File

@@ -0,0 +1,12 @@
// Run tools exports
export {
CommandSecurity,
DEFAULT_BLACKLIST,
DEFAULT_WHITELIST,
type CommandClassification,
type SecurityCheckResult,
} from "./CommandSecurity.js"
export { RunCommandTool, type RunCommandResult } from "./RunCommandTool.js"
export { RunTestsTool, type RunTestsResult, type TestRunner } from "./RunTestsTool.js"

View File

@@ -0,0 +1,167 @@
/**
* Main TUI App component.
* Orchestrates the terminal user interface.
*/
import { Box, Text, useApp } from "ink"
import React, { useCallback, useEffect, useState } from "react"
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 } from "../domain/services/ITool.js"
import type { ErrorChoice } from "../shared/types/index.js"
import type { IToolRegistry } from "../application/interfaces/IToolRegistry.js"
import type { ProjectStructure } from "../infrastructure/llm/prompts.js"
import { Chat, Input, StatusBar } from "./components/index.js"
import { useHotkeys, useSession } from "./hooks/index.js"
import type { AppProps, BranchInfo } from "./types.js"
export interface AppDependencies {
storage: IStorage
sessionStorage: ISessionStorage
llm: ILLMClient
tools: IToolRegistry
projectStructure?: ProjectStructure
}
export interface ExtendedAppProps extends AppProps {
deps: AppDependencies
onExit?: () => void
}
function LoadingScreen(): React.JSX.Element {
return (
<Box flexDirection="column" padding={1}>
<Text color="cyan">Loading session...</Text>
</Box>
)
}
function ErrorScreen({ error }: { error: Error }): React.JSX.Element {
return (
<Box flexDirection="column" padding={1}>
<Text color="red" bold>
Error
</Text>
<Text color="red">{error.message}</Text>
</Box>
)
}
async function handleConfirmationDefault(_message: string, _diff?: DiffInfo): Promise<boolean> {
return Promise.resolve(true)
}
async function handleErrorDefault(_error: Error): Promise<ErrorChoice> {
return Promise.resolve("skip")
}
export function App({
projectPath,
autoApply = false,
deps,
onExit,
}: ExtendedAppProps): React.JSX.Element {
const { exit } = useApp()
const [branch] = useState<BranchInfo>({ name: "main", isDetached: false })
const [sessionTime, setSessionTime] = useState("0m")
const projectName = projectPath.split("/").pop() ?? "unknown"
const { session, messages, status, isLoading, error, sendMessage, undo, abort } = useSession(
{
storage: deps.storage,
sessionStorage: deps.sessionStorage,
llm: deps.llm,
tools: deps.tools,
projectRoot: projectPath,
projectName,
projectStructure: deps.projectStructure,
},
{
autoApply,
onConfirmation: handleConfirmationDefault,
onError: handleErrorDefault,
},
)
const handleExit = useCallback((): void => {
onExit?.()
exit()
}, [exit, onExit])
const handleInterrupt = useCallback((): void => {
if (status === "thinking" || status === "tool_call") {
abort()
}
}, [status, abort])
const handleUndo = useCallback((): void => {
void undo()
}, [undo])
useHotkeys(
{
onInterrupt: handleInterrupt,
onExit: handleExit,
onUndo: handleUndo,
},
{ enabled: !isLoading },
)
useEffect(() => {
if (!session) {
return
}
const interval = setInterval(() => {
setSessionTime(session.getSessionDurationFormatted())
}, 60_000)
setSessionTime(session.getSessionDurationFormatted())
return (): void => {
clearInterval(interval)
}
}, [session])
const handleSubmit = useCallback(
(text: string): void => {
if (text.startsWith("/")) {
return
}
void sendMessage(text)
},
[sendMessage],
)
if (isLoading) {
return <LoadingScreen />
}
if (error) {
return <ErrorScreen error={error} />
}
const isInputDisabled = status === "thinking" || status === "tool_call"
return (
<Box flexDirection="column" height="100%">
<StatusBar
contextUsage={session?.context.tokenUsage ?? 0}
projectName={projectName}
branch={branch}
sessionTime={sessionTime}
status={status}
/>
<Chat messages={messages} isThinking={status === "thinking"} />
<Input
onSubmit={handleSubmit}
history={session?.inputHistory ?? []}
disabled={isInputDisabled}
placeholder={isInputDisabled ? "Processing..." : "Type a message..."}
/>
</Box>
)
}

View File

@@ -0,0 +1,170 @@
/**
* Chat component for TUI.
* Displays message history with tool calls and stats.
*/
import { Box, Text } from "ink"
import type React from "react"
import type { ChatMessage } from "../../domain/value-objects/ChatMessage.js"
import type { ToolCall } from "../../domain/value-objects/ToolCall.js"
export interface ChatProps {
messages: ChatMessage[]
isThinking: boolean
}
function formatTimestamp(timestamp: number): string {
const date = new Date(timestamp)
const hours = String(date.getHours()).padStart(2, "0")
const minutes = String(date.getMinutes()).padStart(2, "0")
return `${hours}:${minutes}`
}
function formatStats(stats: ChatMessage["stats"]): string {
if (!stats) {
return ""
}
const time = (stats.timeMs / 1000).toFixed(1)
const tokens = stats.tokens.toLocaleString()
const tools = stats.toolCalls
const parts = [`${time}s`, `${tokens} tokens`]
if (tools > 0) {
parts.push(`${String(tools)} tool${tools > 1 ? "s" : ""}`)
}
return parts.join(" | ")
}
function formatToolCall(call: ToolCall): string {
const params = Object.entries(call.params)
.map(([k, v]) => `${k}=${JSON.stringify(v)}`)
.join(" ")
return `[${call.name} ${params}]`
}
function UserMessage({ message }: { message: ChatMessage }): React.JSX.Element {
return (
<Box flexDirection="column" marginBottom={1}>
<Box gap={1}>
<Text color="green" bold>
You
</Text>
<Text color="gray" dimColor>
{formatTimestamp(message.timestamp)}
</Text>
</Box>
<Box marginLeft={2}>
<Text>{message.content}</Text>
</Box>
</Box>
)
}
function AssistantMessage({ message }: { message: ChatMessage }): React.JSX.Element {
const stats = formatStats(message.stats)
return (
<Box flexDirection="column" marginBottom={1}>
<Box gap={1}>
<Text color="cyan" bold>
Assistant
</Text>
<Text color="gray" dimColor>
{formatTimestamp(message.timestamp)}
</Text>
</Box>
{message.toolCalls && message.toolCalls.length > 0 && (
<Box flexDirection="column" marginLeft={2} marginBottom={1}>
{message.toolCalls.map((call) => (
<Text key={call.id} color="yellow">
{formatToolCall(call)}
</Text>
))}
</Box>
)}
{message.content && (
<Box marginLeft={2}>
<Text>{message.content}</Text>
</Box>
)}
{stats && (
<Box marginLeft={2} marginTop={1}>
<Text color="gray" dimColor>
{stats}
</Text>
</Box>
)}
</Box>
)
}
function ToolMessage({ message }: { message: ChatMessage }): React.JSX.Element {
return (
<Box flexDirection="column" marginBottom={1} marginLeft={2}>
{message.toolResults?.map((result) => (
<Box key={result.callId} flexDirection="column">
<Text color={result.success ? "green" : "red"}>
{result.success ? "+" : "x"} {result.callId.slice(0, 8)}
</Text>
</Box>
))}
</Box>
)
}
function SystemMessage({ message }: { message: ChatMessage }): React.JSX.Element {
const isError = message.content.toLowerCase().startsWith("error")
return (
<Box marginBottom={1} marginLeft={2}>
<Text color={isError ? "red" : "gray"} dimColor={!isError}>
{message.content}
</Text>
</Box>
)
}
function MessageComponent({ message }: { message: ChatMessage }): React.JSX.Element {
switch (message.role) {
case "user": {
return <UserMessage message={message} />
}
case "assistant": {
return <AssistantMessage message={message} />
}
case "tool": {
return <ToolMessage message={message} />
}
case "system": {
return <SystemMessage message={message} />
}
default: {
return <></>
}
}
}
function ThinkingIndicator(): React.JSX.Element {
return (
<Box marginBottom={1}>
<Text color="yellow">Thinking...</Text>
</Box>
)
}
export function Chat({ messages, isThinking }: ChatProps): React.JSX.Element {
return (
<Box flexDirection="column" flexGrow={1} paddingX={1}>
{messages.map((message, index) => (
<MessageComponent
key={`${String(message.timestamp)}-${String(index)}`}
message={message}
/>
))}
{isThinking && <ThinkingIndicator />}
</Box>
)
}

View File

@@ -0,0 +1,83 @@
/**
* ConfirmDialog component for TUI.
* Displays a confirmation dialog with [Y] Apply / [N] Cancel / [E] Edit options.
*/
import { Box, Text, useInput } from "ink"
import React, { useState } from "react"
import type { ConfirmChoice } from "../../shared/types/index.js"
import { DiffView, type DiffViewProps } from "./DiffView.js"
export interface ConfirmDialogProps {
message: string
diff?: DiffViewProps
onSelect: (choice: ConfirmChoice) => void
}
function ChoiceButton({
hotkey,
label,
isSelected,
}: {
hotkey: string
label: string
isSelected: boolean
}): React.JSX.Element {
return (
<Box>
<Text color={isSelected ? "cyan" : "gray"}>
[<Text bold>{hotkey}</Text>] {label}
</Text>
</Box>
)
}
export function ConfirmDialog({ message, diff, onSelect }: ConfirmDialogProps): React.JSX.Element {
const [selected, setSelected] = useState<ConfirmChoice | null>(null)
useInput((input, key) => {
const lowerInput = input.toLowerCase()
if (lowerInput === "y") {
setSelected("apply")
onSelect("apply")
} else if (lowerInput === "n") {
setSelected("cancel")
onSelect("cancel")
} else if (lowerInput === "e") {
setSelected("edit")
onSelect("edit")
} else if (key.escape) {
setSelected("cancel")
onSelect("cancel")
}
})
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor="yellow"
paddingX={1}
paddingY={1}
>
<Box marginBottom={1}>
<Text color="yellow" bold>
{message}
</Text>
</Box>
{diff && (
<Box marginBottom={1}>
<DiffView {...diff} />
</Box>
)}
<Box gap={2}>
<ChoiceButton hotkey="Y" label="Apply" isSelected={selected === "apply"} />
<ChoiceButton hotkey="N" label="Cancel" isSelected={selected === "cancel"} />
<ChoiceButton hotkey="E" label="Edit" isSelected={selected === "edit"} />
</Box>
</Box>
)
}

View File

@@ -0,0 +1,193 @@
/**
* DiffView component for TUI.
* Displays inline diff with green (added) and red (removed) highlighting.
*/
import { Box, Text } from "ink"
import type React from "react"
export interface DiffViewProps {
filePath: string
oldLines: string[]
newLines: string[]
startLine: number
}
interface DiffLine {
type: "add" | "remove" | "context"
content: string
lineNumber?: number
}
function computeDiff(oldLines: string[], newLines: string[], startLine: number): DiffLine[] {
const result: DiffLine[] = []
let oldIdx = 0
let newIdx = 0
while (oldIdx < oldLines.length || newIdx < newLines.length) {
const oldLine = oldIdx < oldLines.length ? oldLines[oldIdx] : undefined
const newLine = newIdx < newLines.length ? newLines[newIdx] : undefined
if (oldLine === newLine) {
result.push({
type: "context",
content: oldLine ?? "",
lineNumber: startLine + newIdx,
})
oldIdx++
newIdx++
} else {
if (oldLine !== undefined) {
result.push({
type: "remove",
content: oldLine,
})
oldIdx++
}
if (newLine !== undefined) {
result.push({
type: "add",
content: newLine,
lineNumber: startLine + newIdx,
})
newIdx++
}
}
}
return result
}
function getLinePrefix(line: DiffLine): string {
switch (line.type) {
case "add": {
return "+"
}
case "remove": {
return "-"
}
case "context": {
return " "
}
}
}
function getLineColor(line: DiffLine): string {
switch (line.type) {
case "add": {
return "green"
}
case "remove": {
return "red"
}
case "context": {
return "gray"
}
}
}
function formatLineNumber(num: number | undefined, width: number): string {
if (num === undefined) {
return " ".repeat(width)
}
return String(num).padStart(width, " ")
}
function DiffLine({
line,
lineNumberWidth,
}: {
line: DiffLine
lineNumberWidth: number
}): React.JSX.Element {
const prefix = getLinePrefix(line)
const color = getLineColor(line)
const lineNum = formatLineNumber(line.lineNumber, lineNumberWidth)
return (
<Box>
<Text color="gray">{lineNum} </Text>
<Text color={color}>
{prefix} {line.content}
</Text>
</Box>
)
}
function DiffHeader({
filePath,
startLine,
endLine,
}: {
filePath: string
startLine: number
endLine: number
}): React.JSX.Element {
const lineRange =
startLine === endLine
? `line ${String(startLine)}`
: `lines ${String(startLine)}-${String(endLine)}`
return (
<Box>
<Text color="gray"> </Text>
<Text color="cyan">{filePath}</Text>
<Text color="gray"> ({lineRange}) </Text>
</Box>
)
}
function DiffFooter(): React.JSX.Element {
return (
<Box>
<Text color="gray"></Text>
</Box>
)
}
function DiffStats({
additions,
deletions,
}: {
additions: number
deletions: number
}): React.JSX.Element {
return (
<Box gap={1} marginTop={1}>
<Text color="green">+{String(additions)}</Text>
<Text color="red">-{String(deletions)}</Text>
</Box>
)
}
export function DiffView({
filePath,
oldLines,
newLines,
startLine,
}: DiffViewProps): React.JSX.Element {
const diffLines = computeDiff(oldLines, newLines, startLine)
const endLine = startLine + newLines.length - 1
const lineNumberWidth = String(endLine).length
const additions = diffLines.filter((l) => l.type === "add").length
const deletions = diffLines.filter((l) => l.type === "remove").length
return (
<Box flexDirection="column" paddingX={1}>
<DiffHeader filePath={filePath} startLine={startLine} endLine={endLine} />
<Box flexDirection="column" paddingX={1}>
{diffLines.map((line, index) => (
<DiffLine
key={`${line.type}-${String(index)}`}
line={line}
lineNumberWidth={lineNumberWidth}
/>
))}
</Box>
<DiffFooter />
<DiffStats additions={additions} deletions={deletions} />
</Box>
)
}

View File

@@ -0,0 +1,105 @@
/**
* ErrorDialog component for TUI.
* Displays an error with [R] Retry / [S] Skip / [A] Abort options.
*/
import { Box, Text, useInput } from "ink"
import React, { useState } from "react"
import type { ErrorChoice } from "../../shared/types/index.js"
export interface ErrorInfo {
type: string
message: string
recoverable: boolean
}
export interface ErrorDialogProps {
error: ErrorInfo
onChoice: (choice: ErrorChoice) => void
}
function ChoiceButton({
hotkey,
label,
isSelected,
disabled,
}: {
hotkey: string
label: string
isSelected: boolean
disabled?: boolean
}): React.JSX.Element {
if (disabled) {
return (
<Box>
<Text color="gray" dimColor>
[{hotkey}] {label}
</Text>
</Box>
)
}
return (
<Box>
<Text color={isSelected ? "cyan" : "gray"}>
[<Text bold>{hotkey}</Text>] {label}
</Text>
</Box>
)
}
export function ErrorDialog({ error, onChoice }: ErrorDialogProps): React.JSX.Element {
const [selected, setSelected] = useState<ErrorChoice | null>(null)
useInput((input, key) => {
const lowerInput = input.toLowerCase()
if (lowerInput === "r" && error.recoverable) {
setSelected("retry")
onChoice("retry")
} else if (lowerInput === "s" && error.recoverable) {
setSelected("skip")
onChoice("skip")
} else if (lowerInput === "a") {
setSelected("abort")
onChoice("abort")
} else if (key.escape) {
setSelected("abort")
onChoice("abort")
}
})
return (
<Box flexDirection="column" borderStyle="round" borderColor="red" paddingX={1} paddingY={1}>
<Box marginBottom={1}>
<Text color="red" bold>
x {error.type}: {error.message}
</Text>
</Box>
<Box gap={2}>
<ChoiceButton
hotkey="R"
label="Retry"
isSelected={selected === "retry"}
disabled={!error.recoverable}
/>
<ChoiceButton
hotkey="S"
label="Skip"
isSelected={selected === "skip"}
disabled={!error.recoverable}
/>
<ChoiceButton hotkey="A" label="Abort" isSelected={selected === "abort"} />
</Box>
{!error.recoverable && (
<Box marginTop={1}>
<Text color="gray" dimColor>
This error is not recoverable. Press [A] to abort.
</Text>
</Box>
)}
</Box>
)
}

View File

@@ -0,0 +1,99 @@
/**
* Input component for TUI.
* Prompt with history navigation (up/down) and path autocomplete (tab).
*/
import { Box, Text, useInput } from "ink"
import TextInput from "ink-text-input"
import React, { useCallback, useState } from "react"
export interface InputProps {
onSubmit: (text: string) => void
history: string[]
disabled: boolean
placeholder?: string
}
export function Input({
onSubmit,
history,
disabled,
placeholder = "Type a message...",
}: InputProps): React.JSX.Element {
const [value, setValue] = useState("")
const [historyIndex, setHistoryIndex] = useState(-1)
const [savedInput, setSavedInput] = useState("")
const handleChange = useCallback((newValue: string) => {
setValue(newValue)
setHistoryIndex(-1)
}, [])
const handleSubmit = useCallback(
(text: string) => {
if (disabled || !text.trim()) {
return
}
onSubmit(text)
setValue("")
setHistoryIndex(-1)
setSavedInput("")
},
[disabled, onSubmit],
)
useInput(
(input, key) => {
if (disabled) {
return
}
if (key.upArrow && history.length > 0) {
if (historyIndex === -1) {
setSavedInput(value)
}
const newIndex =
historyIndex === -1 ? history.length - 1 : Math.max(0, historyIndex - 1)
setHistoryIndex(newIndex)
setValue(history[newIndex] ?? "")
}
if (key.downArrow) {
if (historyIndex === -1) {
return
}
if (historyIndex >= history.length - 1) {
setHistoryIndex(-1)
setValue(savedInput)
} else {
const newIndex = historyIndex + 1
setHistoryIndex(newIndex)
setValue(history[newIndex] ?? "")
}
}
},
{ isActive: !disabled },
)
return (
<Box borderStyle="single" borderColor={disabled ? "gray" : "cyan"} paddingX={1}>
<Text color={disabled ? "gray" : "green"} bold>
{">"}{" "}
</Text>
{disabled ? (
<Text color="gray" dimColor>
{placeholder}
</Text>
) : (
<TextInput
value={value}
onChange={handleChange}
onSubmit={handleSubmit}
placeholder={placeholder}
/>
)}
</Box>
)
}

View File

@@ -0,0 +1,62 @@
/**
* Progress component for TUI.
* Displays a progress bar: [=====> ] 45% (120/267 files)
*/
import { Box, Text } from "ink"
import type React from "react"
export interface ProgressProps {
current: number
total: number
label: string
width?: number
}
function calculatePercentage(current: number, total: number): number {
if (total === 0) {
return 0
}
return Math.min(100, Math.round((current / total) * 100))
}
function createProgressBar(percentage: number, width: number): { filled: string; empty: string } {
const filledWidth = Math.round((percentage / 100) * width)
const emptyWidth = width - filledWidth
const filled = "=".repeat(Math.max(0, filledWidth - 1)) + (filledWidth > 0 ? ">" : "")
const empty = " ".repeat(Math.max(0, emptyWidth))
return { filled, empty }
}
function getProgressColor(percentage: number): string {
if (percentage >= 100) {
return "green"
}
if (percentage >= 50) {
return "yellow"
}
return "cyan"
}
export function Progress({ current, total, label, width = 30 }: ProgressProps): React.JSX.Element {
const percentage = calculatePercentage(current, total)
const { filled, empty } = createProgressBar(percentage, width)
const color = getProgressColor(percentage)
return (
<Box gap={1}>
<Text color="gray">[</Text>
<Text color={color}>{filled}</Text>
<Text color="gray">{empty}</Text>
<Text color="gray">]</Text>
<Text color={color} bold>
{String(percentage)}%
</Text>
<Text color="gray">
({String(current)}/{String(total)} {label})
</Text>
</Box>
)
}

View File

@@ -0,0 +1,81 @@
/**
* StatusBar component for TUI.
* Displays: [ipuaro] [ctx: 12%] [project: myapp] [main] [47m] status
*/
import { Box, Text } from "ink"
import type React from "react"
import type { BranchInfo, TuiStatus } from "../types.js"
export interface StatusBarProps {
contextUsage: number
projectName: string
branch: BranchInfo
sessionTime: string
status: TuiStatus
}
function getStatusIndicator(status: TuiStatus): { text: string; color: string } {
switch (status) {
case "ready": {
return { text: "ready", color: "green" }
}
case "thinking": {
return { text: "thinking...", color: "yellow" }
}
case "tool_call": {
return { text: "executing...", color: "cyan" }
}
case "awaiting_confirmation": {
return { text: "confirm?", color: "magenta" }
}
case "error": {
return { text: "error", color: "red" }
}
default: {
return { text: "ready", color: "green" }
}
}
}
function formatContextUsage(usage: number): string {
return `${String(Math.round(usage * 100))}%`
}
export function StatusBar({
contextUsage,
projectName,
branch,
sessionTime,
status,
}: StatusBarProps): React.JSX.Element {
const statusIndicator = getStatusIndicator(status)
const branchDisplay = branch.isDetached ? `HEAD@${branch.name.slice(0, 7)}` : branch.name
return (
<Box borderStyle="single" borderColor="gray" paddingX={1} justifyContent="space-between">
<Box gap={1}>
<Text color="cyan" bold>
[ipuaro]
</Text>
<Text color="gray">
[ctx:{" "}
<Text color={contextUsage > 0.8 ? "red" : "white"}>
{formatContextUsage(contextUsage)}
</Text>
]
</Text>
<Text color="gray">
[<Text color="blue">{projectName}</Text>]
</Text>
<Text color="gray">
[<Text color="green">{branchDisplay}</Text>]
</Text>
<Text color="gray">
[<Text color="white">{sessionTime}</Text>]
</Text>
</Box>
<Text color={statusIndicator.color}>{statusIndicator.text}</Text>
</Box>
)
}

View File

@@ -0,0 +1,11 @@
/**
* TUI components.
*/
export { StatusBar, type StatusBarProps } from "./StatusBar.js"
export { Chat, type ChatProps } from "./Chat.js"
export { Input, type InputProps } from "./Input.js"
export { DiffView, type DiffViewProps } from "./DiffView.js"
export { ConfirmDialog, type ConfirmDialogProps } from "./ConfirmDialog.js"
export { ErrorDialog, type ErrorDialogProps, type ErrorInfo } from "./ErrorDialog.js"
export { Progress, type ProgressProps } from "./Progress.js"

View File

@@ -0,0 +1,11 @@
/**
* TUI hooks.
*/
export {
useSession,
type UseSessionDependencies,
type UseSessionOptions,
type UseSessionReturn,
} from "./useSession.js"
export { useHotkeys, type HotkeyHandlers, type UseHotkeysOptions } from "./useHotkeys.js"

View File

@@ -0,0 +1,59 @@
/**
* useHotkeys hook for TUI.
* Handles global keyboard shortcuts.
*/
import { useInput } from "ink"
import { useCallback, useRef } from "react"
export interface HotkeyHandlers {
onInterrupt?: () => void
onExit?: () => void
onUndo?: () => void
}
export interface UseHotkeysOptions {
enabled?: boolean
}
export function useHotkeys(handlers: HotkeyHandlers, options: UseHotkeysOptions = {}): void {
const { enabled = true } = options
const interruptCount = useRef(0)
const interruptTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const resetInterruptCount = useCallback((): void => {
interruptCount.current = 0
if (interruptTimer.current) {
clearTimeout(interruptTimer.current)
interruptTimer.current = null
}
}, [])
useInput(
(_input, key) => {
if (key.ctrl && _input === "c") {
interruptCount.current++
if (interruptCount.current === 1) {
handlers.onInterrupt?.()
interruptTimer.current = setTimeout(() => {
resetInterruptCount()
}, 1000)
} else if (interruptCount.current >= 2) {
resetInterruptCount()
handlers.onExit?.()
}
}
if (key.ctrl && _input === "d") {
handlers.onExit?.()
}
if (key.ctrl && _input === "z") {
handlers.onUndo?.()
}
},
{ isActive: enabled },
)
}

View File

@@ -0,0 +1,205 @@
/**
* useSession hook for TUI.
* Manages session state and message handling.
*/
import { useCallback, useEffect, useRef, useState } from "react"
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 } from "../../domain/services/ITool.js"
import type { ChatMessage } from "../../domain/value-objects/ChatMessage.js"
import type { ErrorChoice } from "../../shared/types/index.js"
import type { IToolRegistry } from "../../application/interfaces/IToolRegistry.js"
import {
HandleMessage,
type HandleMessageStatus,
} from "../../application/use-cases/HandleMessage.js"
import { StartSession } from "../../application/use-cases/StartSession.js"
import { UndoChange } from "../../application/use-cases/UndoChange.js"
import type { ProjectStructure } from "../../infrastructure/llm/prompts.js"
import type { TuiStatus } from "../types.js"
export interface UseSessionDependencies {
storage: IStorage
sessionStorage: ISessionStorage
llm: ILLMClient
tools: IToolRegistry
projectRoot: string
projectName: string
projectStructure?: ProjectStructure
}
export interface UseSessionOptions {
autoApply?: boolean
onConfirmation?: (message: string, diff?: DiffInfo) => Promise<boolean>
onError?: (error: Error) => Promise<ErrorChoice>
}
export interface UseSessionReturn {
session: Session | null
messages: ChatMessage[]
status: TuiStatus
isLoading: boolean
error: Error | null
sendMessage: (message: string) => Promise<void>
undo: () => Promise<boolean>
clearHistory: () => void
abort: () => void
}
interface SessionRefs {
session: Session | null
handleMessage: HandleMessage | null
undoChange: UndoChange | null
}
type SetStatus = React.Dispatch<React.SetStateAction<TuiStatus>>
type SetMessages = React.Dispatch<React.SetStateAction<ChatMessage[]>>
interface StateSetters {
setMessages: SetMessages
setStatus: SetStatus
forceUpdate: () => void
}
function createEventHandlers(
setters: StateSetters,
options: UseSessionOptions,
): Parameters<HandleMessage["setEvents"]>[0] {
return {
onMessage: (msg) => {
setters.setMessages((prev) => [...prev, msg])
},
onToolCall: () => {
setters.setStatus("tool_call")
},
onToolResult: () => {
setters.setStatus("thinking")
},
onConfirmation: options.onConfirmation,
onError: options.onError,
onStatusChange: (s: HandleMessageStatus) => {
setters.setStatus(s)
},
onUndoEntry: () => {
setters.forceUpdate()
},
}
}
async function initializeSession(
deps: UseSessionDependencies,
options: UseSessionOptions,
refs: React.MutableRefObject<SessionRefs>,
setters: StateSetters,
): Promise<void> {
const startSession = new StartSession(deps.sessionStorage)
const result = await startSession.execute(deps.projectName)
refs.current.session = result.session
setters.setMessages([...result.session.history])
const handleMessage = new HandleMessage(
deps.storage,
deps.sessionStorage,
deps.llm,
deps.tools,
deps.projectRoot,
)
if (deps.projectStructure) {
handleMessage.setProjectStructure(deps.projectStructure)
}
handleMessage.setOptions({ autoApply: options.autoApply })
handleMessage.setEvents(createEventHandlers(setters, options))
refs.current.handleMessage = handleMessage
refs.current.undoChange = new UndoChange(deps.sessionStorage, deps.storage)
setters.forceUpdate()
}
export function useSession(
deps: UseSessionDependencies,
options: UseSessionOptions = {},
): UseSessionReturn {
const [messages, setMessages] = useState<ChatMessage[]>([])
const [status, setStatus] = useState<TuiStatus>("ready")
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
const [, setTrigger] = useState(0)
const refs = useRef<SessionRefs>({ session: null, handleMessage: null, undoChange: null })
const forceUpdate = useCallback(() => {
setTrigger((v) => v + 1)
}, [])
useEffect(() => {
setIsLoading(true)
const setters: StateSetters = { setMessages, setStatus, forceUpdate }
initializeSession(deps, options, refs, setters)
.then(() => {
setError(null)
})
.catch((err: unknown) => {
setError(err instanceof Error ? err : new Error(String(err)))
})
.finally(() => {
setIsLoading(false)
})
}, [deps.projectName, forceUpdate])
const sendMessage = useCallback(async (message: string): Promise<void> => {
const { session, handleMessage } = refs.current
if (!session || !handleMessage) {
return
}
try {
setStatus("thinking")
await handleMessage.execute(session, message)
} catch (err) {
setError(err instanceof Error ? err : new Error(String(err)))
setStatus("error")
}
}, [])
const undo = useCallback(async (): Promise<boolean> => {
const { session, undoChange } = refs.current
if (!session || !undoChange) {
return false
}
try {
const result = await undoChange.execute(session)
if (result.success) {
forceUpdate()
return true
}
return false
} catch {
return false
}
}, [forceUpdate])
const clearHistory = useCallback(() => {
if (!refs.current.session) {
return
}
refs.current.session.clearHistory()
setMessages([])
forceUpdate()
}, [forceUpdate])
const abort = useCallback(() => {
refs.current.handleMessage?.abort()
setStatus("ready")
}, [])
return {
session: refs.current.session,
messages,
status,
isLoading,
error,
sendMessage,
undo,
clearHistory,
abort,
}
}

View File

@@ -0,0 +1,8 @@
/**
* TUI module - Terminal User Interface.
*/
export { App, type AppDependencies, type ExtendedAppProps } from "./App.js"
export * from "./components/index.js"
export * from "./hooks/index.js"
export * from "./types.js"

View File

@@ -0,0 +1,38 @@
/**
* TUI types and interfaces.
*/
import type { HandleMessageStatus } from "../application/use-cases/HandleMessage.js"
/**
* TUI status - maps to HandleMessageStatus.
*/
export type TuiStatus = HandleMessageStatus
/**
* Git branch information.
*/
export interface BranchInfo {
name: string
isDetached: boolean
}
/**
* Props for the main App component.
*/
export interface AppProps {
projectPath: string
autoApply?: boolean
model?: string
}
/**
* Status bar display data.
*/
export interface StatusBarData {
contextUsage: number
projectName: string
branch: BranchInfo
sessionTime: string
status: TuiStatus
}

View File

@@ -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)
})
})
})

View File

@@ -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()
})
})
})

View File

@@ -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)
})
})
})

View File

@@ -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()
})
})
})

View File

@@ -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()
})
})
})

View File

@@ -0,0 +1,390 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
import {
GitCommitTool,
type GitCommitResult,
} from "../../../../../src/infrastructure/tools/git/GitCommitTool.js"
import type { ToolContext } from "../../../../../src/domain/services/ITool.js"
import type { IStorage } from "../../../../../src/domain/services/IStorage.js"
import type { SimpleGit, CommitResult, StatusResult } from "simple-git"
function createMockStorage(): IStorage {
return {
getFile: vi.fn(),
setFile: vi.fn(),
deleteFile: vi.fn(),
getAllFiles: vi.fn().mockResolvedValue(new Map()),
getFileCount: vi.fn().mockResolvedValue(0),
getAST: vi.fn(),
setAST: vi.fn(),
deleteAST: vi.fn(),
getAllASTs: vi.fn().mockResolvedValue(new Map()),
getMeta: vi.fn(),
setMeta: vi.fn(),
deleteMeta: vi.fn(),
getAllMetas: vi.fn().mockResolvedValue(new Map()),
getSymbolIndex: vi.fn().mockResolvedValue(new Map()),
setSymbolIndex: vi.fn(),
getDepsGraph: vi.fn().mockResolvedValue({ imports: new Map(), importedBy: new Map() }),
setDepsGraph: vi.fn(),
getProjectConfig: vi.fn(),
setProjectConfig: vi.fn(),
connect: vi.fn(),
disconnect: vi.fn(),
isConnected: vi.fn().mockReturnValue(true),
clear: vi.fn(),
} as unknown as IStorage
}
function createMockContext(storage?: IStorage, confirmResult: boolean = true): ToolContext {
return {
projectRoot: "/test/project",
storage: storage ?? createMockStorage(),
requestConfirmation: vi.fn().mockResolvedValue(confirmResult),
onProgress: vi.fn(),
}
}
function createMockStatusResult(overrides: Partial<StatusResult> = {}): StatusResult {
return {
not_added: [],
conflicted: [],
created: [],
deleted: [],
ignored: [],
modified: [],
renamed: [],
files: [],
staged: ["file.ts"],
ahead: 0,
behind: 0,
current: "main",
tracking: "origin/main",
detached: false,
isClean: () => false,
...overrides,
} as StatusResult
}
function createMockCommitResult(overrides: Partial<CommitResult> = {}): CommitResult {
return {
commit: "abc1234",
branch: "main",
root: false,
author: null,
summary: {
changes: 1,
insertions: 5,
deletions: 2,
},
...overrides,
} as CommitResult
}
function createMockGit(options: {
isRepo?: boolean
status?: StatusResult
commitResult?: CommitResult
error?: Error
addError?: Error
}): SimpleGit {
const mockGit = {
checkIsRepo: vi.fn().mockResolvedValue(options.isRepo ?? true),
status: vi.fn().mockResolvedValue(options.status ?? createMockStatusResult()),
add: vi.fn(),
commit: vi.fn(),
}
if (options.addError) {
mockGit.add.mockRejectedValue(options.addError)
} else {
mockGit.add.mockResolvedValue(undefined)
}
if (options.error) {
mockGit.commit.mockRejectedValue(options.error)
} else {
mockGit.commit.mockResolvedValue(options.commitResult ?? createMockCommitResult())
}
return mockGit as unknown as SimpleGit
}
describe("GitCommitTool", () => {
let tool: GitCommitTool
beforeEach(() => {
tool = new GitCommitTool()
})
describe("metadata", () => {
it("should have correct name", () => {
expect(tool.name).toBe("git_commit")
})
it("should have correct category", () => {
expect(tool.category).toBe("git")
})
it("should require confirmation", () => {
expect(tool.requiresConfirmation).toBe(true)
})
it("should have correct parameters", () => {
expect(tool.parameters).toHaveLength(2)
expect(tool.parameters[0].name).toBe("message")
expect(tool.parameters[0].required).toBe(true)
expect(tool.parameters[1].name).toBe("files")
expect(tool.parameters[1].required).toBe(false)
})
it("should have description", () => {
expect(tool.description).toContain("commit")
expect(tool.description).toContain("confirmation")
})
})
describe("validateParams", () => {
it("should return error for missing message", () => {
expect(tool.validateParams({})).toContain("message")
expect(tool.validateParams({})).toContain("required")
})
it("should return error for non-string message", () => {
expect(tool.validateParams({ message: 123 })).toContain("message")
expect(tool.validateParams({ message: 123 })).toContain("string")
})
it("should return error for empty message", () => {
expect(tool.validateParams({ message: "" })).toContain("empty")
expect(tool.validateParams({ message: " " })).toContain("empty")
})
it("should return null for valid message", () => {
expect(tool.validateParams({ message: "fix: bug" })).toBeNull()
})
it("should return null for valid message with files", () => {
expect(tool.validateParams({ message: "fix: bug", files: ["a.ts", "b.ts"] })).toBeNull()
})
it("should return error for non-array files", () => {
expect(tool.validateParams({ message: "fix: bug", files: "a.ts" })).toContain("array")
})
it("should return error for non-string in files array", () => {
expect(tool.validateParams({ message: "fix: bug", files: [1, 2] })).toContain("strings")
})
})
describe("execute", () => {
describe("not a git repository", () => {
it("should return error when not in a git repo", async () => {
const mockGit = createMockGit({ isRepo: false })
const toolWithMock = new GitCommitTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute({ message: "test commit" }, ctx)
expect(result.success).toBe(false)
expect(result.error).toContain("Not a git repository")
})
})
describe("nothing to commit", () => {
it("should return error when no staged files", async () => {
const mockGit = createMockGit({
status: createMockStatusResult({ staged: [] }),
})
const toolWithMock = new GitCommitTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute({ message: "test commit" }, ctx)
expect(result.success).toBe(false)
expect(result.error).toContain("Nothing to commit")
})
})
describe("with staged files", () => {
it("should commit successfully", async () => {
const mockGit = createMockGit({
status: createMockStatusResult({ staged: ["file.ts"] }),
commitResult: createMockCommitResult({
commit: "def5678",
branch: "main",
summary: { changes: 1, insertions: 10, deletions: 3 },
}),
})
const toolWithMock = new GitCommitTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute({ message: "feat: new feature" }, ctx)
expect(result.success).toBe(true)
const data = result.data as GitCommitResult
expect(data.hash).toBe("def5678")
expect(data.branch).toBe("main")
expect(data.message).toBe("feat: new feature")
expect(data.filesChanged).toBe(1)
expect(data.insertions).toBe(10)
expect(data.deletions).toBe(3)
})
it("should include author when available", async () => {
const mockGit = createMockGit({
commitResult: createMockCommitResult({
author: {
name: "Test User",
email: "test@example.com",
},
}),
})
const toolWithMock = new GitCommitTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute({ message: "test commit" }, ctx)
expect(result.success).toBe(true)
const data = result.data as GitCommitResult
expect(data.author).toEqual({
name: "Test User",
email: "test@example.com",
})
})
})
describe("files parameter", () => {
it("should stage specified files before commit", async () => {
const mockGit = createMockGit({
status: createMockStatusResult({ staged: [] }),
})
const toolWithMock = new GitCommitTool(() => mockGit)
const ctx = createMockContext()
await toolWithMock.execute({ message: "test", files: ["a.ts", "b.ts"] }, ctx)
expect(mockGit.add).toHaveBeenCalledWith(["a.ts", "b.ts"])
})
it("should not call add when files is empty", async () => {
const mockGit = createMockGit({})
const toolWithMock = new GitCommitTool(() => mockGit)
const ctx = createMockContext()
await toolWithMock.execute({ message: "test", files: [] }, ctx)
expect(mockGit.add).not.toHaveBeenCalled()
})
it("should handle add errors", async () => {
const mockGit = createMockGit({
addError: new Error("Failed to add files"),
})
const toolWithMock = new GitCommitTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute(
{ message: "test", files: ["nonexistent.ts"] },
ctx,
)
expect(result.success).toBe(false)
expect(result.error).toContain("Failed to add files")
})
})
describe("confirmation", () => {
it("should request confirmation before commit", async () => {
const mockGit = createMockGit({})
const toolWithMock = new GitCommitTool(() => mockGit)
const ctx = createMockContext()
await toolWithMock.execute({ message: "test commit" }, ctx)
expect(ctx.requestConfirmation).toHaveBeenCalled()
const confirmMessage = (ctx.requestConfirmation as ReturnType<typeof vi.fn>).mock
.calls[0][0] as string
expect(confirmMessage).toContain("Committing")
expect(confirmMessage).toContain("test commit")
})
it("should cancel commit when user declines", async () => {
const mockGit = createMockGit({})
const toolWithMock = new GitCommitTool(() => mockGit)
const ctx = createMockContext(undefined, false)
const result = await toolWithMock.execute({ message: "test commit" }, ctx)
expect(result.success).toBe(false)
expect(result.error).toContain("cancelled")
expect(mockGit.commit).not.toHaveBeenCalled()
})
it("should proceed with commit when user confirms", async () => {
const mockGit = createMockGit({})
const toolWithMock = new GitCommitTool(() => mockGit)
const ctx = createMockContext(undefined, true)
const result = await toolWithMock.execute({ message: "test commit" }, ctx)
expect(result.success).toBe(true)
expect(mockGit.commit).toHaveBeenCalledWith("test commit")
})
})
describe("error handling", () => {
it("should handle git command errors", async () => {
const mockGit = createMockGit({
error: new Error("Git commit failed"),
})
const toolWithMock = new GitCommitTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute({ message: "test commit" }, ctx)
expect(result.success).toBe(false)
expect(result.error).toContain("Git commit failed")
})
it("should handle non-Error exceptions", async () => {
const mockGit = {
checkIsRepo: vi.fn().mockResolvedValue(true),
status: vi.fn().mockResolvedValue(createMockStatusResult()),
add: vi.fn(),
commit: vi.fn().mockRejectedValue("string error"),
} as unknown as SimpleGit
const toolWithMock = new GitCommitTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute({ message: "test commit" }, ctx)
expect(result.success).toBe(false)
expect(result.error).toBe("string error")
})
})
describe("timing", () => {
it("should return timing information", async () => {
const mockGit = createMockGit({})
const toolWithMock = new GitCommitTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute({ message: "test commit" }, ctx)
expect(result.executionTimeMs).toBeGreaterThanOrEqual(0)
})
})
describe("call id", () => {
it("should generate unique call id", async () => {
const mockGit = createMockGit({})
const toolWithMock = new GitCommitTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute({ message: "test commit" }, ctx)
expect(result.callId).toMatch(/^git_commit-\d+$/)
})
})
})
})

View File

@@ -0,0 +1,393 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
import {
GitDiffTool,
type GitDiffResult,
} from "../../../../../src/infrastructure/tools/git/GitDiffTool.js"
import type { ToolContext } from "../../../../../src/domain/services/ITool.js"
import type { IStorage } from "../../../../../src/domain/services/IStorage.js"
import type { SimpleGit, DiffResult } from "simple-git"
function createMockStorage(): IStorage {
return {
getFile: vi.fn(),
setFile: vi.fn(),
deleteFile: vi.fn(),
getAllFiles: vi.fn().mockResolvedValue(new Map()),
getFileCount: vi.fn().mockResolvedValue(0),
getAST: vi.fn(),
setAST: vi.fn(),
deleteAST: vi.fn(),
getAllASTs: vi.fn().mockResolvedValue(new Map()),
getMeta: vi.fn(),
setMeta: vi.fn(),
deleteMeta: vi.fn(),
getAllMetas: vi.fn().mockResolvedValue(new Map()),
getSymbolIndex: vi.fn().mockResolvedValue(new Map()),
setSymbolIndex: vi.fn(),
getDepsGraph: vi.fn().mockResolvedValue({ imports: new Map(), importedBy: new Map() }),
setDepsGraph: vi.fn(),
getProjectConfig: vi.fn(),
setProjectConfig: vi.fn(),
connect: vi.fn(),
disconnect: vi.fn(),
isConnected: vi.fn().mockReturnValue(true),
clear: vi.fn(),
} as unknown as IStorage
}
function createMockContext(storage?: IStorage): ToolContext {
return {
projectRoot: "/test/project",
storage: storage ?? createMockStorage(),
requestConfirmation: vi.fn().mockResolvedValue(true),
onProgress: vi.fn(),
}
}
function createMockDiffSummary(overrides: Partial<DiffResult> = {}): DiffResult {
return {
changed: 0,
deletions: 0,
insertions: 0,
files: [],
...overrides,
} as DiffResult
}
function createMockGit(options: {
isRepo?: boolean
diffSummary?: DiffResult
diff?: string
error?: Error
}): SimpleGit {
const mockGit = {
checkIsRepo: vi.fn().mockResolvedValue(options.isRepo ?? true),
diffSummary: vi.fn(),
diff: vi.fn(),
}
if (options.error) {
mockGit.diffSummary.mockRejectedValue(options.error)
} else {
mockGit.diffSummary.mockResolvedValue(options.diffSummary ?? createMockDiffSummary())
mockGit.diff.mockResolvedValue(options.diff ?? "")
}
return mockGit as unknown as SimpleGit
}
describe("GitDiffTool", () => {
let tool: GitDiffTool
beforeEach(() => {
tool = new GitDiffTool()
})
describe("metadata", () => {
it("should have correct name", () => {
expect(tool.name).toBe("git_diff")
})
it("should have correct category", () => {
expect(tool.category).toBe("git")
})
it("should not require confirmation", () => {
expect(tool.requiresConfirmation).toBe(false)
})
it("should have correct parameters", () => {
expect(tool.parameters).toHaveLength(2)
expect(tool.parameters[0].name).toBe("path")
expect(tool.parameters[0].required).toBe(false)
expect(tool.parameters[1].name).toBe("staged")
expect(tool.parameters[1].required).toBe(false)
})
it("should have description", () => {
expect(tool.description).toContain("diff")
expect(tool.description).toContain("changes")
})
})
describe("validateParams", () => {
it("should return null for empty params", () => {
expect(tool.validateParams({})).toBeNull()
})
it("should return null for valid path", () => {
expect(tool.validateParams({ path: "src" })).toBeNull()
})
it("should return null for valid staged", () => {
expect(tool.validateParams({ staged: true })).toBeNull()
expect(tool.validateParams({ staged: false })).toBeNull()
})
it("should return error for invalid path type", () => {
expect(tool.validateParams({ path: 123 })).toContain("path")
expect(tool.validateParams({ path: 123 })).toContain("string")
})
it("should return error for invalid staged type", () => {
expect(tool.validateParams({ staged: "yes" })).toContain("staged")
expect(tool.validateParams({ staged: "yes" })).toContain("boolean")
})
})
describe("execute", () => {
describe("not a git repository", () => {
it("should return error when not in a git repo", async () => {
const mockGit = createMockGit({ isRepo: false })
const toolWithMock = new GitDiffTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute({}, ctx)
expect(result.success).toBe(false)
expect(result.error).toContain("Not a git repository")
})
})
describe("no changes", () => {
it("should return empty diff for clean repo", async () => {
const mockGit = createMockGit({
diffSummary: createMockDiffSummary({ files: [] }),
diff: "",
})
const toolWithMock = new GitDiffTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute({}, ctx)
expect(result.success).toBe(true)
const data = result.data as GitDiffResult
expect(data.hasChanges).toBe(false)
expect(data.files).toHaveLength(0)
expect(data.diff).toBe("")
})
})
describe("with changes", () => {
it("should return diff for modified files", async () => {
const mockGit = createMockGit({
diffSummary: createMockDiffSummary({
files: [
{ file: "src/index.ts", insertions: 5, deletions: 2, binary: false },
],
insertions: 5,
deletions: 2,
}),
diff: "diff --git a/src/index.ts",
})
const toolWithMock = new GitDiffTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute({}, ctx)
expect(result.success).toBe(true)
const data = result.data as GitDiffResult
expect(data.hasChanges).toBe(true)
expect(data.files).toHaveLength(1)
expect(data.files[0].file).toBe("src/index.ts")
expect(data.files[0].insertions).toBe(5)
expect(data.files[0].deletions).toBe(2)
})
it("should return multiple files", async () => {
const mockGit = createMockGit({
diffSummary: createMockDiffSummary({
files: [
{ file: "a.ts", insertions: 1, deletions: 0, binary: false },
{ file: "b.ts", insertions: 2, deletions: 1, binary: false },
{ file: "c.ts", insertions: 0, deletions: 5, binary: false },
],
insertions: 3,
deletions: 6,
}),
})
const toolWithMock = new GitDiffTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute({}, ctx)
expect(result.success).toBe(true)
const data = result.data as GitDiffResult
expect(data.files).toHaveLength(3)
expect(data.summary.filesChanged).toBe(3)
expect(data.summary.insertions).toBe(3)
expect(data.summary.deletions).toBe(6)
})
it("should handle binary files", async () => {
const mockGit = createMockGit({
diffSummary: createMockDiffSummary({
files: [{ file: "image.png", insertions: 0, deletions: 0, binary: true }],
}),
})
const toolWithMock = new GitDiffTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute({}, ctx)
expect(result.success).toBe(true)
const data = result.data as GitDiffResult
expect(data.files[0].binary).toBe(true)
})
})
describe("staged parameter", () => {
it("should default to false (unstaged)", async () => {
const mockGit = createMockGit({})
const toolWithMock = new GitDiffTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute({}, ctx)
expect(result.success).toBe(true)
const data = result.data as GitDiffResult
expect(data.staged).toBe(false)
expect(mockGit.diffSummary).toHaveBeenCalledWith([])
})
it("should pass --cached for staged=true", async () => {
const mockGit = createMockGit({})
const toolWithMock = new GitDiffTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute({ staged: true }, ctx)
expect(result.success).toBe(true)
const data = result.data as GitDiffResult
expect(data.staged).toBe(true)
expect(mockGit.diffSummary).toHaveBeenCalledWith(["--cached"])
})
})
describe("path parameter", () => {
it("should filter by path", async () => {
const mockGit = createMockGit({})
const toolWithMock = new GitDiffTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute({ path: "src" }, ctx)
expect(result.success).toBe(true)
const data = result.data as GitDiffResult
expect(data.pathFilter).toBe("src")
expect(mockGit.diffSummary).toHaveBeenCalledWith(["--", "src"])
})
it("should combine staged and path", async () => {
const mockGit = createMockGit({})
const toolWithMock = new GitDiffTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute(
{ staged: true, path: "src/index.ts" },
ctx,
)
expect(result.success).toBe(true)
expect(mockGit.diffSummary).toHaveBeenCalledWith(["--cached", "--", "src/index.ts"])
})
it("should return null pathFilter when not provided", async () => {
const mockGit = createMockGit({})
const toolWithMock = new GitDiffTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute({}, ctx)
expect(result.success).toBe(true)
const data = result.data as GitDiffResult
expect(data.pathFilter).toBeNull()
})
})
describe("diff text", () => {
it("should include full diff text", async () => {
const diffText = `diff --git a/src/index.ts b/src/index.ts
index abc123..def456 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,3 +1,4 @@
+import { foo } from "./foo"
export function main() {
console.log("hello")
}`
const mockGit = createMockGit({
diffSummary: createMockDiffSummary({
files: [
{ file: "src/index.ts", insertions: 1, deletions: 0, binary: false },
],
}),
diff: diffText,
})
const toolWithMock = new GitDiffTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute({}, ctx)
expect(result.success).toBe(true)
const data = result.data as GitDiffResult
expect(data.diff).toBe(diffText)
expect(data.diff).toContain("diff --git")
expect(data.diff).toContain("import { foo }")
})
})
describe("error handling", () => {
it("should handle git command errors", async () => {
const mockGit = createMockGit({
error: new Error("Git command failed"),
})
const toolWithMock = new GitDiffTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute({}, ctx)
expect(result.success).toBe(false)
expect(result.error).toContain("Git command failed")
})
it("should handle non-Error exceptions", async () => {
const mockGit = {
checkIsRepo: vi.fn().mockResolvedValue(true),
diffSummary: vi.fn().mockRejectedValue("string error"),
} as unknown as SimpleGit
const toolWithMock = new GitDiffTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute({}, ctx)
expect(result.success).toBe(false)
expect(result.error).toBe("string error")
})
})
describe("timing", () => {
it("should return timing information", async () => {
const mockGit = createMockGit({})
const toolWithMock = new GitDiffTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute({}, ctx)
expect(result.executionTimeMs).toBeGreaterThanOrEqual(0)
})
})
describe("call id", () => {
it("should generate unique call id", async () => {
const mockGit = createMockGit({})
const toolWithMock = new GitDiffTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute({}, ctx)
expect(result.callId).toMatch(/^git_diff-\d+$/)
})
})
})
})

View File

@@ -0,0 +1,503 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
import {
GitStatusTool,
type GitStatusResult,
} from "../../../../../src/infrastructure/tools/git/GitStatusTool.js"
import type { ToolContext } from "../../../../../src/domain/services/ITool.js"
import type { IStorage } from "../../../../../src/domain/services/IStorage.js"
import type { SimpleGit, StatusResult } from "simple-git"
function createMockStorage(): IStorage {
return {
getFile: vi.fn(),
setFile: vi.fn(),
deleteFile: vi.fn(),
getAllFiles: vi.fn().mockResolvedValue(new Map()),
getFileCount: vi.fn().mockResolvedValue(0),
getAST: vi.fn(),
setAST: vi.fn(),
deleteAST: vi.fn(),
getAllASTs: vi.fn().mockResolvedValue(new Map()),
getMeta: vi.fn(),
setMeta: vi.fn(),
deleteMeta: vi.fn(),
getAllMetas: vi.fn().mockResolvedValue(new Map()),
getSymbolIndex: vi.fn().mockResolvedValue(new Map()),
setSymbolIndex: vi.fn(),
getDepsGraph: vi.fn().mockResolvedValue({ imports: new Map(), importedBy: new Map() }),
setDepsGraph: vi.fn(),
getProjectConfig: vi.fn(),
setProjectConfig: vi.fn(),
connect: vi.fn(),
disconnect: vi.fn(),
isConnected: vi.fn().mockReturnValue(true),
clear: vi.fn(),
} as unknown as IStorage
}
function createMockContext(storage?: IStorage): ToolContext {
return {
projectRoot: "/test/project",
storage: storage ?? createMockStorage(),
requestConfirmation: vi.fn().mockResolvedValue(true),
onProgress: vi.fn(),
}
}
function createMockStatusResult(overrides: Partial<StatusResult> = {}): StatusResult {
return {
not_added: [],
conflicted: [],
created: [],
deleted: [],
ignored: [],
modified: [],
renamed: [],
files: [],
staged: [],
ahead: 0,
behind: 0,
current: "main",
tracking: "origin/main",
detached: false,
isClean: () => true,
...overrides,
} as StatusResult
}
function createMockGit(options: {
isRepo?: boolean
status?: StatusResult
error?: Error
}): SimpleGit {
const mockGit = {
checkIsRepo: vi.fn().mockResolvedValue(options.isRepo ?? true),
status: vi.fn(),
}
if (options.error) {
mockGit.status.mockRejectedValue(options.error)
} else {
mockGit.status.mockResolvedValue(options.status ?? createMockStatusResult())
}
return mockGit as unknown as SimpleGit
}
describe("GitStatusTool", () => {
let tool: GitStatusTool
beforeEach(() => {
tool = new GitStatusTool()
})
describe("metadata", () => {
it("should have correct name", () => {
expect(tool.name).toBe("git_status")
})
it("should have correct category", () => {
expect(tool.category).toBe("git")
})
it("should not require confirmation", () => {
expect(tool.requiresConfirmation).toBe(false)
})
it("should have no parameters", () => {
expect(tool.parameters).toHaveLength(0)
})
it("should have description", () => {
expect(tool.description).toContain("git")
expect(tool.description).toContain("status")
})
})
describe("validateParams", () => {
it("should return null for empty params", () => {
expect(tool.validateParams({})).toBeNull()
})
it("should return null for any params (no required)", () => {
expect(tool.validateParams({ foo: "bar" })).toBeNull()
})
})
describe("execute", () => {
describe("not a git repository", () => {
it("should return error when not in a git repo", async () => {
const mockGit = createMockGit({ isRepo: false })
const toolWithMock = new GitStatusTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute({}, ctx)
expect(result.success).toBe(false)
expect(result.error).toContain("Not a git repository")
})
})
describe("clean repository", () => {
it("should return clean status", async () => {
const mockGit = createMockGit({
status: createMockStatusResult({
current: "main",
tracking: "origin/main",
ahead: 0,
behind: 0,
isClean: () => true,
}),
})
const toolWithMock = new GitStatusTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute({}, ctx)
expect(result.success).toBe(true)
const data = result.data as GitStatusResult
expect(data.branch).toBe("main")
expect(data.tracking).toBe("origin/main")
expect(data.isClean).toBe(true)
expect(data.staged).toHaveLength(0)
expect(data.modified).toHaveLength(0)
expect(data.untracked).toHaveLength(0)
})
})
describe("branch information", () => {
it("should return current branch name", async () => {
const mockGit = createMockGit({
status: createMockStatusResult({ current: "feature/test" }),
})
const toolWithMock = new GitStatusTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute({}, ctx)
expect(result.success).toBe(true)
const data = result.data as GitStatusResult
expect(data.branch).toBe("feature/test")
})
it("should handle detached HEAD", async () => {
const mockGit = createMockGit({
status: createMockStatusResult({ current: null }),
})
const toolWithMock = new GitStatusTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute({}, ctx)
expect(result.success).toBe(true)
const data = result.data as GitStatusResult
expect(data.branch).toBe("HEAD (detached)")
})
it("should return tracking branch when available", async () => {
const mockGit = createMockGit({
status: createMockStatusResult({ tracking: "origin/develop" }),
})
const toolWithMock = new GitStatusTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute({}, ctx)
expect(result.success).toBe(true)
const data = result.data as GitStatusResult
expect(data.tracking).toBe("origin/develop")
})
it("should handle no tracking branch", async () => {
const mockGit = createMockGit({
status: createMockStatusResult({ tracking: null }),
})
const toolWithMock = new GitStatusTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute({}, ctx)
expect(result.success).toBe(true)
const data = result.data as GitStatusResult
expect(data.tracking).toBeNull()
})
it("should return ahead/behind counts", async () => {
const mockGit = createMockGit({
status: createMockStatusResult({ ahead: 3, behind: 1 }),
})
const toolWithMock = new GitStatusTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute({}, ctx)
expect(result.success).toBe(true)
const data = result.data as GitStatusResult
expect(data.ahead).toBe(3)
expect(data.behind).toBe(1)
})
})
describe("staged files", () => {
it("should return staged files (new file)", async () => {
const mockGit = createMockGit({
status: createMockStatusResult({
files: [{ path: "new.ts", index: "A", working_dir: " " }],
isClean: () => false,
}),
})
const toolWithMock = new GitStatusTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute({}, ctx)
expect(result.success).toBe(true)
const data = result.data as GitStatusResult
expect(data.staged).toHaveLength(1)
expect(data.staged[0].path).toBe("new.ts")
expect(data.staged[0].index).toBe("A")
})
it("should return staged files (modified)", async () => {
const mockGit = createMockGit({
status: createMockStatusResult({
files: [{ path: "src/index.ts", index: "M", working_dir: " " }],
isClean: () => false,
}),
})
const toolWithMock = new GitStatusTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute({}, ctx)
expect(result.success).toBe(true)
const data = result.data as GitStatusResult
expect(data.staged).toHaveLength(1)
expect(data.staged[0].path).toBe("src/index.ts")
expect(data.staged[0].index).toBe("M")
})
it("should return staged files (deleted)", async () => {
const mockGit = createMockGit({
status: createMockStatusResult({
files: [{ path: "old.ts", index: "D", working_dir: " " }],
isClean: () => false,
}),
})
const toolWithMock = new GitStatusTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute({}, ctx)
expect(result.success).toBe(true)
const data = result.data as GitStatusResult
expect(data.staged).toHaveLength(1)
expect(data.staged[0].index).toBe("D")
})
it("should return multiple staged files", async () => {
const mockGit = createMockGit({
status: createMockStatusResult({
files: [
{ path: "a.ts", index: "A", working_dir: " " },
{ path: "b.ts", index: "M", working_dir: " " },
{ path: "c.ts", index: "D", working_dir: " " },
],
isClean: () => false,
}),
})
const toolWithMock = new GitStatusTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute({}, ctx)
expect(result.success).toBe(true)
const data = result.data as GitStatusResult
expect(data.staged).toHaveLength(3)
})
})
describe("modified files", () => {
it("should return modified unstaged files", async () => {
const mockGit = createMockGit({
status: createMockStatusResult({
files: [{ path: "src/app.ts", index: " ", working_dir: "M" }],
isClean: () => false,
}),
})
const toolWithMock = new GitStatusTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute({}, ctx)
expect(result.success).toBe(true)
const data = result.data as GitStatusResult
expect(data.modified).toHaveLength(1)
expect(data.modified[0].path).toBe("src/app.ts")
expect(data.modified[0].workingDir).toBe("M")
})
it("should return deleted unstaged files", async () => {
const mockGit = createMockGit({
status: createMockStatusResult({
files: [{ path: "deleted.ts", index: " ", working_dir: "D" }],
isClean: () => false,
}),
})
const toolWithMock = new GitStatusTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute({}, ctx)
expect(result.success).toBe(true)
const data = result.data as GitStatusResult
expect(data.modified).toHaveLength(1)
expect(data.modified[0].workingDir).toBe("D")
})
})
describe("untracked files", () => {
it("should return untracked files", async () => {
const mockGit = createMockGit({
status: createMockStatusResult({
not_added: ["new-file.ts", "another.js"],
isClean: () => false,
}),
})
const toolWithMock = new GitStatusTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute({}, ctx)
expect(result.success).toBe(true)
const data = result.data as GitStatusResult
expect(data.untracked).toContain("new-file.ts")
expect(data.untracked).toContain("another.js")
})
})
describe("conflicted files", () => {
it("should return conflicted files", async () => {
const mockGit = createMockGit({
status: createMockStatusResult({
conflicted: ["conflict.ts"],
isClean: () => false,
}),
})
const toolWithMock = new GitStatusTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute({}, ctx)
expect(result.success).toBe(true)
const data = result.data as GitStatusResult
expect(data.conflicted).toContain("conflict.ts")
})
})
describe("mixed status", () => {
it("should correctly categorize files with both staged and unstaged changes", async () => {
const mockGit = createMockGit({
status: createMockStatusResult({
files: [{ path: "both.ts", index: "M", working_dir: "M" }],
isClean: () => false,
}),
})
const toolWithMock = new GitStatusTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute({}, ctx)
expect(result.success).toBe(true)
const data = result.data as GitStatusResult
expect(data.staged).toHaveLength(1)
expect(data.modified).toHaveLength(1)
expect(data.staged[0].path).toBe("both.ts")
expect(data.modified[0].path).toBe("both.ts")
})
it("should not include untracked in staged/modified", async () => {
const mockGit = createMockGit({
status: createMockStatusResult({
files: [{ path: "new.ts", index: "?", working_dir: "?" }],
not_added: ["new.ts"],
isClean: () => false,
}),
})
const toolWithMock = new GitStatusTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute({}, ctx)
expect(result.success).toBe(true)
const data = result.data as GitStatusResult
expect(data.staged).toHaveLength(0)
expect(data.modified).toHaveLength(0)
expect(data.untracked).toContain("new.ts")
})
})
describe("error handling", () => {
it("should handle git command errors", async () => {
const mockGit = createMockGit({
error: new Error("Git command failed"),
})
const toolWithMock = new GitStatusTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute({}, ctx)
expect(result.success).toBe(false)
expect(result.error).toContain("Git command failed")
})
it("should handle non-Error exceptions", async () => {
const mockGit = {
checkIsRepo: vi.fn().mockResolvedValue(true),
status: vi.fn().mockRejectedValue("string error"),
} as unknown as SimpleGit
const toolWithMock = new GitStatusTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute({}, ctx)
expect(result.success).toBe(false)
expect(result.error).toBe("string error")
})
})
describe("timing", () => {
it("should return timing information", async () => {
const mockGit = createMockGit({})
const toolWithMock = new GitStatusTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute({}, ctx)
expect(result.executionTimeMs).toBeGreaterThanOrEqual(0)
})
it("should include timing on error", async () => {
const mockGit = createMockGit({ error: new Error("fail") })
const toolWithMock = new GitStatusTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute({}, ctx)
expect(result.executionTimeMs).toBeGreaterThanOrEqual(0)
})
})
describe("call id", () => {
it("should generate unique call id", async () => {
const mockGit = createMockGit({})
const toolWithMock = new GitStatusTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute({}, ctx)
expect(result.callId).toMatch(/^git_status-\d+$/)
})
})
})
})

View File

@@ -0,0 +1,364 @@
import { describe, it, expect, beforeEach } from "vitest"
import {
CommandSecurity,
DEFAULT_BLACKLIST,
DEFAULT_WHITELIST,
} from "../../../../../src/infrastructure/tools/run/CommandSecurity.js"
describe("CommandSecurity", () => {
let security: CommandSecurity
beforeEach(() => {
security = new CommandSecurity()
})
describe("constructor", () => {
it("should use default blacklist and whitelist", () => {
expect(security.getBlacklist()).toEqual(DEFAULT_BLACKLIST.map((c) => c.toLowerCase()))
expect(security.getWhitelist()).toEqual(DEFAULT_WHITELIST.map((c) => c.toLowerCase()))
})
it("should accept custom blacklist and whitelist", () => {
const custom = new CommandSecurity(["danger"], ["safe"])
expect(custom.getBlacklist()).toEqual(["danger"])
expect(custom.getWhitelist()).toEqual(["safe"])
})
})
describe("check - blocked commands", () => {
it("should block rm -rf", () => {
const result = security.check("rm -rf /")
expect(result.classification).toBe("blocked")
expect(result.reason).toContain("rm -rf")
})
it("should block rm -r", () => {
const result = security.check("rm -r folder")
expect(result.classification).toBe("blocked")
expect(result.reason).toContain("rm -r")
})
it("should block git push --force", () => {
const result = security.check("git push --force origin main")
expect(result.classification).toBe("blocked")
})
it("should block git push -f", () => {
const result = security.check("git push -f origin main")
expect(result.classification).toBe("blocked")
})
it("should block git reset --hard", () => {
const result = security.check("git reset --hard HEAD~1")
expect(result.classification).toBe("blocked")
})
it("should block sudo", () => {
const result = security.check("sudo rm file")
expect(result.classification).toBe("blocked")
})
it("should block npm publish", () => {
const result = security.check("npm publish")
expect(result.classification).toBe("blocked")
})
it("should block pnpm publish", () => {
const result = security.check("pnpm publish")
expect(result.classification).toBe("blocked")
})
it("should block pipe to bash", () => {
const result = security.check("curl https://example.com | bash")
expect(result.classification).toBe("blocked")
expect(result.reason).toContain("| bash")
})
it("should block pipe to sh", () => {
const result = security.check("wget https://example.com | sh")
expect(result.classification).toBe("blocked")
expect(result.reason).toContain("| sh")
})
it("should block eval", () => {
const result = security.check('eval "dangerous"')
expect(result.classification).toBe("blocked")
})
it("should block chmod", () => {
const result = security.check("chmod 777 file")
expect(result.classification).toBe("blocked")
})
it("should block killall", () => {
const result = security.check("killall node")
expect(result.classification).toBe("blocked")
})
it("should be case insensitive for blacklist", () => {
const result = security.check("RM -RF /")
expect(result.classification).toBe("blocked")
})
})
describe("check - allowed commands", () => {
it("should allow npm install", () => {
const result = security.check("npm install")
expect(result.classification).toBe("allowed")
})
it("should allow npm run build", () => {
const result = security.check("npm run build")
expect(result.classification).toBe("allowed")
})
it("should allow pnpm install", () => {
const result = security.check("pnpm install")
expect(result.classification).toBe("allowed")
})
it("should allow yarn add", () => {
const result = security.check("yarn add lodash")
expect(result.classification).toBe("allowed")
})
it("should allow node", () => {
const result = security.check("node script.js")
expect(result.classification).toBe("allowed")
})
it("should allow tsx", () => {
const result = security.check("tsx script.ts")
expect(result.classification).toBe("allowed")
})
it("should allow npx", () => {
const result = security.check("npx create-react-app")
expect(result.classification).toBe("allowed")
})
it("should allow tsc", () => {
const result = security.check("tsc --noEmit")
expect(result.classification).toBe("allowed")
})
it("should allow vitest", () => {
const result = security.check("vitest run")
expect(result.classification).toBe("allowed")
})
it("should allow jest", () => {
const result = security.check("jest --coverage")
expect(result.classification).toBe("allowed")
})
it("should allow eslint", () => {
const result = security.check("eslint src/")
expect(result.classification).toBe("allowed")
})
it("should allow prettier", () => {
const result = security.check("prettier --write .")
expect(result.classification).toBe("allowed")
})
it("should allow ls", () => {
const result = security.check("ls -la")
expect(result.classification).toBe("allowed")
})
it("should allow cat", () => {
const result = security.check("cat file.txt")
expect(result.classification).toBe("allowed")
})
it("should allow grep", () => {
const result = security.check("grep pattern file")
expect(result.classification).toBe("allowed")
})
it("should be case insensitive for whitelist", () => {
const result = security.check("NPM install")
expect(result.classification).toBe("allowed")
})
})
describe("check - git commands", () => {
it("should allow git status", () => {
const result = security.check("git status")
expect(result.classification).toBe("allowed")
})
it("should allow git log", () => {
const result = security.check("git log --oneline")
expect(result.classification).toBe("allowed")
})
it("should allow git diff", () => {
const result = security.check("git diff HEAD~1")
expect(result.classification).toBe("allowed")
})
it("should allow git branch", () => {
const result = security.check("git branch -a")
expect(result.classification).toBe("allowed")
})
it("should allow git fetch", () => {
const result = security.check("git fetch origin")
expect(result.classification).toBe("allowed")
})
it("should allow git pull", () => {
const result = security.check("git pull origin main")
expect(result.classification).toBe("allowed")
})
it("should allow git stash", () => {
const result = security.check("git stash")
expect(result.classification).toBe("allowed")
})
it("should require confirmation for git commit", () => {
const result = security.check("git commit -m 'message'")
expect(result.classification).toBe("requires_confirmation")
})
it("should require confirmation for git push (without force)", () => {
const result = security.check("git push origin main")
expect(result.classification).toBe("requires_confirmation")
})
it("should require confirmation for git checkout", () => {
const result = security.check("git checkout -b new-branch")
expect(result.classification).toBe("requires_confirmation")
})
it("should require confirmation for git merge", () => {
const result = security.check("git merge feature")
expect(result.classification).toBe("requires_confirmation")
})
it("should require confirmation for git rebase", () => {
const result = security.check("git rebase main")
expect(result.classification).toBe("requires_confirmation")
})
it("should require confirmation for git without subcommand", () => {
const result = security.check("git")
expect(result.classification).toBe("requires_confirmation")
})
})
describe("check - requires confirmation", () => {
it("should require confirmation for unknown commands", () => {
const result = security.check("unknown-command")
expect(result.classification).toBe("requires_confirmation")
expect(result.reason).toContain("not in the whitelist")
})
it("should require confirmation for curl (without pipe)", () => {
const result = security.check("curl https://example.com")
expect(result.classification).toBe("requires_confirmation")
})
it("should require confirmation for wget (without pipe)", () => {
const result = security.check("wget https://example.com")
expect(result.classification).toBe("requires_confirmation")
})
it("should require confirmation for mkdir", () => {
const result = security.check("mkdir new-folder")
expect(result.classification).toBe("requires_confirmation")
})
it("should require confirmation for touch", () => {
const result = security.check("touch new-file.txt")
expect(result.classification).toBe("requires_confirmation")
})
it("should require confirmation for cp", () => {
const result = security.check("cp file1 file2")
expect(result.classification).toBe("requires_confirmation")
})
it("should require confirmation for mv", () => {
const result = security.check("mv file1 file2")
expect(result.classification).toBe("requires_confirmation")
})
})
describe("addToBlacklist", () => {
it("should add patterns to blacklist", () => {
security.addToBlacklist(["danger"])
expect(security.getBlacklist()).toContain("danger")
})
it("should not add duplicates", () => {
const initialLength = security.getBlacklist().length
security.addToBlacklist(["rm -rf"])
expect(security.getBlacklist().length).toBe(initialLength)
})
it("should normalize to lowercase", () => {
security.addToBlacklist(["DANGER"])
expect(security.getBlacklist()).toContain("danger")
})
})
describe("addToWhitelist", () => {
it("should add commands to whitelist", () => {
security.addToWhitelist(["mycommand"])
expect(security.getWhitelist()).toContain("mycommand")
})
it("should not add duplicates", () => {
const initialLength = security.getWhitelist().length
security.addToWhitelist(["npm"])
expect(security.getWhitelist().length).toBe(initialLength)
})
it("should normalize to lowercase", () => {
security.addToWhitelist(["MYCOMMAND"])
expect(security.getWhitelist()).toContain("mycommand")
})
it("should allow newly added commands", () => {
security.addToWhitelist(["mycommand"])
const result = security.check("mycommand arg1 arg2")
expect(result.classification).toBe("allowed")
})
})
describe("edge cases", () => {
it("should handle empty command", () => {
const result = security.check("")
expect(result.classification).toBe("requires_confirmation")
})
it("should handle whitespace-only command", () => {
const result = security.check(" ")
expect(result.classification).toBe("requires_confirmation")
})
it("should handle command with leading/trailing whitespace", () => {
const result = security.check(" npm install ")
expect(result.classification).toBe("allowed")
})
it("should handle command with multiple spaces", () => {
const result = security.check("npm install lodash")
expect(result.classification).toBe("allowed")
})
it("should detect blocked pattern anywhere in command", () => {
const result = security.check("echo test && rm -rf /")
expect(result.classification).toBe("blocked")
})
it("should detect blocked pattern in subshell", () => {
const result = security.check("$(rm -rf /)")
expect(result.classification).toBe("blocked")
})
})
})

View File

@@ -0,0 +1,473 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
import {
RunCommandTool,
type RunCommandResult,
} from "../../../../../src/infrastructure/tools/run/RunCommandTool.js"
import { CommandSecurity } from "../../../../../src/infrastructure/tools/run/CommandSecurity.js"
import type { ToolContext } from "../../../../../src/domain/services/ITool.js"
import type { IStorage } from "../../../../../src/domain/services/IStorage.js"
function createMockStorage(): IStorage {
return {
getFile: vi.fn(),
setFile: vi.fn(),
deleteFile: vi.fn(),
getAllFiles: vi.fn().mockResolvedValue(new Map()),
getFileCount: vi.fn().mockResolvedValue(0),
getAST: vi.fn(),
setAST: vi.fn(),
deleteAST: vi.fn(),
getAllASTs: vi.fn().mockResolvedValue(new Map()),
getMeta: vi.fn(),
setMeta: vi.fn(),
deleteMeta: vi.fn(),
getAllMetas: vi.fn().mockResolvedValue(new Map()),
getSymbolIndex: vi.fn().mockResolvedValue(new Map()),
setSymbolIndex: vi.fn(),
getDepsGraph: vi.fn().mockResolvedValue({ imports: new Map(), importedBy: new Map() }),
setDepsGraph: vi.fn(),
getProjectConfig: vi.fn(),
setProjectConfig: vi.fn(),
connect: vi.fn(),
disconnect: vi.fn(),
isConnected: vi.fn().mockReturnValue(true),
clear: vi.fn(),
} as unknown as IStorage
}
function createMockContext(storage?: IStorage, confirmResult: boolean = true): ToolContext {
return {
projectRoot: "/test/project",
storage: storage ?? createMockStorage(),
requestConfirmation: vi.fn().mockResolvedValue(confirmResult),
onProgress: vi.fn(),
}
}
type ExecResult = { stdout: string; stderr: string }
type ExecFn = (command: string, options: Record<string, unknown>) => Promise<ExecResult>
function createMockExec(options: {
stdout?: string
stderr?: string
error?: Error & { code?: number; stdout?: string; stderr?: string }
}): ExecFn {
return vi.fn().mockImplementation(() => {
if (options.error) {
return Promise.reject(options.error)
}
return Promise.resolve({
stdout: options.stdout ?? "",
stderr: options.stderr ?? "",
})
})
}
describe("RunCommandTool", () => {
let tool: RunCommandTool
beforeEach(() => {
tool = new RunCommandTool()
})
describe("metadata", () => {
it("should have correct name", () => {
expect(tool.name).toBe("run_command")
})
it("should have correct category", () => {
expect(tool.category).toBe("run")
})
it("should not require confirmation (handled internally)", () => {
expect(tool.requiresConfirmation).toBe(false)
})
it("should have correct parameters", () => {
expect(tool.parameters).toHaveLength(2)
expect(tool.parameters[0].name).toBe("command")
expect(tool.parameters[0].required).toBe(true)
expect(tool.parameters[1].name).toBe("timeout")
expect(tool.parameters[1].required).toBe(false)
})
it("should have description", () => {
expect(tool.description).toContain("shell command")
expect(tool.description).toContain("security")
})
})
describe("validateParams", () => {
it("should return error for missing command", () => {
expect(tool.validateParams({})).toContain("command")
expect(tool.validateParams({})).toContain("required")
})
it("should return error for non-string command", () => {
expect(tool.validateParams({ command: 123 })).toContain("string")
})
it("should return error for empty command", () => {
expect(tool.validateParams({ command: "" })).toContain("empty")
expect(tool.validateParams({ command: " " })).toContain("empty")
})
it("should return null for valid command", () => {
expect(tool.validateParams({ command: "ls" })).toBeNull()
})
it("should return error for non-number timeout", () => {
expect(tool.validateParams({ command: "ls", timeout: "5000" })).toContain("number")
})
it("should return error for negative timeout", () => {
expect(tool.validateParams({ command: "ls", timeout: -1 })).toContain("positive")
})
it("should return error for zero timeout", () => {
expect(tool.validateParams({ command: "ls", timeout: 0 })).toContain("positive")
})
it("should return error for timeout > 10 minutes", () => {
expect(tool.validateParams({ command: "ls", timeout: 600001 })).toContain("600000")
})
it("should return null for valid timeout", () => {
expect(tool.validateParams({ command: "ls", timeout: 5000 })).toBeNull()
})
})
describe("execute - blocked commands", () => {
it("should block dangerous commands", async () => {
const execFn = createMockExec({})
const toolWithMock = new RunCommandTool(undefined, execFn)
const ctx = createMockContext()
const result = await toolWithMock.execute({ command: "rm -rf /" }, ctx)
expect(result.success).toBe(false)
expect(result.error).toContain("blocked")
expect(execFn).not.toHaveBeenCalled()
})
it("should block sudo commands", async () => {
const execFn = createMockExec({})
const toolWithMock = new RunCommandTool(undefined, execFn)
const ctx = createMockContext()
const result = await toolWithMock.execute({ command: "sudo apt-get" }, ctx)
expect(result.success).toBe(false)
expect(result.error).toContain("blocked")
})
it("should block git push --force", async () => {
const execFn = createMockExec({})
const toolWithMock = new RunCommandTool(undefined, execFn)
const ctx = createMockContext()
const result = await toolWithMock.execute({ command: "git push --force" }, ctx)
expect(result.success).toBe(false)
expect(result.error).toContain("blocked")
})
})
describe("execute - allowed commands", () => {
it("should execute whitelisted commands without confirmation", async () => {
const execFn = createMockExec({ stdout: "output" })
const toolWithMock = new RunCommandTool(undefined, execFn)
const ctx = createMockContext()
const result = await toolWithMock.execute({ command: "npm install" }, ctx)
expect(result.success).toBe(true)
expect(ctx.requestConfirmation).not.toHaveBeenCalled()
expect(execFn).toHaveBeenCalled()
})
it("should return stdout and stderr", async () => {
const execFn = createMockExec({
stdout: "standard output",
stderr: "standard error",
})
const toolWithMock = new RunCommandTool(undefined, execFn)
const ctx = createMockContext()
const result = await toolWithMock.execute({ command: "npm run build" }, ctx)
expect(result.success).toBe(true)
const data = result.data as RunCommandResult
expect(data.stdout).toBe("standard output")
expect(data.stderr).toBe("standard error")
expect(data.exitCode).toBe(0)
expect(data.success).toBe(true)
})
it("should mark requiredConfirmation as false", async () => {
const execFn = createMockExec({})
const toolWithMock = new RunCommandTool(undefined, execFn)
const ctx = createMockContext()
const result = await toolWithMock.execute({ command: "ls" }, ctx)
expect(result.success).toBe(true)
const data = result.data as RunCommandResult
expect(data.requiredConfirmation).toBe(false)
})
})
describe("execute - requires confirmation", () => {
it("should request confirmation for unknown commands", async () => {
const execFn = createMockExec({})
const toolWithMock = new RunCommandTool(undefined, execFn)
const ctx = createMockContext()
await toolWithMock.execute({ command: "unknown-command" }, ctx)
expect(ctx.requestConfirmation).toHaveBeenCalled()
})
it("should execute after confirmation", async () => {
const execFn = createMockExec({ stdout: "done" })
const toolWithMock = new RunCommandTool(undefined, execFn)
const ctx = createMockContext(undefined, true)
const result = await toolWithMock.execute({ command: "custom-script" }, ctx)
expect(result.success).toBe(true)
const data = result.data as RunCommandResult
expect(data.requiredConfirmation).toBe(true)
expect(execFn).toHaveBeenCalled()
})
it("should cancel when user declines", async () => {
const execFn = createMockExec({})
const toolWithMock = new RunCommandTool(undefined, execFn)
const ctx = createMockContext(undefined, false)
const result = await toolWithMock.execute({ command: "custom-script" }, ctx)
expect(result.success).toBe(false)
expect(result.error).toContain("cancelled")
expect(execFn).not.toHaveBeenCalled()
})
it("should require confirmation for git commit", async () => {
const execFn = createMockExec({})
const toolWithMock = new RunCommandTool(undefined, execFn)
const ctx = createMockContext()
await toolWithMock.execute({ command: "git commit -m 'test'" }, ctx)
expect(ctx.requestConfirmation).toHaveBeenCalled()
})
})
describe("execute - error handling", () => {
it("should handle command failure with exit code", async () => {
const error = Object.assign(new Error("Command failed"), {
code: 1,
stdout: "partial output",
stderr: "error message",
})
const execFn = createMockExec({ error })
const toolWithMock = new RunCommandTool(undefined, execFn)
const ctx = createMockContext()
const result = await toolWithMock.execute({ command: "npm test" }, ctx)
expect(result.success).toBe(true)
const data = result.data as RunCommandResult
expect(data.success).toBe(false)
expect(data.exitCode).toBe(1)
expect(data.stdout).toBe("partial output")
expect(data.stderr).toBe("error message")
})
it("should handle timeout", async () => {
const error = new Error("Command timed out")
const execFn = createMockExec({ error })
const toolWithMock = new RunCommandTool(undefined, execFn)
const ctx = createMockContext()
const result = await toolWithMock.execute({ command: "ls" }, ctx)
expect(result.success).toBe(false)
expect(result.error).toContain("timed out")
})
it("should handle ETIMEDOUT", async () => {
const error = new Error("ETIMEDOUT")
const execFn = createMockExec({ error })
const toolWithMock = new RunCommandTool(undefined, execFn)
const ctx = createMockContext()
const result = await toolWithMock.execute({ command: "ls" }, ctx)
expect(result.success).toBe(false)
expect(result.error).toContain("timed out")
})
it("should handle generic errors", async () => {
const error = new Error("Something went wrong")
const execFn = createMockExec({ error })
const toolWithMock = new RunCommandTool(undefined, execFn)
const ctx = createMockContext()
const result = await toolWithMock.execute({ command: "ls" }, ctx)
expect(result.success).toBe(false)
expect(result.error).toBe("Something went wrong")
})
it("should handle non-Error exceptions", async () => {
const execFn = vi.fn().mockRejectedValue("string error")
const toolWithMock = new RunCommandTool(undefined, execFn)
const ctx = createMockContext()
const result = await toolWithMock.execute({ command: "ls" }, ctx)
expect(result.success).toBe(false)
expect(result.error).toBe("string error")
})
})
describe("execute - options", () => {
it("should use default timeout", async () => {
const execFn = createMockExec({})
const toolWithMock = new RunCommandTool(undefined, execFn)
const ctx = createMockContext()
await toolWithMock.execute({ command: "ls" }, ctx)
expect(execFn).toHaveBeenCalledWith("ls", expect.objectContaining({ timeout: 30000 }))
})
it("should use custom timeout", async () => {
const execFn = createMockExec({})
const toolWithMock = new RunCommandTool(undefined, execFn)
const ctx = createMockContext()
await toolWithMock.execute({ command: "ls", timeout: 5000 }, ctx)
expect(execFn).toHaveBeenCalledWith("ls", expect.objectContaining({ timeout: 5000 }))
})
it("should execute in project root", async () => {
const execFn = createMockExec({})
const toolWithMock = new RunCommandTool(undefined, execFn)
const ctx = createMockContext()
ctx.projectRoot = "/my/project"
await toolWithMock.execute({ command: "ls" }, ctx)
expect(execFn).toHaveBeenCalledWith(
"ls",
expect.objectContaining({ cwd: "/my/project" }),
)
})
it("should disable colors", async () => {
const execFn = createMockExec({})
const toolWithMock = new RunCommandTool(undefined, execFn)
const ctx = createMockContext()
await toolWithMock.execute({ command: "ls" }, ctx)
expect(execFn).toHaveBeenCalledWith(
"ls",
expect.objectContaining({
env: expect.objectContaining({ FORCE_COLOR: "0" }),
}),
)
})
})
describe("execute - output truncation", () => {
it("should truncate very long output", async () => {
const longOutput = "x".repeat(200000)
const execFn = createMockExec({ stdout: longOutput })
const toolWithMock = new RunCommandTool(undefined, execFn)
const ctx = createMockContext()
const result = await toolWithMock.execute({ command: "ls" }, ctx)
expect(result.success).toBe(true)
const data = result.data as RunCommandResult
expect(data.stdout.length).toBeLessThan(longOutput.length)
expect(data.stdout).toContain("truncated")
})
it("should not truncate normal output", async () => {
const normalOutput = "normal output"
const execFn = createMockExec({ stdout: normalOutput })
const toolWithMock = new RunCommandTool(undefined, execFn)
const ctx = createMockContext()
const result = await toolWithMock.execute({ command: "ls" }, ctx)
expect(result.success).toBe(true)
const data = result.data as RunCommandResult
expect(data.stdout).toBe(normalOutput)
})
})
describe("execute - timing", () => {
it("should return execution time", async () => {
const execFn = createMockExec({})
const toolWithMock = new RunCommandTool(undefined, execFn)
const ctx = createMockContext()
const result = await toolWithMock.execute({ command: "ls" }, ctx)
expect(result.success).toBe(true)
const data = result.data as RunCommandResult
expect(data.durationMs).toBeGreaterThanOrEqual(0)
})
it("should return execution time ms in result", async () => {
const execFn = createMockExec({})
const toolWithMock = new RunCommandTool(undefined, execFn)
const ctx = createMockContext()
const result = await toolWithMock.execute({ command: "ls" }, ctx)
expect(result.executionTimeMs).toBeGreaterThanOrEqual(0)
})
})
describe("execute - call id", () => {
it("should generate unique call id", async () => {
const execFn = createMockExec({})
const toolWithMock = new RunCommandTool(undefined, execFn)
const ctx = createMockContext()
const result = await toolWithMock.execute({ command: "ls" }, ctx)
expect(result.callId).toMatch(/^run_command-\d+$/)
})
})
describe("getSecurity", () => {
it("should return security instance", () => {
const security = new CommandSecurity()
const toolWithSecurity = new RunCommandTool(security)
expect(toolWithSecurity.getSecurity()).toBe(security)
})
it("should allow modifying security", async () => {
const execFn = createMockExec({})
const toolWithMock = new RunCommandTool(undefined, execFn)
const ctx = createMockContext()
toolWithMock.getSecurity().addToWhitelist(["custom-safe"])
const result = await toolWithMock.execute({ command: "custom-safe arg" }, ctx)
expect(result.success).toBe(true)
expect(ctx.requestConfirmation).not.toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,547 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
import {
RunTestsTool,
type RunTestsResult,
type TestRunner,
} from "../../../../../src/infrastructure/tools/run/RunTestsTool.js"
import type { ToolContext } from "../../../../../src/domain/services/ITool.js"
import type { IStorage } from "../../../../../src/domain/services/IStorage.js"
function createMockStorage(): IStorage {
return {
getFile: vi.fn(),
setFile: vi.fn(),
deleteFile: vi.fn(),
getAllFiles: vi.fn().mockResolvedValue(new Map()),
getFileCount: vi.fn().mockResolvedValue(0),
getAST: vi.fn(),
setAST: vi.fn(),
deleteAST: vi.fn(),
getAllASTs: vi.fn().mockResolvedValue(new Map()),
getMeta: vi.fn(),
setMeta: vi.fn(),
deleteMeta: vi.fn(),
getAllMetas: vi.fn().mockResolvedValue(new Map()),
getSymbolIndex: vi.fn().mockResolvedValue(new Map()),
setSymbolIndex: vi.fn(),
getDepsGraph: vi.fn().mockResolvedValue({ imports: new Map(), importedBy: new Map() }),
setDepsGraph: vi.fn(),
getProjectConfig: vi.fn(),
setProjectConfig: vi.fn(),
connect: vi.fn(),
disconnect: vi.fn(),
isConnected: vi.fn().mockReturnValue(true),
clear: vi.fn(),
} as unknown as IStorage
}
function createMockContext(storage?: IStorage): ToolContext {
return {
projectRoot: "/test/project",
storage: storage ?? createMockStorage(),
requestConfirmation: vi.fn().mockResolvedValue(true),
onProgress: vi.fn(),
}
}
type ExecResult = { stdout: string; stderr: string }
type ExecFn = (command: string, options: Record<string, unknown>) => Promise<ExecResult>
function createMockExec(options: {
stdout?: string
stderr?: string
error?: Error & { code?: number; stdout?: string; stderr?: string }
}): ExecFn {
return vi.fn().mockImplementation(() => {
if (options.error) {
return Promise.reject(options.error)
}
return Promise.resolve({
stdout: options.stdout ?? "",
stderr: options.stderr ?? "",
})
})
}
function createMockFsAccess(existingFiles: string[]): typeof import("fs/promises").access {
return vi.fn().mockImplementation((filePath: string) => {
for (const file of existingFiles) {
if (filePath.endsWith(file)) {
return Promise.resolve()
}
}
return Promise.reject(new Error("ENOENT"))
})
}
function createMockFsReadFile(
packageJson?: Record<string, unknown>,
): typeof import("fs/promises").readFile {
return vi.fn().mockImplementation((filePath: string) => {
if (filePath.endsWith("package.json") && packageJson) {
return Promise.resolve(JSON.stringify(packageJson))
}
return Promise.reject(new Error("ENOENT"))
})
}
describe("RunTestsTool", () => {
let tool: RunTestsTool
beforeEach(() => {
tool = new RunTestsTool()
})
describe("metadata", () => {
it("should have correct name", () => {
expect(tool.name).toBe("run_tests")
})
it("should have correct category", () => {
expect(tool.category).toBe("run")
})
it("should not require confirmation", () => {
expect(tool.requiresConfirmation).toBe(false)
})
it("should have correct parameters", () => {
expect(tool.parameters).toHaveLength(3)
expect(tool.parameters[0].name).toBe("path")
expect(tool.parameters[1].name).toBe("filter")
expect(tool.parameters[2].name).toBe("watch")
})
it("should have description", () => {
expect(tool.description).toContain("test")
expect(tool.description).toContain("vitest")
})
})
describe("validateParams", () => {
it("should return null for empty params", () => {
expect(tool.validateParams({})).toBeNull()
})
it("should return null for valid params", () => {
expect(tool.validateParams({ path: "src", filter: "login", watch: true })).toBeNull()
})
it("should return error for invalid path", () => {
expect(tool.validateParams({ path: 123 })).toContain("path")
})
it("should return error for invalid filter", () => {
expect(tool.validateParams({ filter: 123 })).toContain("filter")
})
it("should return error for invalid watch", () => {
expect(tool.validateParams({ watch: "yes" })).toContain("watch")
})
})
describe("detectTestRunner", () => {
it("should detect vitest from config file", async () => {
const fsAccess = createMockFsAccess(["vitest.config.ts"])
const fsReadFile = createMockFsReadFile()
const toolWithMocks = new RunTestsTool(undefined, fsAccess, fsReadFile)
const runner = await toolWithMocks.detectTestRunner("/test/project")
expect(runner).toBe("vitest")
})
it("should detect vitest from .js config", async () => {
const fsAccess = createMockFsAccess(["vitest.config.js"])
const fsReadFile = createMockFsReadFile()
const toolWithMocks = new RunTestsTool(undefined, fsAccess, fsReadFile)
const runner = await toolWithMocks.detectTestRunner("/test/project")
expect(runner).toBe("vitest")
})
it("should detect vitest from .mts config", async () => {
const fsAccess = createMockFsAccess(["vitest.config.mts"])
const fsReadFile = createMockFsReadFile()
const toolWithMocks = new RunTestsTool(undefined, fsAccess, fsReadFile)
const runner = await toolWithMocks.detectTestRunner("/test/project")
expect(runner).toBe("vitest")
})
it("should detect jest from config file", async () => {
const fsAccess = createMockFsAccess(["jest.config.js"])
const fsReadFile = createMockFsReadFile()
const toolWithMocks = new RunTestsTool(undefined, fsAccess, fsReadFile)
const runner = await toolWithMocks.detectTestRunner("/test/project")
expect(runner).toBe("jest")
})
it("should detect vitest from devDependencies", async () => {
const fsAccess = createMockFsAccess([])
const fsReadFile = createMockFsReadFile({
devDependencies: { vitest: "^1.0.0" },
})
const toolWithMocks = new RunTestsTool(undefined, fsAccess, fsReadFile)
const runner = await toolWithMocks.detectTestRunner("/test/project")
expect(runner).toBe("vitest")
})
it("should detect jest from devDependencies", async () => {
const fsAccess = createMockFsAccess([])
const fsReadFile = createMockFsReadFile({
devDependencies: { jest: "^29.0.0" },
})
const toolWithMocks = new RunTestsTool(undefined, fsAccess, fsReadFile)
const runner = await toolWithMocks.detectTestRunner("/test/project")
expect(runner).toBe("jest")
})
it("should detect mocha from devDependencies", async () => {
const fsAccess = createMockFsAccess([])
const fsReadFile = createMockFsReadFile({
devDependencies: { mocha: "^10.0.0" },
})
const toolWithMocks = new RunTestsTool(undefined, fsAccess, fsReadFile)
const runner = await toolWithMocks.detectTestRunner("/test/project")
expect(runner).toBe("mocha")
})
it("should detect npm test script as fallback", async () => {
const fsAccess = createMockFsAccess([])
const fsReadFile = createMockFsReadFile({
scripts: { test: "node test.js" },
})
const toolWithMocks = new RunTestsTool(undefined, fsAccess, fsReadFile)
const runner = await toolWithMocks.detectTestRunner("/test/project")
expect(runner).toBe("npm")
})
it("should return null when no runner found", async () => {
const fsAccess = createMockFsAccess([])
const fsReadFile = createMockFsReadFile({})
const toolWithMocks = new RunTestsTool(undefined, fsAccess, fsReadFile)
const runner = await toolWithMocks.detectTestRunner("/test/project")
expect(runner).toBeNull()
})
})
describe("buildCommand", () => {
describe("vitest", () => {
it("should build basic vitest command", () => {
const cmd = tool.buildCommand("vitest")
expect(cmd).toBe("npx vitest run")
})
it("should build vitest with path", () => {
const cmd = tool.buildCommand("vitest", "src/tests")
expect(cmd).toBe("npx vitest run src/tests")
})
it("should build vitest with filter", () => {
const cmd = tool.buildCommand("vitest", undefined, "login")
expect(cmd).toBe('npx vitest run -t "login"')
})
it("should build vitest with watch", () => {
const cmd = tool.buildCommand("vitest", undefined, undefined, true)
expect(cmd).toBe("npx vitest")
})
it("should build vitest with all options", () => {
const cmd = tool.buildCommand("vitest", "src", "login", true)
expect(cmd).toBe('npx vitest src -t "login"')
})
})
describe("jest", () => {
it("should build basic jest command", () => {
const cmd = tool.buildCommand("jest")
expect(cmd).toBe("npx jest")
})
it("should build jest with path", () => {
const cmd = tool.buildCommand("jest", "src/tests")
expect(cmd).toBe("npx jest src/tests")
})
it("should build jest with filter", () => {
const cmd = tool.buildCommand("jest", undefined, "login")
expect(cmd).toBe('npx jest -t "login"')
})
it("should build jest with watch", () => {
const cmd = tool.buildCommand("jest", undefined, undefined, true)
expect(cmd).toBe("npx jest --watch")
})
})
describe("mocha", () => {
it("should build basic mocha command", () => {
const cmd = tool.buildCommand("mocha")
expect(cmd).toBe("npx mocha")
})
it("should build mocha with path", () => {
const cmd = tool.buildCommand("mocha", "test/")
expect(cmd).toBe("npx mocha test/")
})
it("should build mocha with filter", () => {
const cmd = tool.buildCommand("mocha", undefined, "login")
expect(cmd).toBe('npx mocha --grep "login"')
})
it("should build mocha with watch", () => {
const cmd = tool.buildCommand("mocha", undefined, undefined, true)
expect(cmd).toBe("npx mocha --watch")
})
})
describe("npm", () => {
it("should build basic npm test command", () => {
const cmd = tool.buildCommand("npm")
expect(cmd).toBe("npm test")
})
it("should build npm test with path", () => {
const cmd = tool.buildCommand("npm", "src/tests")
expect(cmd).toBe("npm test -- src/tests")
})
it("should build npm test with filter", () => {
const cmd = tool.buildCommand("npm", undefined, "login")
expect(cmd).toBe('npm test -- "login"')
})
})
})
describe("execute", () => {
describe("no runner detected", () => {
it("should return error when no runner found", async () => {
const execFn = createMockExec({})
const fsAccess = createMockFsAccess([])
const fsReadFile = createMockFsReadFile({})
const toolWithMocks = new RunTestsTool(execFn, fsAccess, fsReadFile)
const ctx = createMockContext()
const result = await toolWithMocks.execute({}, ctx)
expect(result.success).toBe(false)
expect(result.error).toContain("No test runner detected")
})
})
describe("successful tests", () => {
it("should return success when tests pass", async () => {
const execFn = createMockExec({
stdout: "All tests passed",
stderr: "",
})
const fsAccess = createMockFsAccess(["vitest.config.ts"])
const fsReadFile = createMockFsReadFile()
const toolWithMocks = new RunTestsTool(execFn, fsAccess, fsReadFile)
const ctx = createMockContext()
const result = await toolWithMocks.execute({}, ctx)
expect(result.success).toBe(true)
const data = result.data as RunTestsResult
expect(data.passed).toBe(true)
expect(data.exitCode).toBe(0)
expect(data.runner).toBe("vitest")
expect(data.stdout).toContain("All tests passed")
})
it("should include command in result", async () => {
const execFn = createMockExec({})
const fsAccess = createMockFsAccess(["vitest.config.ts"])
const fsReadFile = createMockFsReadFile()
const toolWithMocks = new RunTestsTool(execFn, fsAccess, fsReadFile)
const ctx = createMockContext()
const result = await toolWithMocks.execute({}, ctx)
expect(result.success).toBe(true)
const data = result.data as RunTestsResult
expect(data.command).toBe("npx vitest run")
})
it("should include duration in result", async () => {
const execFn = createMockExec({})
const fsAccess = createMockFsAccess(["vitest.config.ts"])
const fsReadFile = createMockFsReadFile()
const toolWithMocks = new RunTestsTool(execFn, fsAccess, fsReadFile)
const ctx = createMockContext()
const result = await toolWithMocks.execute({}, ctx)
expect(result.success).toBe(true)
const data = result.data as RunTestsResult
expect(data.durationMs).toBeGreaterThanOrEqual(0)
})
})
describe("failing tests", () => {
it("should return success=true but passed=false for test failures", async () => {
const error = Object.assign(new Error("Tests failed"), {
code: 1,
stdout: "1 test failed",
stderr: "AssertionError",
})
const execFn = createMockExec({ error })
const fsAccess = createMockFsAccess(["vitest.config.ts"])
const fsReadFile = createMockFsReadFile()
const toolWithMocks = new RunTestsTool(execFn, fsAccess, fsReadFile)
const ctx = createMockContext()
const result = await toolWithMocks.execute({}, ctx)
expect(result.success).toBe(true)
const data = result.data as RunTestsResult
expect(data.passed).toBe(false)
expect(data.exitCode).toBe(1)
expect(data.stdout).toContain("1 test failed")
expect(data.stderr).toContain("AssertionError")
})
})
describe("with options", () => {
it("should pass path to command", async () => {
const execFn = createMockExec({})
const fsAccess = createMockFsAccess(["vitest.config.ts"])
const fsReadFile = createMockFsReadFile()
const toolWithMocks = new RunTestsTool(execFn, fsAccess, fsReadFile)
const ctx = createMockContext()
const result = await toolWithMocks.execute({ path: "src/tests" }, ctx)
expect(result.success).toBe(true)
const data = result.data as RunTestsResult
expect(data.command).toContain("src/tests")
})
it("should pass filter to command", async () => {
const execFn = createMockExec({})
const fsAccess = createMockFsAccess(["vitest.config.ts"])
const fsReadFile = createMockFsReadFile()
const toolWithMocks = new RunTestsTool(execFn, fsAccess, fsReadFile)
const ctx = createMockContext()
const result = await toolWithMocks.execute({ filter: "login" }, ctx)
expect(result.success).toBe(true)
const data = result.data as RunTestsResult
expect(data.command).toContain('-t "login"')
})
it("should pass watch option", async () => {
const execFn = createMockExec({})
const fsAccess = createMockFsAccess(["vitest.config.ts"])
const fsReadFile = createMockFsReadFile()
const toolWithMocks = new RunTestsTool(execFn, fsAccess, fsReadFile)
const ctx = createMockContext()
const result = await toolWithMocks.execute({ watch: true }, ctx)
expect(result.success).toBe(true)
const data = result.data as RunTestsResult
expect(data.command).toBe("npx vitest")
expect(data.command).not.toContain("run")
})
})
describe("error handling", () => {
it("should handle timeout", async () => {
const error = new Error("Command timed out")
const execFn = createMockExec({ error })
const fsAccess = createMockFsAccess(["vitest.config.ts"])
const fsReadFile = createMockFsReadFile()
const toolWithMocks = new RunTestsTool(execFn, fsAccess, fsReadFile)
const ctx = createMockContext()
const result = await toolWithMocks.execute({}, ctx)
expect(result.success).toBe(false)
expect(result.error).toContain("timed out")
})
it("should handle generic errors", async () => {
const error = new Error("Something went wrong")
const execFn = createMockExec({ error })
const fsAccess = createMockFsAccess(["vitest.config.ts"])
const fsReadFile = createMockFsReadFile()
const toolWithMocks = new RunTestsTool(execFn, fsAccess, fsReadFile)
const ctx = createMockContext()
const result = await toolWithMocks.execute({}, ctx)
expect(result.success).toBe(false)
expect(result.error).toBe("Something went wrong")
})
})
describe("exec options", () => {
it("should run in project root", async () => {
const execFn = createMockExec({})
const fsAccess = createMockFsAccess(["vitest.config.ts"])
const fsReadFile = createMockFsReadFile()
const toolWithMocks = new RunTestsTool(execFn, fsAccess, fsReadFile)
const ctx = createMockContext()
ctx.projectRoot = "/my/project"
await toolWithMocks.execute({}, ctx)
expect(execFn).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ cwd: "/my/project" }),
)
})
it("should set CI environment variable", async () => {
const execFn = createMockExec({})
const fsAccess = createMockFsAccess(["vitest.config.ts"])
const fsReadFile = createMockFsReadFile()
const toolWithMocks = new RunTestsTool(execFn, fsAccess, fsReadFile)
const ctx = createMockContext()
await toolWithMocks.execute({}, ctx)
expect(execFn).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
env: expect.objectContaining({ CI: "true" }),
}),
)
})
})
describe("call id", () => {
it("should generate unique call id", async () => {
const execFn = createMockExec({})
const fsAccess = createMockFsAccess(["vitest.config.ts"])
const fsReadFile = createMockFsReadFile()
const toolWithMocks = new RunTestsTool(execFn, fsAccess, fsReadFile)
const ctx = createMockContext()
const result = await toolWithMocks.execute({}, ctx)
expect(result.callId).toMatch(/^run_tests-\d+$/)
})
})
})
})

View File

@@ -0,0 +1,145 @@
/**
* Tests for Chat component.
*/
import { describe, expect, it } from "vitest"
import type { ChatProps } from "../../../../src/tui/components/Chat.js"
import type { ChatMessage } from "../../../../src/domain/value-objects/ChatMessage.js"
describe("Chat", () => {
describe("module exports", () => {
it("should export Chat component", async () => {
const mod = await import("../../../../src/tui/components/Chat.js")
expect(mod.Chat).toBeDefined()
expect(typeof mod.Chat).toBe("function")
})
})
describe("ChatProps interface", () => {
it("should accept messages array", () => {
const messages: ChatMessage[] = []
const props: ChatProps = {
messages,
isThinking: false,
}
expect(props.messages).toEqual([])
})
it("should accept isThinking boolean", () => {
const props: ChatProps = {
messages: [],
isThinking: true,
}
expect(props.isThinking).toBe(true)
})
})
describe("message formatting", () => {
it("should handle user messages", () => {
const message: ChatMessage = {
role: "user",
content: "Hello",
timestamp: Date.now(),
}
expect(message.role).toBe("user")
expect(message.content).toBe("Hello")
})
it("should handle assistant messages", () => {
const message: ChatMessage = {
role: "assistant",
content: "Hi there!",
timestamp: Date.now(),
stats: {
tokens: 100,
timeMs: 1000,
toolCalls: 0,
},
}
expect(message.role).toBe("assistant")
expect(message.stats?.tokens).toBe(100)
})
it("should handle tool messages", () => {
const message: ChatMessage = {
role: "tool",
content: "",
timestamp: Date.now(),
toolResults: [
{
callId: "123",
success: true,
data: "result",
durationMs: 50,
},
],
}
expect(message.role).toBe("tool")
expect(message.toolResults?.length).toBe(1)
})
it("should handle system messages", () => {
const message: ChatMessage = {
role: "system",
content: "System notification",
timestamp: Date.now(),
}
expect(message.role).toBe("system")
})
})
describe("timestamp formatting", () => {
it("should format timestamp as HH:MM", () => {
const timestamp = new Date(2025, 0, 1, 14, 30).getTime()
const date = new Date(timestamp)
const hours = String(date.getHours()).padStart(2, "0")
const minutes = String(date.getMinutes()).padStart(2, "0")
const formatted = `${hours}:${minutes}`
expect(formatted).toBe("14:30")
})
})
describe("stats formatting", () => {
it("should format response stats", () => {
const stats = {
tokens: 1247,
timeMs: 3200,
toolCalls: 1,
}
const time = (stats.timeMs / 1000).toFixed(1)
const tokens = stats.tokens.toLocaleString("en-US")
const tools = stats.toolCalls
expect(time).toBe("3.2")
expect(tokens).toBe("1,247")
expect(tools).toBe(1)
})
it("should pluralize tool calls correctly", () => {
const formatTools = (count: number): string => {
return `${String(count)} tool${count > 1 ? "s" : ""}`
}
expect(formatTools(1)).toBe("1 tool")
expect(formatTools(2)).toBe("2 tools")
expect(formatTools(5)).toBe("5 tools")
})
})
describe("tool call formatting", () => {
it("should format tool calls with params", () => {
const toolCall = {
id: "123",
name: "get_lines",
params: { path: "/src/index.ts", start: 1, end: 10 },
}
const params = Object.entries(toolCall.params)
.map(([k, v]) => `${k}=${JSON.stringify(v)}`)
.join(" ")
expect(params).toBe('path="/src/index.ts" start=1 end=10')
})
})
})

View File

@@ -0,0 +1,184 @@
/**
* Tests for Input component.
*/
import { describe, expect, it, vi } from "vitest"
import type { InputProps } from "../../../../src/tui/components/Input.js"
describe("Input", () => {
describe("module exports", () => {
it("should export Input component", async () => {
const mod = await import("../../../../src/tui/components/Input.js")
expect(mod.Input).toBeDefined()
expect(typeof mod.Input).toBe("function")
})
})
describe("InputProps interface", () => {
it("should accept onSubmit callback", () => {
const onSubmit = vi.fn()
const props: InputProps = {
onSubmit,
history: [],
disabled: false,
}
expect(props.onSubmit).toBe(onSubmit)
})
it("should accept history array", () => {
const history = ["first", "second", "third"]
const props: InputProps = {
onSubmit: vi.fn(),
history,
disabled: false,
}
expect(props.history).toEqual(history)
})
it("should accept disabled state", () => {
const props: InputProps = {
onSubmit: vi.fn(),
history: [],
disabled: true,
}
expect(props.disabled).toBe(true)
})
it("should accept optional placeholder", () => {
const props: InputProps = {
onSubmit: vi.fn(),
history: [],
disabled: false,
placeholder: "Custom placeholder...",
}
expect(props.placeholder).toBe("Custom placeholder...")
})
it("should have default placeholder when not provided", () => {
const props: InputProps = {
onSubmit: vi.fn(),
history: [],
disabled: false,
}
expect(props.placeholder).toBeUndefined()
})
})
describe("history navigation logic", () => {
it("should navigate up through history", () => {
const history = ["first", "second", "third"]
let historyIndex = -1
let value = ""
historyIndex = history.length - 1
value = history[historyIndex] ?? ""
expect(value).toBe("third")
historyIndex = Math.max(0, historyIndex - 1)
value = history[historyIndex] ?? ""
expect(value).toBe("second")
historyIndex = Math.max(0, historyIndex - 1)
value = history[historyIndex] ?? ""
expect(value).toBe("first")
historyIndex = Math.max(0, historyIndex - 1)
value = history[historyIndex] ?? ""
expect(value).toBe("first")
})
it("should navigate down through history", () => {
const history = ["first", "second", "third"]
let historyIndex = 0
let value = ""
const savedInput = "current input"
historyIndex = historyIndex + 1
value = history[historyIndex] ?? ""
expect(value).toBe("second")
historyIndex = historyIndex + 1
value = history[historyIndex] ?? ""
expect(value).toBe("third")
if (historyIndex >= history.length - 1) {
historyIndex = -1
value = savedInput
}
expect(value).toBe("current input")
expect(historyIndex).toBe(-1)
})
it("should save current input when navigating up", () => {
const currentInput = "typing something"
let savedInput = ""
savedInput = currentInput
expect(savedInput).toBe("typing something")
})
it("should restore saved input when navigating past history end", () => {
const savedInput = "original input"
let value = ""
value = savedInput
expect(value).toBe("original input")
})
})
describe("submit behavior", () => {
it("should not submit empty input", () => {
const onSubmit = vi.fn()
const text = " "
if (text.trim()) {
onSubmit(text)
}
expect(onSubmit).not.toHaveBeenCalled()
})
it("should submit non-empty input", () => {
const onSubmit = vi.fn()
const text = "hello"
if (text.trim()) {
onSubmit(text)
}
expect(onSubmit).toHaveBeenCalledWith("hello")
})
it("should not submit when disabled", () => {
const onSubmit = vi.fn()
const text = "hello"
const disabled = true
if (!disabled && text.trim()) {
onSubmit(text)
}
expect(onSubmit).not.toHaveBeenCalled()
})
})
describe("state reset after submit", () => {
it("should reset value after submit", () => {
let value = "test input"
value = ""
expect(value).toBe("")
})
it("should reset history index after submit", () => {
let historyIndex = 2
historyIndex = -1
expect(historyIndex).toBe(-1)
})
it("should reset saved input after submit", () => {
let savedInput = "saved"
savedInput = ""
expect(savedInput).toBe("")
})
})
})

View File

@@ -0,0 +1,112 @@
/**
* Tests for StatusBar component.
*/
import { describe, expect, it } from "vitest"
import type { StatusBarProps } from "../../../../src/tui/components/StatusBar.js"
import type { TuiStatus, BranchInfo } from "../../../../src/tui/types.js"
describe("StatusBar", () => {
describe("module exports", () => {
it("should export StatusBar component", async () => {
const mod = await import("../../../../src/tui/components/StatusBar.js")
expect(mod.StatusBar).toBeDefined()
expect(typeof mod.StatusBar).toBe("function")
})
})
describe("StatusBarProps interface", () => {
it("should accept contextUsage as number", () => {
const props: Partial<StatusBarProps> = {
contextUsage: 0.5,
}
expect(props.contextUsage).toBe(0.5)
})
it("should accept contextUsage from 0 to 1", () => {
const props1: Partial<StatusBarProps> = { contextUsage: 0 }
const props2: Partial<StatusBarProps> = { contextUsage: 0.5 }
const props3: Partial<StatusBarProps> = { contextUsage: 1 }
expect(props1.contextUsage).toBe(0)
expect(props2.contextUsage).toBe(0.5)
expect(props3.contextUsage).toBe(1)
})
it("should accept projectName as string", () => {
const props: Partial<StatusBarProps> = {
projectName: "my-project",
}
expect(props.projectName).toBe("my-project")
})
it("should accept branch info", () => {
const branch: BranchInfo = {
name: "main",
isDetached: false,
}
const props: Partial<StatusBarProps> = { branch }
expect(props.branch?.name).toBe("main")
expect(props.branch?.isDetached).toBe(false)
})
it("should handle detached HEAD state", () => {
const branch: BranchInfo = {
name: "abc1234",
isDetached: true,
}
const props: Partial<StatusBarProps> = { branch }
expect(props.branch?.isDetached).toBe(true)
})
it("should accept sessionTime as string", () => {
const props: Partial<StatusBarProps> = {
sessionTime: "47m",
}
expect(props.sessionTime).toBe("47m")
})
it("should accept status value", () => {
const statuses: TuiStatus[] = [
"ready",
"thinking",
"tool_call",
"awaiting_confirmation",
"error",
]
statuses.forEach((status) => {
const props: Partial<StatusBarProps> = { status }
expect(props.status).toBe(status)
})
})
})
describe("status display logic", () => {
const statusExpectations: Array<{ status: TuiStatus; expectedText: string }> = [
{ status: "ready", expectedText: "ready" },
{ status: "thinking", expectedText: "thinking..." },
{ status: "tool_call", expectedText: "executing..." },
{ status: "awaiting_confirmation", expectedText: "confirm?" },
{ status: "error", expectedText: "error" },
]
statusExpectations.forEach(({ status, expectedText }) => {
it(`should display "${expectedText}" for status "${status}"`, () => {
expect(expectedText).toBeTruthy()
})
})
})
describe("context usage display", () => {
it("should format context usage as percentage", () => {
const usages = [0, 0.1, 0.5, 0.8, 1]
const expected = ["0%", "10%", "50%", "80%", "100%"]
usages.forEach((usage, index) => {
const formatted = `${String(Math.round(usage * 100))}%`
expect(formatted).toBe(expected[index])
})
})
})
})

View File

@@ -0,0 +1,67 @@
/**
* Tests for useHotkeys hook.
*/
import { describe, expect, it, vi, beforeEach } from "vitest"
describe("useHotkeys", () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe("module exports", () => {
it("should export useHotkeys function", async () => {
const mod = await import("../../../../src/tui/hooks/useHotkeys.js")
expect(mod.useHotkeys).toBeDefined()
expect(typeof mod.useHotkeys).toBe("function")
})
})
describe("HotkeyHandlers interface", () => {
it("should accept onInterrupt callback", () => {
const handlers = {
onInterrupt: vi.fn(),
}
expect(handlers.onInterrupt).toBeDefined()
})
it("should accept onExit callback", () => {
const handlers = {
onExit: vi.fn(),
}
expect(handlers.onExit).toBeDefined()
})
it("should accept onUndo callback", () => {
const handlers = {
onUndo: vi.fn(),
}
expect(handlers.onUndo).toBeDefined()
})
it("should accept all callbacks together", () => {
const handlers = {
onInterrupt: vi.fn(),
onExit: vi.fn(),
onUndo: vi.fn(),
}
expect(handlers.onInterrupt).toBeDefined()
expect(handlers.onExit).toBeDefined()
expect(handlers.onUndo).toBeDefined()
})
})
describe("UseHotkeysOptions interface", () => {
it("should accept enabled option", () => {
const options = {
enabled: true,
}
expect(options.enabled).toBe(true)
})
it("should default enabled to undefined when not provided", () => {
const options = {}
expect((options as { enabled?: boolean }).enabled).toBeUndefined()
})
})
})

View File

@@ -0,0 +1,128 @@
/**
* Tests for useSession hook.
*/
import { describe, expect, it, vi, beforeEach } from "vitest"
import type {
UseSessionDependencies,
UseSessionOptions,
} from "../../../../src/tui/hooks/useSession.js"
describe("useSession", () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe("module exports", () => {
it("should export useSession function", async () => {
const mod = await import("../../../../src/tui/hooks/useSession.js")
expect(mod.useSession).toBeDefined()
expect(typeof mod.useSession).toBe("function")
})
})
describe("UseSessionDependencies interface", () => {
it("should require storage", () => {
const deps: Partial<UseSessionDependencies> = {
storage: {} as UseSessionDependencies["storage"],
}
expect(deps.storage).toBeDefined()
})
it("should require sessionStorage", () => {
const deps: Partial<UseSessionDependencies> = {
sessionStorage: {} as UseSessionDependencies["sessionStorage"],
}
expect(deps.sessionStorage).toBeDefined()
})
it("should require llm", () => {
const deps: Partial<UseSessionDependencies> = {
llm: {} as UseSessionDependencies["llm"],
}
expect(deps.llm).toBeDefined()
})
it("should require tools", () => {
const deps: Partial<UseSessionDependencies> = {
tools: {} as UseSessionDependencies["tools"],
}
expect(deps.tools).toBeDefined()
})
it("should require projectRoot", () => {
const deps: Partial<UseSessionDependencies> = {
projectRoot: "/path/to/project",
}
expect(deps.projectRoot).toBe("/path/to/project")
})
it("should require projectName", () => {
const deps: Partial<UseSessionDependencies> = {
projectName: "test-project",
}
expect(deps.projectName).toBe("test-project")
})
it("should accept optional projectStructure", () => {
const deps: Partial<UseSessionDependencies> = {
projectStructure: { files: [], directories: [] },
}
expect(deps.projectStructure).toBeDefined()
})
})
describe("UseSessionOptions interface", () => {
it("should accept autoApply option", () => {
const options: UseSessionOptions = {
autoApply: true,
}
expect(options.autoApply).toBe(true)
})
it("should accept onConfirmation callback", () => {
const options: UseSessionOptions = {
onConfirmation: async () => true,
}
expect(options.onConfirmation).toBeDefined()
})
it("should accept onError callback", () => {
const options: UseSessionOptions = {
onError: async () => "skip",
}
expect(options.onError).toBeDefined()
})
it("should allow all options together", () => {
const options: UseSessionOptions = {
autoApply: false,
onConfirmation: async () => false,
onError: async () => "retry",
}
expect(options.autoApply).toBe(false)
expect(options.onConfirmation).toBeDefined()
expect(options.onError).toBeDefined()
})
})
describe("UseSessionReturn interface", () => {
it("should define expected return shape", () => {
const expectedKeys = [
"session",
"messages",
"status",
"isLoading",
"error",
"sendMessage",
"undo",
"clearHistory",
"abort",
]
expectedKeys.forEach((key) => {
expect(key).toBeTruthy()
})
})
})
})

View File

@@ -0,0 +1,171 @@
/**
* Tests for TUI types.
*/
import { describe, expect, it } from "vitest"
import type { TuiStatus, BranchInfo, AppProps, StatusBarData } from "../../../src/tui/types.js"
describe("TUI types", () => {
describe("TuiStatus type", () => {
it("should include ready status", () => {
const status: TuiStatus = "ready"
expect(status).toBe("ready")
})
it("should include thinking status", () => {
const status: TuiStatus = "thinking"
expect(status).toBe("thinking")
})
it("should include tool_call status", () => {
const status: TuiStatus = "tool_call"
expect(status).toBe("tool_call")
})
it("should include awaiting_confirmation status", () => {
const status: TuiStatus = "awaiting_confirmation"
expect(status).toBe("awaiting_confirmation")
})
it("should include error status", () => {
const status: TuiStatus = "error"
expect(status).toBe("error")
})
})
describe("BranchInfo interface", () => {
it("should have name property", () => {
const branch: BranchInfo = {
name: "main",
isDetached: false,
}
expect(branch.name).toBe("main")
})
it("should have isDetached property", () => {
const branch: BranchInfo = {
name: "abc1234",
isDetached: true,
}
expect(branch.isDetached).toBe(true)
})
it("should represent normal branch", () => {
const branch: BranchInfo = {
name: "feature/new-feature",
isDetached: false,
}
expect(branch.name).toBe("feature/new-feature")
expect(branch.isDetached).toBe(false)
})
it("should represent detached HEAD", () => {
const branch: BranchInfo = {
name: "abc1234def5678",
isDetached: true,
}
expect(branch.isDetached).toBe(true)
})
})
describe("AppProps interface", () => {
it("should require projectPath", () => {
const props: AppProps = {
projectPath: "/path/to/project",
}
expect(props.projectPath).toBe("/path/to/project")
})
it("should accept optional autoApply", () => {
const props: AppProps = {
projectPath: "/path/to/project",
autoApply: true,
}
expect(props.autoApply).toBe(true)
})
it("should accept optional model", () => {
const props: AppProps = {
projectPath: "/path/to/project",
model: "qwen2.5-coder:7b-instruct",
}
expect(props.model).toBe("qwen2.5-coder:7b-instruct")
})
it("should accept all optional props together", () => {
const props: AppProps = {
projectPath: "/path/to/project",
autoApply: false,
model: "custom-model",
}
expect(props.projectPath).toBe("/path/to/project")
expect(props.autoApply).toBe(false)
expect(props.model).toBe("custom-model")
})
})
describe("StatusBarData interface", () => {
it("should have contextUsage as number", () => {
const data: StatusBarData = {
contextUsage: 0.5,
projectName: "test",
branch: { name: "main", isDetached: false },
sessionTime: "10m",
status: "ready",
}
expect(data.contextUsage).toBe(0.5)
})
it("should have projectName as string", () => {
const data: StatusBarData = {
contextUsage: 0,
projectName: "my-project",
branch: { name: "main", isDetached: false },
sessionTime: "0m",
status: "ready",
}
expect(data.projectName).toBe("my-project")
})
it("should have branch as BranchInfo", () => {
const data: StatusBarData = {
contextUsage: 0,
projectName: "test",
branch: { name: "develop", isDetached: false },
sessionTime: "0m",
status: "ready",
}
expect(data.branch.name).toBe("develop")
expect(data.branch.isDetached).toBe(false)
})
it("should have sessionTime as string", () => {
const data: StatusBarData = {
contextUsage: 0,
projectName: "test",
branch: { name: "main", isDetached: false },
sessionTime: "1h 30m",
status: "ready",
}
expect(data.sessionTime).toBe("1h 30m")
})
it("should have status as TuiStatus", () => {
const data: StatusBarData = {
contextUsage: 0,
projectName: "test",
branch: { name: "main", isDetached: false },
sessionTime: "0m",
status: "thinking",
}
expect(data.status).toBe("thinking")
})
})
describe("module exports", () => {
it("should export all types", async () => {
const mod = await import("../../../src/tui/types.js")
expect(mod).toBeDefined()
})
})
})

View File

@@ -9,7 +9,13 @@ export default defineConfig({
provider: "v8",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts", "src/**/*.tsx"],
exclude: ["src/**/*.d.ts", "src/**/index.ts", "src/**/*.test.ts"],
exclude: [
"src/**/*.d.ts",
"src/**/index.ts",
"src/**/*.test.ts",
"src/tui/**/*.ts",
"src/tui/**/*.tsx",
],
thresholds: {
lines: 95,
functions: 95,