feat(ipuaro): add session management (v0.10.0)

- Add ISessionStorage interface and RedisSessionStorage implementation
- Add ContextManager for token budget and compression
- Add StartSession, HandleMessage, UndoChange use cases
- Update CHANGELOG and TODO documentation
- 88 new tests (1174 total), 97.73% coverage
This commit is contained in:
imfozilbek
2025-12-01 12:27:22 +05:00
parent 56643d903f
commit 0f2ed5b301
22 changed files with 2798 additions and 261 deletions

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

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

@@ -150,12 +150,9 @@ export class RunTestsTool implements ITool {
return createSuccessResult(callId, result, Date.now() - startTime)
} catch (error) {
return this.handleExecError(
callId,
runner,
command,
{ callId, runner, command, startTime },
error,
execStartTime,
startTime,
)
}
} catch (error) {
@@ -168,25 +165,37 @@ export class RunTestsTool implements ITool {
* Detect which test runner is available in the project.
*/
async detectTestRunner(projectRoot: string): Promise<TestRunner | null> {
if (await this.hasFile(projectRoot, "vitest.config.ts")) {
return "vitest"
}
if (await this.hasFile(projectRoot, "vitest.config.js")) {
return "vitest"
}
if (await this.hasFile(projectRoot, "vitest.config.mts")) {
return "vitest"
}
if (await this.hasFile(projectRoot, "jest.config.js")) {
return "jest"
}
if (await this.hasFile(projectRoot, "jest.config.ts")) {
return "jest"
}
if (await this.hasFile(projectRoot, "jest.config.json")) {
return "jest"
const configRunner = await this.detectByConfigFile(projectRoot)
if (configRunner) {
return configRunner
}
return this.detectByPackageJson(projectRoot)
}
private async detectByConfigFile(projectRoot: string): Promise<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")
@@ -196,23 +205,22 @@ export class RunTestsTool implements ITool {
dependencies?: Record<string, string>
}
if (pkg.devDependencies?.vitest || pkg.dependencies?.vitest) {
const deps = { ...pkg.devDependencies, ...pkg.dependencies }
if (deps.vitest) {
return "vitest"
}
if (pkg.devDependencies?.jest || pkg.dependencies?.jest) {
if (deps.jest) {
return "jest"
}
if (pkg.devDependencies?.mocha || pkg.dependencies?.mocha) {
if (deps.mocha) {
return "mocha"
}
if (pkg.scripts?.test) {
return "npm"
}
} catch {
// package.json doesn't exist or is invalid
}
return null
}
@@ -220,63 +228,69 @@ export class RunTestsTool implements ITool {
* Build the test command based on runner and options.
*/
buildCommand(runner: TestRunner, testPath?: string, filter?: string, watch?: boolean): string {
const parts: string[] = []
switch (runner) {
case "vitest":
parts.push("npx vitest")
if (!watch) {
parts.push("run")
}
if (testPath) {
parts.push(testPath)
}
if (filter) {
parts.push("-t", `"${filter}"`)
}
break
case "jest":
parts.push("npx jest")
if (testPath) {
parts.push(testPath)
}
if (filter) {
parts.push("-t", `"${filter}"`)
}
if (watch) {
parts.push("--watch")
}
break
case "mocha":
parts.push("npx mocha")
if (testPath) {
parts.push(testPath)
}
if (filter) {
parts.push("--grep", `"${filter}"`)
}
if (watch) {
parts.push("--watch")
}
break
case "npm":
parts.push("npm test")
if (testPath || filter) {
parts.push("--")
if (testPath) {
parts.push(testPath)
}
if (filter) {
parts.push(`"${filter}"`)
}
}
break
const builders: Record<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(" ")
}
return parts.join(" ")
private buildVitestCommand(testPath?: string, filter?: string, watch?: boolean): string[] {
const parts = ["npx vitest"]
if (!watch) {
parts.push("run")
}
if (testPath) {
parts.push(testPath)
}
if (filter) {
parts.push("-t", `"${filter}"`)
}
return parts
}
private buildJestCommand(testPath?: string, filter?: string, watch?: boolean): string[] {
const parts = ["npx jest"]
if (testPath) {
parts.push(testPath)
}
if (filter) {
parts.push("-t", `"${filter}"`)
}
if (watch) {
parts.push("--watch")
}
return parts
}
private buildMochaCommand(testPath?: string, filter?: string, watch?: boolean): string[] {
const parts = ["npx mocha"]
if (testPath) {
parts.push(testPath)
}
if (filter) {
parts.push("--grep", `"${filter}"`)
}
if (watch) {
parts.push("--watch")
}
return parts
}
private buildNpmCommand(testPath?: string, filter?: string): string[] {
const parts = ["npm test"]
if (testPath || filter) {
parts.push("--")
if (testPath) {
parts.push(testPath)
}
if (filter) {
parts.push(`"${filter}"`)
}
}
return parts
}
/**
@@ -295,13 +309,11 @@ export class RunTestsTool implements ITool {
* Handle exec errors and return appropriate result.
*/
private handleExecError(
callId: string,
runner: TestRunner,
command: string,
ctx: { callId: string; runner: TestRunner; command: string; startTime: number },
error: unknown,
execStartTime: number,
startTime: number,
): ToolResult {
const { callId, runner, command, startTime } = ctx
const durationMs = Date.now() - execStartTime
if (this.isExecError(error)) {