From f0d051398140fe6fb85f7636c3619dd3595a2e47 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 1 Oct 2025 09:38:35 +0200 Subject: [PATCH 1/3] feat: add slash command suggestions for provider configuration Implement slash command suggestion infrastructure to power provider configuration UX. Replace command parser with definition driven structure and surface new provider list IPC channel to populate dynamic suggestions. Add unit tests covering heuristics. --- scripts/update_vercel_docs.sh | 1 + src/components/ChatInput.tsx | 47 ++++- src/components/CommandSuggestions.tsx | 65 ++---- src/constants/ipc-constants.ts | 1 + src/main.ts | 137 ++++++------- src/preload.ts | 1 + src/types/ipc.ts | 1 + src/utils/commandParser.ts | 162 +++++++++++---- src/utils/slashCommands.test.ts | 54 +++++ src/utils/slashCommands.ts | 274 ++++++++++++++++++++++++++ 10 files changed, 583 insertions(+), 160 deletions(-) create mode 100644 src/utils/slashCommands.test.ts create mode 100644 src/utils/slashCommands.ts diff --git a/scripts/update_vercel_docs.sh b/scripts/update_vercel_docs.sh index 0719394aff..d352f02430 100755 --- a/scripts/update_vercel_docs.sh +++ b/scripts/update_vercel_docs.sh @@ -18,6 +18,7 @@ cd "$TEMP_DIR" git init -q git remote add origin https://github.com/vercel/ai.git git config core.sparseCheckout true +mkdir -p .git/info echo "content/*" >.git/info/sparse-checkout git fetch --depth=1 origin main diff --git a/src/components/ChatInput.tsx b/src/components/ChatInput.tsx index 0e36a9c97a..01d147dc7f 100644 --- a/src/components/ChatInput.tsx +++ b/src/components/ChatInput.tsx @@ -9,6 +9,7 @@ import type { SendMessageError as SendMessageErrorType } from "../types/errors"; import { usePersistedState } from "../hooks/usePersistedState"; import { ThinkingSliderComponent } from "./ThinkingSlider"; import { useThinkingLevel } from "../hooks/useThinkingLevel"; +import { getSlashCommandSuggestions, type SlashSuggestion } from "../utils/slashCommands"; const InputSection = styled.div` position: relative; @@ -248,7 +249,8 @@ export const ChatInput: React.FC = ({ const [input, setInput] = usePersistedState("input:" + workspaceId, ""); const [isSending, setIsSending] = useState(false); const [showCommandSuggestions, setShowCommandSuggestions] = useState(false); - const [availableCommands] = useState([]); // Will be populated in future + const [commandSuggestions, setCommandSuggestions] = useState([]); + const [providerNames, setProviderNames] = useState([]); const [toast, setToast] = useState(null); const inputRef = useRef(null); const [thinkingLevel] = useThinkingLevel(); @@ -270,13 +272,37 @@ export const ChatInput: React.FC = ({ // Watch input for slash commands useEffect(() => { - setShowCommandSuggestions(input.startsWith("/") && availableCommands.length > 0); - }, [input, availableCommands]); + const suggestions = getSlashCommandSuggestions(input, { providerNames }); + setCommandSuggestions(suggestions); + setShowCommandSuggestions(suggestions.length > 0); + }, [input, providerNames]); + + // Load provider names for suggestions + useEffect(() => { + let isMounted = true; + + const loadProviders = async () => { + try { + const names = await window.api.providers.list(); + if (isMounted && Array.isArray(names)) { + setProviderNames(names); + } + } catch (error) { + console.error("Failed to load provider list:", error); + } + }; + + void loadProviders(); + + return () => { + isMounted = false; + }; + }, []); // Handle command selection const handleCommandSelect = useCallback( - (command: string) => { - setInput(`/${command} `); + (suggestion: SlashSuggestion) => { + setInput(suggestion.replacement); setShowCommandSuggestions(false); inputRef.current?.focus(); }, @@ -404,7 +430,11 @@ export const ChatInput: React.FC = ({ } // Don't handle keys if command suggestions are visible - if (showCommandSuggestions && COMMAND_SUGGESTION_KEYS.includes(e.key)) { + if ( + showCommandSuggestions && + commandSuggestions.length > 0 && + COMMAND_SUGGESTION_KEYS.includes(e.key) + ) { return; // Let CommandSuggestions handle it } @@ -424,9 +454,8 @@ export const ChatInput: React.FC = ({ setToast(null)} /> setShowCommandSuggestions(false)} isVisible={showCommandSuggestions} /> diff --git a/src/components/CommandSuggestions.tsx b/src/components/CommandSuggestions.tsx index 00a2fd5d45..09680ce420 100644 --- a/src/components/CommandSuggestions.tsx +++ b/src/components/CommandSuggestions.tsx @@ -1,14 +1,14 @@ import React, { useState, useEffect } from "react"; import styled from "@emotion/styled"; +import type { SlashSuggestion } from "../utils/slashCommands"; // Export the keys that CommandSuggestions handles export const COMMAND_SUGGESTION_KEYS = ["Tab", "ArrowUp", "ArrowDown", "Escape"]; // Props interface interface CommandSuggestionsProps { - input: string; - availableCommands: string[]; - onSelectCommand: (command: string) => void; + suggestions: SlashSuggestion[]; + onSelectSuggestion: (suggestion: SlashSuggestion) => void; onDismiss: () => void; isVisible: boolean; } @@ -79,61 +79,36 @@ const HelperText = styled.div` // Main component export const CommandSuggestions: React.FC = ({ - input, - availableCommands, - onSelectCommand, + suggestions, + onSelectSuggestion, onDismiss, isVisible, }) => { - const [filteredCommands, setFilteredCommands] = useState([]); const [selectedIndex, setSelectedIndex] = useState(0); - // Command descriptions for built-in commands - const getDescription = (cmd: string): string => { - const descriptions: Record = { - clear: "Clear conversation and start fresh", - compact: "Compress conversation history", - context: "Show context usage information", - cost: "Show token usage and costs", - init: "Initialize or reinitialize session", - model: "Switch AI model", - help: "Show available commands", - }; - - return descriptions[cmd] || `/${cmd}`; - }; - - // Filter commands based on input + // Reset selection whenever suggestions change useEffect(() => { - if (input.startsWith("/")) { - const searchTerm = input.slice(1).toLowerCase(); - const filtered = availableCommands - .filter((cmd) => cmd.toLowerCase().startsWith(searchTerm)) - .slice(0, 10); - - setFilteredCommands(filtered); - setSelectedIndex(0); - } - }, [input, availableCommands]); + setSelectedIndex(0); + }, [suggestions]); // Handle keyboard navigation useEffect(() => { - if (!isVisible) return; + if (!isVisible || suggestions.length === 0) return; const handleKeyDown = (e: KeyboardEvent) => { switch (e.key) { case "ArrowDown": e.preventDefault(); - setSelectedIndex((i) => (i + 1) % filteredCommands.length); + setSelectedIndex((i) => (i + 1) % suggestions.length); break; case "ArrowUp": e.preventDefault(); - setSelectedIndex((i) => (i - 1 + filteredCommands.length) % filteredCommands.length); + setSelectedIndex((i) => (i - 1 + suggestions.length) % suggestions.length); break; case "Tab": - if (!e.shiftKey && filteredCommands.length > 0) { + if (!e.shiftKey && suggestions.length > 0) { e.preventDefault(); - onSelectCommand(filteredCommands[selectedIndex]); + onSelectSuggestion(suggestions[selectedIndex]); } break; case "Escape": @@ -145,7 +120,7 @@ export const CommandSuggestions: React.FC = ({ document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); - }, [isVisible, filteredCommands, selectedIndex, onSelectCommand, onDismiss]); + }, [isVisible, suggestions, selectedIndex, onSelectSuggestion, onDismiss]); // Click outside handler useEffect(() => { @@ -162,21 +137,21 @@ export const CommandSuggestions: React.FC = ({ return () => document.removeEventListener("mousedown", handleClickOutside); }, [isVisible, onDismiss]); - if (!isVisible || filteredCommands.length === 0) { + if (!isVisible || suggestions.length === 0) { return null; } return ( - {filteredCommands.map((cmd, index) => ( + {suggestions.map((suggestion, index) => ( setSelectedIndex(index)} - onClick={() => onSelectCommand(cmd)} + onClick={() => onSelectSuggestion(suggestion)} > - /{cmd} - {getDescription(cmd)} + {suggestion.display} + {suggestion.description} ))} diff --git a/src/constants/ipc-constants.ts b/src/constants/ipc-constants.ts index 0c38d96413..4bcee245cf 100644 --- a/src/constants/ipc-constants.ts +++ b/src/constants/ipc-constants.ts @@ -13,6 +13,7 @@ export const IPC_CHANNELS = { // Provider channels PROVIDERS_SET_CONFIG: "providers:setConfig", + PROVIDERS_LIST: "providers:list", // Workspace channels WORKSPACE_LIST: "workspace:list", diff --git a/src/main.ts b/src/main.ts index ac08ace745..9ec2cda998 100644 --- a/src/main.ts +++ b/src/main.ts @@ -9,6 +9,7 @@ import { loadProvidersConfig, saveProvidersConfig, } from "./config"; +import type { ProvidersConfig } from "./config"; import { createWorktree, removeWorktree } from "./git"; import { AIService } from "./services/aiService"; import { HistoryService } from "./services/historyService"; @@ -27,6 +28,7 @@ import type { import { IPC_CHANNELS, getChatChannel } from "./constants/ipc-constants"; import type { SendMessageError } from "./types/errors"; import type { StreamErrorMessage } from "./types/ipc"; +import type { ThinkingLevel } from "./types/thinking"; const historyService = new HistoryService(); const partialService = new PartialService(historyService); @@ -57,6 +59,14 @@ if (!gotTheLock) { let mainWindow: BrowserWindow | null = null; +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null; + +const createUnknownSendMessageError = (raw: string): SendMessageError => ({ + type: "unknown", + raw, +}); + // Register IPC handlers before creating window ipcMain.handle(IPC_CHANNELS.CONFIG_LOAD, () => { const config = load_config_or_default(); @@ -209,7 +219,7 @@ ipcMain.handle( workspaceId: string, message: string, editMessageId?: string, - thinkingLevel?: string + thinkingLevel?: ThinkingLevel ) => { log.debug("sendMessage handler: Received", { workspaceId, @@ -228,10 +238,7 @@ ipcMain.handle( log.error("Failed to truncate history for edit:", truncateResult.error); return { success: false, - error: { - type: "unknown" as const, - raw: truncateResult.error, - }, + error: createUnknownSendMessageError(truncateResult.error), }; } // Note: We don't send a clear event here. The aggregator will handle @@ -251,10 +258,7 @@ ipcMain.handle( log.error("Failed to append message to history:", appendResult.error); return { success: false, - error: { - type: "unknown" as const, - raw: appendResult.error, - }, + error: createUnknownSendMessageError(appendResult.error), }; } @@ -273,10 +277,7 @@ ipcMain.handle( log.error("Failed to get conversation history:", historyResult.error); return { success: false, - error: { - type: "unknown" as const, - raw: historyResult.error, - }, + error: createUnknownSendMessageError(historyResult.error), }; } @@ -287,8 +288,7 @@ ipcMain.handle( const streamResult = await aiService.streamMessage( historyResult.data, workspaceId, - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument - thinkingLevel as any // Cast to ThinkingLevel - validated by TypeScript in frontend + thinkingLevel ); log.debug("sendMessage handler: Stream completed"); return streamResult; @@ -315,24 +315,31 @@ ipcMain.handle( (_event, provider: string, keyPath: string[], value: string) => { try { // Load current providers config or create empty - const config = loadProvidersConfig() ?? {}; + const config: ProvidersConfig = loadProvidersConfig() ?? {}; - // Ensure provider exists - if (!config[provider]) { + if (!isRecord(config[provider])) { config[provider] = {}; } - // Set nested property value - let current = config[provider] as Record; + const ensuredConfig = config[provider]; + if (!isRecord(ensuredConfig)) { + throw new Error(`Provider config for ${provider} could not be initialized`); + } + + let current: Record = ensuredConfig; for (let i = 0; i < keyPath.length - 1; i++) { const key = keyPath[i]; - if (!(key in current) || typeof current[key] !== "object" || current[key] === null) { - current[key] = {}; + const existing = current[key]; + if (isRecord(existing)) { + current = existing; + continue; } - current = current[key] as Record; + + const next: Record = {}; + current[key] = next; + current = next; } - // Set the final value if (keyPath.length > 0) { current[keyPath[keyPath.length - 1]] = value; } @@ -348,55 +355,53 @@ ipcMain.handle( } ); +ipcMain.handle(IPC_CHANNELS.PROVIDERS_LIST, () => { + try { + const config: ProvidersConfig = loadProvidersConfig() ?? {}; + return Object.keys(config); + } catch (error) { + log.error("Failed to list providers config:", error); + return []; + } +}); + // Handle subscription events for chat history -ipcMain.on( - `workspace:chat:subscribe`, - (_event, workspaceId: string) => - void (async () => { - const chatChannel = getChatChannel(workspaceId); - - // Emit current chat history immediately - const history = await historyService.getHistory(workspaceId); - if (history.success) { - // Merge partial.json with chat history for complete message list - const partial = await partialService.readPartial(workspaceId); - - // Send all history messages - history.data.forEach((msg) => { - mainWindow?.webContents.send(chatChannel, msg); - }); +ipcMain.on(`workspace:chat:subscribe`, (_event, workspaceId: string) => { + void (async () => { + const chatChannel = getChatChannel(workspaceId); + + const history = await historyService.getHistory(workspaceId); + if (history.success) { + for (const msg of history.data) { + mainWindow?.webContents.send(chatChannel, msg); + } - // If there's a partial message (interrupted stream), send it after history - if (partial) { - mainWindow?.webContents.send(chatChannel, partial); - } + const partial = await partialService.readPartial(workspaceId); + if (partial) { + mainWindow?.webContents.send(chatChannel, partial); } + } - // Send caught-up signal - mainWindow?.webContents.send(chatChannel, { type: "caught-up" }); - })() -); + mainWindow?.webContents.send(chatChannel, { type: "caught-up" }); + })(); +}); // Handle subscription events for metadata -ipcMain.on( - `workspace:metadata:subscribe`, - () => - void (async () => { - try { - const workspaceData = await getAllWorkspaceMetadata(); - - // Emit current metadata for each workspace - for (const { workspaceId, metadata } of workspaceData) { - mainWindow?.webContents.send(IPC_CHANNELS.WORKSPACE_METADATA, { - workspaceId, - metadata, - }); - } - } catch (error) { - console.error("Failed to emit current metadata:", error); +ipcMain.on(`workspace:metadata:subscribe`, () => { + void (async () => { + try { + const workspaceData = await getAllWorkspaceMetadata(); + for (const { workspaceId, metadata } of workspaceData) { + mainWindow?.webContents.send(IPC_CHANNELS.WORKSPACE_METADATA, { + workspaceId, + metadata, + }); } - })() -); + } catch (error) { + console.error("Failed to emit current metadata:", error); + } + })(); +}); // Set up event listeners for AI service aiService.on("stream-start", (data: StreamStartEvent) => { diff --git a/src/preload.ts b/src/preload.ts index 1a9fe72ebf..d88c6fef0c 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -35,6 +35,7 @@ const api: IPCApi = { providers: { setProviderConfig: (provider, keyPath, value) => ipcRenderer.invoke(IPC_CHANNELS.PROVIDERS_SET_CONFIG, provider, keyPath, value), + list: () => ipcRenderer.invoke(IPC_CHANNELS.PROVIDERS_LIST), }, workspace: { list: () => ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_LIST), diff --git a/src/types/ipc.ts b/src/types/ipc.ts index b3e35ab6a5..f2434c8273 100644 --- a/src/types/ipc.ts +++ b/src/types/ipc.ts @@ -122,6 +122,7 @@ export interface IPCApi { keyPath: string[], value: string ): Promise>; + list(): Promise; }; workspace: { list(): Promise; diff --git a/src/utils/commandParser.ts b/src/utils/commandParser.ts index c02e70a003..89e691e770 100644 --- a/src/utils/commandParser.ts +++ b/src/utils/commandParser.ts @@ -11,6 +11,93 @@ export type ParsedCommand = | { type: "unknown-command"; command: string; subcommand?: string } | null; +export interface SlashCommandDefinition { + key: string; + description: string; + appendSpace?: boolean; + handler?: SlashCommandHandler; + children?: readonly SlashCommandDefinition[]; +} + +interface SlashCommandHandlerArgs { + definition: SlashCommandDefinition; + path: readonly SlashCommandDefinition[]; + remainingTokens: string[]; + cleanRemainingTokens: string[]; +} + +type SlashCommandHandler = (input: SlashCommandHandlerArgs) => ParsedCommand; + +const clearCommandDefinition: SlashCommandDefinition = { + key: "clear", + description: "Clear conversation history", + appendSpace: false, + handler: ({ cleanRemainingTokens }) => { + if (cleanRemainingTokens.length > 0) { + return { + type: "unknown-command", + command: "clear", + subcommand: cleanRemainingTokens[0], + }; + } + + return { type: "clear" }; + }, +}; + +const providersSetCommandDefinition: SlashCommandDefinition = { + key: "set", + description: "Set a provider configuration value", + handler: ({ cleanRemainingTokens }) => { + if (cleanRemainingTokens.length < 3) { + return { + type: "providers-missing-args", + subcommand: "set", + argCount: cleanRemainingTokens.length, + }; + } + + const [provider, key, ...valueParts] = cleanRemainingTokens; + const value = valueParts.join(" "); + const keyPath = key.split("."); + + return { + type: "providers-set", + provider, + keyPath, + value, + }; + }, +}; + +const providersCommandDefinition: SlashCommandDefinition = { + key: "providers", + description: "Configure AI provider settings", + handler: ({ cleanRemainingTokens }) => { + if (cleanRemainingTokens.length === 0) { + return { type: "providers-help" }; + } + + return { + type: "providers-invalid-subcommand", + subcommand: cleanRemainingTokens[0] ?? "", + }; + }, + children: [providersSetCommandDefinition], +}; +const SLASH_COMMAND_DEFINITIONS: readonly SlashCommandDefinition[] = [ + clearCommandDefinition, + providersCommandDefinition, +]; + +const SLASH_COMMAND_DEFINITION_MAP = new Map( + SLASH_COMMAND_DEFINITIONS.map((definition) => [definition.key, definition]) +); + +export function getSlashCommandDefinitions(): readonly SlashCommandDefinition[] { + return SLASH_COMMAND_DEFINITIONS; +} + /** * Parse a raw command string into a structured command * @param input The raw command string (e.g., "/providers set anthropic apiKey sk-xxx") @@ -23,62 +110,57 @@ export function parseCommand(input: string): ParsedCommand { } // Remove leading slash and split by spaces (respecting quotes) - const parts = trimmed.substring(1).match(/(?:[^\s"]+|"[^"]*")+/g) ?? []; + const parts = (trimmed.substring(1).match(/(?:[^\s"]+|"[^"]*")+/g) ?? []) as string[]; if (parts.length === 0) { return null; } - const [command, subcommand, ...args] = parts; - const cleanArgs = args.map((arg) => arg.replace(/^"(.*)"$/, "$1")); // Remove surrounding quotes + const [commandKey, ...restTokens] = parts; + const definition = SLASH_COMMAND_DEFINITION_MAP.get(commandKey); - // Handle /clear command - if (command === "clear" && !subcommand) { - return { type: "clear" }; + if (!definition) { + return { + type: "unknown-command", + command: commandKey ?? "", + subcommand: restTokens[0], + }; } - // Handle /providers commands - if (command === "providers") { - // No subcommand - show help - if (!subcommand) { - return { type: "providers-help" }; - } + const path: SlashCommandDefinition[] = [definition]; + let remainingTokens = restTokens; - // Invalid subcommand - if (subcommand !== "set") { - return { - type: "providers-invalid-subcommand", - subcommand, - }; - } + while (remainingTokens.length > 0) { + const currentDefinition = path[path.length - 1]; + const children = currentDefinition.children ?? []; + const nextToken = remainingTokens[0]; + const nextDefinition = children.find((child) => child.key === nextToken); - // /providers set - check arguments - if (cleanArgs.length < 3) { - return { - type: "providers-missing-args", - subcommand: "set", - argCount: cleanArgs.length, - }; + if (!nextDefinition) { + break; } - // Valid /providers set command - const [provider, key, ...valueParts] = cleanArgs; - const value = valueParts.join(" "); // Join remaining parts as value (handles spaces) - const keyPath = key.split("."); // Split key by dots for nested path + path.push(nextDefinition); + remainingTokens = remainingTokens.slice(1); + } + const targetDefinition = path[path.length - 1]; + + if (!targetDefinition.handler) { return { - type: "providers-set", - provider, - keyPath, - value, + type: "unknown-command", + command: commandKey ?? "", + subcommand: remainingTokens[0], }; } - // Unknown command - return { - type: "unknown-command", - command: command ?? "", - subcommand, - }; + const cleanRemainingTokens = remainingTokens.map((token) => token.replace(/^"(.*)"$/, "$1")); + + return targetDefinition.handler({ + definition: targetDefinition, + path, + remainingTokens, + cleanRemainingTokens, + }); } /** diff --git a/src/utils/slashCommands.test.ts b/src/utils/slashCommands.test.ts new file mode 100644 index 0000000000..8614290712 --- /dev/null +++ b/src/utils/slashCommands.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from "bun:test"; +import { getSlashCommandSuggestions } from "./slashCommands"; + +describe("getSlashCommandSuggestions", () => { + it("returns empty suggestions for non-commands", () => { + expect(getSlashCommandSuggestions("hello")).toEqual([]); + expect(getSlashCommandSuggestions("")).toEqual([]); + }); + + it("suggests top level commands when starting with slash", () => { + const suggestions = getSlashCommandSuggestions("/"); + const labels = suggestions.map((s) => s.display); + + expect(labels).toContain("/clear"); + expect(labels).toContain("/providers"); + }); + + it("filters top level commands by partial input", () => { + const suggestions = getSlashCommandSuggestions("/cl"); + expect(suggestions).toHaveLength(1); + expect(suggestions[0].replacement).toBe("/clear"); + }); + + it("suggests provider subcommands", () => { + const suggestions = getSlashCommandSuggestions("/providers "); + expect(suggestions.map((s) => s.display)).toContain("set"); + }); + + it("suggests provider names after /providers set", () => { + const suggestions = getSlashCommandSuggestions("/providers set ", { + providerNames: ["anthropic"], + }); + const labels = suggestions.map((s) => s.display); + + expect(labels).toContain("anthropic"); + }); + + it("suggests provider keys after selecting a provider", () => { + const suggestions = getSlashCommandSuggestions("/providers set anthropic "); + const keys = suggestions.map((s) => s.display); + + expect(keys).toContain("apiKey"); + expect(keys).toContain("baseUrl"); + }); + + it("filters provider keys by partial input", () => { + const suggestions = getSlashCommandSuggestions("/providers set anthropic api", { + providerNames: ["anthropic"], + }); + + expect(suggestions).toHaveLength(1); + expect(suggestions[0].display).toBe("apiKey"); + }); +}); diff --git a/src/utils/slashCommands.ts b/src/utils/slashCommands.ts new file mode 100644 index 0000000000..90014f8347 --- /dev/null +++ b/src/utils/slashCommands.ts @@ -0,0 +1,274 @@ +import { getSlashCommandDefinitions, type SlashCommandDefinition } from "./commandParser"; + +export interface SlashSuggestion { + id: string; + display: string; + description: string; + replacement: string; +} + +export interface SlashSuggestionContext { + providerNames?: string[]; +} + +interface SuggestionDefinition { + key: string; + description: string; + appendSpace?: boolean; +} + +const COMMAND_DEFINITIONS = getSlashCommandDefinitions(); + +const COMMAND_DEFINITION_MAP = new Map( + COMMAND_DEFINITIONS.map((definition) => [definition.key, definition]) +); + +const DEFAULT_PROVIDER_NAMES: SuggestionDefinition[] = [ + { + key: "anthropic", + description: "Anthropic (Claude) provider", + }, + { + key: "openai", + description: "OpenAI provider", + }, + { + key: "google", + description: "Google Gemini provider", + }, +]; + +const DEFAULT_PROVIDER_KEYS: Record = { + anthropic: [ + { + key: "apiKey", + description: "API key used when calling Anthropic", + }, + { + key: "baseUrl", + description: "Override Anthropic base URL", + }, + { + key: "baseUrl.scheme", + description: "Protocol to use for the base URL", + }, + ], + openai: [ + { + key: "apiKey", + description: "API key used when calling OpenAI", + }, + { + key: "baseUrl", + description: "Override OpenAI base URL", + }, + ], + google: [ + { + key: "apiKey", + description: "API key used when calling Google Gemini", + }, + ], + default: [ + { + key: "apiKey", + description: "API key required by the provider", + }, + { + key: "baseUrl", + description: "Override provider base URL", + }, + { + key: "baseUrl.scheme", + description: "Protocol to use for the base URL", + }, + ], +}; + +function filterAndMapSuggestions( + definitions: readonly T[], + partial: string, + build: (definition: T) => SlashSuggestion +): SlashSuggestion[] { + const normalizedPartial = partial.trim().toLowerCase(); + + return definitions + .filter((definition) => + normalizedPartial ? definition.key.toLowerCase().startsWith(normalizedPartial) : true + ) + .map((definition) => build(definition)); +} + +function buildTopLevelSuggestions(partial: string): SlashSuggestion[] { + return filterAndMapSuggestions(COMMAND_DEFINITIONS, partial, (definition) => { + const appendSpace = definition.appendSpace ?? true; + const replacement = `/${definition.key}${appendSpace ? " " : ""}`; + return { + id: `command:${definition.key}`, + display: `/${definition.key}`, + description: definition.description, + replacement, + }; + }); +} + +function buildSubcommandSuggestions( + commandDefinition: SlashCommandDefinition, + partial: string, + prefixTokens: string[] +): SlashSuggestion[] { + const subcommands = commandDefinition.children ?? []; + + return filterAndMapSuggestions(subcommands, partial, (definition) => { + const appendSpace = definition.appendSpace ?? true; + const replacementTokens = [...prefixTokens, definition.key]; + const replacementBase = `/${replacementTokens.join(" ")}`; + return { + id: `command:${replacementTokens.join(":")}`, + display: definition.key, + description: definition.description, + replacement: `${replacementBase}${appendSpace ? " " : ""}`, + }; + }); +} + +function dedupeDefinitions(definitions: readonly T[]): T[] { + const seen = new Set(); + const result: T[] = []; + + for (const definition of definitions) { + const key = definition.key.toLowerCase(); + if (seen.has(key)) { + continue; + } + seen.add(key); + result.push(definition); + } + + return result; +} + +function buildProviderNameSuggestions( + partial: string, + providerNames: string[] | undefined +): SlashSuggestion[] { + const dynamicDefinitions = (providerNames ?? []).map((name) => ({ + key: name, + description: `${name} provider configuration`, + })); + + const combined = dedupeDefinitions([...dynamicDefinitions, ...DEFAULT_PROVIDER_NAMES]); + + return filterAndMapSuggestions(combined, partial, (definition) => ({ + id: `command:providers:set:${definition.key}`, + display: definition.key, + description: definition.description, + replacement: `/providers set ${definition.key} `, + })); +} + +function buildProviderKeySuggestions( + partial: string, + providerName: string | undefined +): SlashSuggestion[] { + const definitions = [ + ...(providerName && DEFAULT_PROVIDER_KEYS[providerName] + ? DEFAULT_PROVIDER_KEYS[providerName] + : []), + ...DEFAULT_PROVIDER_KEYS.default, + ]; + + const combined = dedupeDefinitions(definitions); + + return filterAndMapSuggestions(combined, partial, (definition) => ({ + id: `command:providers:set:${providerName}:${definition.key}`, + display: definition.key, + description: definition.description, + replacement: `/providers set ${providerName ?? ""} ${definition.key} `, + })); +} + +export function getSlashCommandSuggestions( + input: string, + context: SlashSuggestionContext = {} +): SlashSuggestion[] { + if (!input.startsWith("/")) { + return []; + } + + const remainder = input.slice(1); + if (remainder.startsWith(" ")) { + return []; + } + + const parts = remainder.split(/\s+/); + const tokens = parts.filter((part) => part.length > 0); + const hasTrailingSpace = remainder.endsWith(" ") || remainder.length === 0; + const completedTokens = hasTrailingSpace ? tokens : tokens.slice(0, -1); + const partialToken = hasTrailingSpace ? "" : (tokens[tokens.length - 1] ?? ""); + const stage = completedTokens.length; + + if (stage === 0) { + return buildTopLevelSuggestions(partialToken); + } + + const rootKey = completedTokens[0] ?? tokens[0]; + if (!rootKey) { + return []; + } + + const rootDefinition = COMMAND_DEFINITION_MAP.get(rootKey); + if (!rootDefinition) { + return []; + } + + const definitionPath: SlashCommandDefinition[] = [rootDefinition]; + let lastDefinition = rootDefinition; + + for (let i = 1; i < completedTokens.length; i++) { + const token = completedTokens[i]; + const nextDefinition = (lastDefinition.children ?? []).find((child) => child.key === token); + + if (!nextDefinition) { + break; + } + + definitionPath.push(nextDefinition); + lastDefinition = nextDefinition; + } + + const matchedDefinitionCount = definitionPath.length; + + if (stage <= matchedDefinitionCount) { + const definitionForSuggestions = definitionPath[Math.max(0, stage - 1)]; + + if (definitionForSuggestions && (definitionForSuggestions.children ?? []).length > 0) { + const prefixTokens = completedTokens.slice(0, stage); + return buildSubcommandSuggestions(definitionForSuggestions, partialToken, prefixTokens); + } + } + + if (definitionPath[0]?.key !== "providers") { + return []; + } + + const setDefinition = definitionPath[1]; + if (!setDefinition || setDefinition.key !== "set") { + return []; + } + + if (stage === 2) { + return buildProviderNameSuggestions(partialToken, context.providerNames); + } + + const providerName = completedTokens[2] ?? tokens[2]; + if (!providerName) { + return []; + } + + if (stage === 3) { + return buildProviderKeySuggestions(partialToken, providerName); + } + + return []; +} From a4b0d41fed2b48b3cd1e0bc959cbbcb410e6d3ad Mon Sep 17 00:00:00 2001 From: Ammar Bandukwala Date: Thu, 2 Oct 2025 10:34:44 -0500 Subject: [PATCH 2/3] Improve testing in the backend - Introduced Config class for test isolation --- src/config.ts | 306 +++++++++-------- src/debug/costs.ts | 4 +- src/debug/list-workspaces.ts | 6 +- src/git.ts | 5 +- src/main.ts | 49 ++- src/services/aiService.test.ts | 8 +- src/services/aiService.ts | 16 +- src/services/historyService.test.ts | 503 ++++++++++++++++++++++++++++ src/services/historyService.ts | 11 +- src/services/log.ts | 4 +- src/services/partialService.ts | 10 +- 11 files changed, 725 insertions(+), 197 deletions(-) create mode 100644 src/services/historyService.test.ts diff --git a/src/config.ts b/src/config.ts index 5a23c959ca..a77c0cae9d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -5,11 +5,6 @@ import * as os from "os"; import * as jsonc from "jsonc-parser"; import type { WorkspaceMetadata } from "./types/workspace"; -export const CONFIG_DIR = path.join(os.homedir(), ".cmux"); -const CONFIG_FILE = path.join(CONFIG_DIR, "config.json"); -const PROVIDERS_FILE = path.join(CONFIG_DIR, "providers.jsonc"); -export const SESSIONS_DIR = path.join(CONFIG_DIR, "sessions"); - export interface Workspace { branch: string; path: string; @@ -20,7 +15,7 @@ export interface ProjectConfig { workspaces: Workspace[]; } -export interface Config { +export interface ProjectsConfig { projects: Map; } @@ -32,164 +27,185 @@ export interface ProviderConfig { export type ProvidersConfig = Record; -export function load_config_or_default(): Config { - try { - if (fs.existsSync(CONFIG_FILE)) { - const data = fs.readFileSync(CONFIG_FILE, "utf-8"); - const parsed = JSON.parse(data) as { projects?: unknown }; - - // Config is stored as array of [path, config] pairs - if (parsed.projects && Array.isArray(parsed.projects)) { - const projectsMap = new Map( - parsed.projects as [string, ProjectConfig][] - ); - return { - projects: projectsMap, - }; - } - } - } catch (error) { - console.error("Error loading config:", error); +/** + * Config - Centralized configuration management + * + * Encapsulates all config paths and operations, making them dependency-injectable + * and testable. Pass a custom rootDir for tests to avoid polluting ~/.cmux + */ +export class Config { + readonly rootDir: string; + readonly sessionsDir: string; + readonly srcDir: string; + private readonly configFile: string; + private readonly providersFile: string; + + constructor(rootDir?: string) { + this.rootDir = rootDir ?? path.join(os.homedir(), ".cmux"); + this.sessionsDir = path.join(this.rootDir, "sessions"); + this.srcDir = path.join(this.rootDir, "src"); + this.configFile = path.join(this.rootDir, "config.json"); + this.providersFile = path.join(this.rootDir, "providers.jsonc"); } - // Return default config - return { - projects: new Map(), - }; -} - -export function save_config(config: Config): void { - try { - if (!fs.existsSync(CONFIG_DIR)) { - fs.mkdirSync(CONFIG_DIR, { recursive: true }); + loadConfigOrDefault(): ProjectsConfig { + try { + if (fs.existsSync(this.configFile)) { + const data = fs.readFileSync(this.configFile, "utf-8"); + const parsed = JSON.parse(data) as { projects?: unknown }; + + // Config is stored as array of [path, config] pairs + if (parsed.projects && Array.isArray(parsed.projects)) { + const projectsMap = new Map( + parsed.projects as [string, ProjectConfig][] + ); + return { + projects: projectsMap, + }; + } + } + } catch (error) { + console.error("Error loading config:", error); } - const data = { - projects: Array.from(config.projects.entries()), + // Return default config + return { + projects: new Map(), }; - - fs.writeFileSync(CONFIG_FILE, JSON.stringify(data, null, 2)); - } catch (error) { - console.error("Error saving config:", error); } -} - -export function getProjectName(projectPath: string): string { - return projectPath.split("/").pop() ?? projectPath.split("\\").pop() ?? "unknown"; -} -export function getWorkspacePath(projectPath: string, branch: string): string { - const projectName = getProjectName(projectPath); - return path.join(CONFIG_DIR, "src", projectName, branch); -} - -/** - * Find a workspace path by project name and branch - * @returns The workspace path or null if not found - */ -export function findWorkspacePath(projectName: string, branch: string): string | null { - const config = load_config_or_default(); + saveConfig(config: ProjectsConfig): void { + try { + if (!fs.existsSync(this.rootDir)) { + fs.mkdirSync(this.rootDir, { recursive: true }); + } - for (const [projectPath, project] of config.projects) { - const currentProjectName = path.basename(projectPath); + const data = { + projects: Array.from(config.projects.entries()), + }; - if (currentProjectName === projectName) { - const workspace = project.workspaces.find((w: Workspace) => w.branch === branch); - if (workspace) { - return workspace.path; - } + fs.writeFileSync(this.configFile, JSON.stringify(data, null, 2)); + } catch (error) { + console.error("Error saving config:", error); } } - return null; -} - -/** - * WARNING: Never try to derive workspace path from workspace ID! - * This is a code smell that leads to bugs. - * - * The workspace path should always: - * 1. Be stored in WorkspaceMetadata when the workspace is created - * 2. Be retrieved from WorkspaceMetadata when needed - * 3. Be passed through the call stack explicitly - * - * Parsing workspaceId strings to derive paths is fragile and error-prone. - * The workspace path is established when the git worktree is created, - * and that canonical path should be preserved and used throughout. - */ + private getProjectName(projectPath: string): string { + return projectPath.split("/").pop() ?? projectPath.split("\\").pop() ?? "unknown"; + } -/** - * Get the session directory for a specific workspace - */ -export function getSessionDir(workspaceId: string): string { - return path.join(SESSIONS_DIR, workspaceId); -} + getWorkspacePath(projectPath: string, branch: string): string { + const projectName = this.getProjectName(projectPath); + return path.join(this.srcDir, projectName, branch); + } -/** - * Get all workspace metadata by scanning sessions directory and loading metadata files - * This centralizes the logic for workspace discovery and metadata loading - */ -export async function getAllWorkspaceMetadata(): Promise< - { workspaceId: string; metadata: WorkspaceMetadata }[] -> { - try { - // Scan sessions directory for workspace directories - await fsPromises.access(SESSIONS_DIR); - const entries = await fsPromises.readdir(SESSIONS_DIR, { withFileTypes: true }); - const workspaceIds = entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name); - - const workspaceMetadata: { workspaceId: string; metadata: WorkspaceMetadata }[] = []; - - for (const workspaceId of workspaceIds) { - try { - const metadataPath = path.join(getSessionDir(workspaceId), "metadata.json"); - const data = await fsPromises.readFile(metadataPath, "utf-8"); - const metadata = JSON.parse(data) as WorkspaceMetadata; - workspaceMetadata.push({ workspaceId, metadata }); - } catch (error) { - // Skip workspaces with missing or invalid metadata - console.warn(`Failed to load metadata for workspace ${workspaceId}:`, error); + /** + * Find a workspace path by project name and branch + * @returns The workspace path or null if not found + */ + findWorkspacePath(projectName: string, branch: string): string | null { + const config = this.loadConfigOrDefault(); + + for (const [projectPath, project] of config.projects) { + const currentProjectName = path.basename(projectPath); + + if (currentProjectName === projectName) { + const workspace = project.workspaces.find((w: Workspace) => w.branch === branch); + if (workspace) { + return workspace.path; + } } } - return workspaceMetadata; - } catch { - return []; // Sessions directory doesn't exist yet + return null; } -} -/** - * Load providers configuration from JSONC file - * Supports comments in JSONC format - */ -export function loadProvidersConfig(): ProvidersConfig | null { - try { - if (fs.existsSync(PROVIDERS_FILE)) { - const data = fs.readFileSync(PROVIDERS_FILE, "utf-8"); - return jsonc.parse(data) as ProvidersConfig; - } - } catch (error) { - console.error("Error loading providers config:", error); + /** + * WARNING: Never try to derive workspace path from workspace ID! + * This is a code smell that leads to bugs. + * + * The workspace path should always: + * 1. Be stored in WorkspaceMetadata when the workspace is created + * 2. Be retrieved from WorkspaceMetadata when needed + * 3. Be passed through the call stack explicitly + * + * Parsing workspaceId strings to derive paths is fragile and error-prone. + * The workspace path is established when the git worktree is created, + * and that canonical path should be preserved and used throughout. + */ + + /** + * Get the session directory for a specific workspace + */ + getSessionDir(workspaceId: string): string { + return path.join(this.sessionsDir, workspaceId); } - return null; -} + /** + * Get all workspace metadata by scanning sessions directory and loading metadata files + * This centralizes the logic for workspace discovery and metadata loading + */ + async getAllWorkspaceMetadata(): Promise<{ workspaceId: string; metadata: WorkspaceMetadata }[]> { + try { + // Scan sessions directory for workspace directories + await fsPromises.access(this.sessionsDir); + const entries = await fsPromises.readdir(this.sessionsDir, { withFileTypes: true }); + const workspaceIds = entries + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name); + + const workspaceMetadata: { workspaceId: string; metadata: WorkspaceMetadata }[] = []; + + for (const workspaceId of workspaceIds) { + try { + const metadataPath = path.join(this.getSessionDir(workspaceId), "metadata.json"); + const data = await fsPromises.readFile(metadataPath, "utf-8"); + const metadata = JSON.parse(data) as WorkspaceMetadata; + workspaceMetadata.push({ workspaceId, metadata }); + } catch (error) { + // Skip workspaces with missing or invalid metadata + console.warn(`Failed to load metadata for workspace ${workspaceId}:`, error); + } + } -/** - * Save providers configuration to JSONC file - * @param config The providers configuration to save - */ -export function saveProvidersConfig(config: ProvidersConfig): void { - try { - if (!fs.existsSync(CONFIG_DIR)) { - fs.mkdirSync(CONFIG_DIR, { recursive: true }); + return workspaceMetadata; + } catch { + return []; // Sessions directory doesn't exist yet + } + } + + /** + * Load providers configuration from JSONC file + * Supports comments in JSONC format + */ + loadProvidersConfig(): ProvidersConfig | null { + try { + if (fs.existsSync(this.providersFile)) { + const data = fs.readFileSync(this.providersFile, "utf-8"); + return jsonc.parse(data) as ProvidersConfig; + } + } catch (error) { + console.error("Error loading providers config:", error); } - // Format with 2-space indentation for readability - const jsonString = JSON.stringify(config, null, 2); + return null; + } + + /** + * Save providers configuration to JSONC file + * @param config The providers configuration to save + */ + saveProvidersConfig(config: ProvidersConfig): void { + try { + if (!fs.existsSync(this.rootDir)) { + fs.mkdirSync(this.rootDir, { recursive: true }); + } - // Add a comment header to the file - const contentWithComments = `// Providers configuration for cmux + // Format with 2-space indentation for readability + const jsonString = JSON.stringify(config, null, 2); + + // Add a comment header to the file + const contentWithComments = `// Providers configuration for cmux // Configure your AI providers here // Example: // { @@ -200,9 +216,13 @@ export function saveProvidersConfig(config: ProvidersConfig): void { // } ${jsonString}`; - fs.writeFileSync(PROVIDERS_FILE, contentWithComments); - } catch (error) { - console.error("Error saving providers config:", error); - throw error; // Re-throw to let caller handle + fs.writeFileSync(this.providersFile, contentWithComments); + } catch (error) { + console.error("Error saving providers config:", error); + throw error; // Re-throw to let caller handle + } } } + +// Default instance for application use +export const defaultConfig = new Config(); diff --git a/src/debug/costs.ts b/src/debug/costs.ts index 1cf6d1e545..805d2715ea 100644 --- a/src/debug/costs.ts +++ b/src/debug/costs.ts @@ -1,6 +1,6 @@ import * as fs from "fs"; import * as path from "path"; -import { getSessionDir } from "../config"; +import { defaultConfig } from "../config"; import { CmuxMessage } from "../types/message"; import { calculateTokenStats } from "../utils/tokenStatsCalculator"; @@ -12,7 +12,7 @@ export async function costsCommand(workspaceId: string) { console.log(`\n=== Cost Statistics for workspace: ${workspaceId} ===\n`); // Load chat history - const sessionDir = getSessionDir(workspaceId); + const sessionDir = defaultConfig.getSessionDir(workspaceId); const chatHistoryPath = path.join(sessionDir, "chat.jsonl"); if (!fs.existsSync(chatHistoryPath)) { diff --git a/src/debug/list-workspaces.ts b/src/debug/list-workspaces.ts index c0bb56d036..e1c9acbbbe 100644 --- a/src/debug/list-workspaces.ts +++ b/src/debug/list-workspaces.ts @@ -1,9 +1,9 @@ -import { load_config_or_default, findWorkspacePath } from "../config"; +import { defaultConfig } from "../config"; import * as path from "path"; import * as fs from "fs"; export function listWorkspacesCommand() { - const config = load_config_or_default(); + const config = defaultConfig.loadConfigOrDefault(); console.log("\n=== Configuration Debug ===\n"); console.log("Projects in config:", config.projects.size); @@ -32,7 +32,7 @@ export function listWorkspacesCommand() { ]; for (const test of testCases) { - const result = findWorkspacePath(test.project, test.branch); + const result = defaultConfig.findWorkspacePath(test.project, test.branch); console.log(`findWorkspacePath('${test.project}', '${test.branch}'):`); if (result) { console.log(` Found: ${result}`); diff --git a/src/git.ts b/src/git.ts index fb2571052d..48df9f716d 100644 --- a/src/git.ts +++ b/src/git.ts @@ -2,7 +2,7 @@ import { exec } from "child_process"; import { promisify } from "util"; import * as fs from "fs"; import * as path from "path"; -import { getWorkspacePath } from "./config"; +import { Config } from "./config"; const execAsync = promisify(exec); @@ -13,11 +13,12 @@ export interface WorktreeResult { } export async function createWorktree( + config: Config, projectPath: string, branchName: string ): Promise { try { - const workspacePath = getWorkspacePath(projectPath, branchName); + const workspacePath = config.getWorkspacePath(projectPath, branchName); // Create workspace directory if it doesn't exist if (!fs.existsSync(path.dirname(workspacePath))) { diff --git a/src/main.ts b/src/main.ts index 8d3321600f..d34b1d3ec3 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,14 +1,6 @@ import { app, BrowserWindow, ipcMain, dialog, Menu, MenuItemConstructorOptions } from "electron"; import * as path from "path"; -import { - load_config_or_default, - save_config, - Config, - ProjectConfig, - getAllWorkspaceMetadata, - loadProvidersConfig, - saveProvidersConfig, -} from "./config"; +import { Config, ProjectsConfig, ProjectConfig } from "./config"; import { createWorktree, removeWorktree } from "./git"; import { AIService } from "./services/aiService"; import { HistoryService } from "./services/historyService"; @@ -28,9 +20,10 @@ import { IPC_CHANNELS, getChatChannel } from "./constants/ipc-constants"; import type { SendMessageError } from "./types/errors"; import type { StreamErrorMessage } from "./types/ipc"; -const historyService = new HistoryService(); -const partialService = new PartialService(historyService); -const aiService = new AIService(historyService, partialService); +const config = new Config(); +const historyService = new HistoryService(config); +const partialService = new PartialService(config, historyService); +const aiService = new AIService(config, historyService, partialService); console.log("Main process starting..."); @@ -59,19 +52,19 @@ let mainWindow: BrowserWindow | null = null; // Register IPC handlers before creating window ipcMain.handle(IPC_CHANNELS.CONFIG_LOAD, () => { - const config = load_config_or_default(); + const projectsConfig = config.loadConfigOrDefault(); return { - projects: Array.from(config.projects.entries()), + projects: Array.from(projectsConfig.projects.entries()), }; }); ipcMain.handle( IPC_CHANNELS.CONFIG_SAVE, (_event, configData: { projects: [string, ProjectConfig][] }) => { - const config: Config = { + const projectsConfig: ProjectsConfig = { projects: new Map(configData.projects), }; - save_config(config); + config.saveConfig(projectsConfig); return true; } ); @@ -95,7 +88,7 @@ ipcMain.handle( IPC_CHANNELS.WORKSPACE_CREATE, async (_event, projectPath: string, branchName: string) => { // First create the git worktree - const result = await createWorktree(projectPath, branchName); + const result = await createWorktree(config, projectPath, branchName); if (result.success && result.path) { const projectName = @@ -127,13 +120,13 @@ ipcMain.handle( ipcMain.handle(IPC_CHANNELS.WORKSPACE_REMOVE, async (_event, workspaceId: string) => { try { // Load current config - const config = load_config_or_default(); + const projectsConfig = config.loadConfigOrDefault(); // Find workspace path from config let workspacePath: string | null = null; let foundProjectPath: string | null = null; - for (const [projectPath, projectConfig] of config.projects.entries()) { + for (const [projectPath, projectConfig] of projectsConfig.projects.entries()) { const workspace = projectConfig.workspaces.find((w) => { const projectName = path.basename(projectPath); const wsId = `${projectName}-${w.branch}`; @@ -163,10 +156,10 @@ ipcMain.handle(IPC_CHANNELS.WORKSPACE_REMOVE, async (_event, workspaceId: string // Update config to remove the workspace if (foundProjectPath && workspacePath) { - const projectConfig = config.projects.get(foundProjectPath); + const projectConfig = projectsConfig.projects.get(foundProjectPath); if (projectConfig) { projectConfig.workspaces = projectConfig.workspaces.filter((w) => w.path !== workspacePath); - save_config(config); + config.saveConfig(projectsConfig); } } @@ -189,7 +182,7 @@ ipcMain.handle(IPC_CHANNELS.WORKSPACE_REMOVE, async (_event, workspaceId: string ipcMain.handle(IPC_CHANNELS.WORKSPACE_LIST, async () => { try { - const workspaceData = await getAllWorkspaceMetadata(); + const workspaceData = await config.getAllWorkspaceMetadata(); return workspaceData.map(({ metadata }) => metadata); } catch (error) { console.error("Failed to list workspaces:", error); @@ -315,15 +308,15 @@ ipcMain.handle( (_event, provider: string, keyPath: string[], value: string) => { try { // Load current providers config or create empty - const config = loadProvidersConfig() ?? {}; + const providersConfig = config.loadProvidersConfig() ?? {}; // Ensure provider exists - if (!config[provider]) { - config[provider] = {}; + if (!providersConfig[provider]) { + providersConfig[provider] = {}; } // Set nested property value - let current = config[provider] as Record; + let current = providersConfig[provider] as Record; for (let i = 0; i < keyPath.length - 1; i++) { const key = keyPath[i]; if (!(key in current) || typeof current[key] !== "object" || current[key] === null) { @@ -338,7 +331,7 @@ ipcMain.handle( } // Save updated config - saveProvidersConfig(config); + config.saveProvidersConfig(providersConfig); return { success: true, data: undefined }; } catch (error) { @@ -383,7 +376,7 @@ ipcMain.on( () => void (async () => { try { - const workspaceData = await getAllWorkspaceMetadata(); + const workspaceData = await config.getAllWorkspaceMetadata(); // Emit current metadata for each workspace for (const { workspaceId, metadata } of workspaceData) { diff --git a/src/services/aiService.test.ts b/src/services/aiService.test.ts index 2ed20c9ea8..4a3ff2b4c9 100644 --- a/src/services/aiService.test.ts +++ b/src/services/aiService.test.ts @@ -6,14 +6,16 @@ import { describe, it, expect, beforeEach } from "bun:test"; import { AIService } from "./aiService"; import { HistoryService } from "./historyService"; import { PartialService } from "./partialService"; +import { Config } from "../config"; describe("AIService", () => { let service: AIService; beforeEach(() => { - const historyService = new HistoryService(); - const partialService = new PartialService(historyService); - service = new AIService(historyService, partialService); + const config = new Config(); + const historyService = new HistoryService(config); + const partialService = new PartialService(config, historyService); + service = new AIService(config, historyService, partialService); }); // Note: These tests are placeholders as Bun doesn't support Jest mocking diff --git a/src/services/aiService.ts b/src/services/aiService.ts index 6bd7f66785..46a0715dfa 100644 --- a/src/services/aiService.ts +++ b/src/services/aiService.ts @@ -6,7 +6,7 @@ import { createAnthropic } from "@ai-sdk/anthropic"; import { Result, Ok, Err } from "../types/result"; import { WorkspaceMetadata, WorkspaceMetadataSchema } from "../types/workspace"; import { CmuxMessage, createCmuxMessage } from "../types/message"; -import { SESSIONS_DIR, getSessionDir, loadProvidersConfig } from "../config"; +import { Config } from "../config"; import { StreamManager } from "./streamManager"; import type { SendMessageError } from "../types/errors"; import { getToolsForModel } from "../utils/tools"; @@ -32,9 +32,11 @@ export class AIService extends EventEmitter { private defaultModel = "anthropic:claude-opus-4-1"; // Default model string private historyService: HistoryService; private partialService: PartialService; + private config: Config; - constructor(historyService: HistoryService, partialService: PartialService) { + constructor(config: Config, historyService: HistoryService, partialService: PartialService) { super(); + this.config = config; this.historyService = historyService; this.partialService = partialService; this.streamManager = new StreamManager(historyService, partialService); @@ -62,14 +64,14 @@ export class AIService extends EventEmitter { private async ensureSessionsDir(): Promise { try { - await fs.mkdir(SESSIONS_DIR, { recursive: true }); + await fs.mkdir(this.config.sessionsDir, { recursive: true }); } catch (error) { log.error("Failed to create sessions directory:", error); } } private getMetadataPath(workspaceId: string): string { - return path.join(getSessionDir(workspaceId), this.METADATA_FILE); + return path.join(this.config.getSessionDir(workspaceId), this.METADATA_FILE); } async getWorkspaceMetadata(workspaceId: string): Promise> { @@ -99,7 +101,7 @@ export class AIService extends EventEmitter { metadata: WorkspaceMetadata ): Promise> { try { - const workspaceDir = getSessionDir(workspaceId); + const workspaceDir = this.config.getSessionDir(workspaceId); await fs.mkdir(workspaceDir, { recursive: true }); const metadataPath = this.getMetadataPath(workspaceId); await fs.writeFile(metadataPath, JSON.stringify(metadata, null, 2)); @@ -137,7 +139,7 @@ export class AIService extends EventEmitter { } // Load providers configuration - the ONLY source of truth - const providersConfig = loadProvidersConfig(); + const providersConfig = this.config.loadProvidersConfig(); const providerConfig = providersConfig?.[providerName]; if (!providerConfig) { @@ -319,7 +321,7 @@ export class AIService extends EventEmitter { async deleteWorkspace(workspaceId: string): Promise> { try { - const workspaceDir = getSessionDir(workspaceId); + const workspaceDir = this.config.getSessionDir(workspaceId); await fs.rm(workspaceDir, { recursive: true, force: true }); return Ok(undefined); } catch (error) { diff --git a/src/services/historyService.test.ts b/src/services/historyService.test.ts new file mode 100644 index 0000000000..cc2c9f6c4d --- /dev/null +++ b/src/services/historyService.test.ts @@ -0,0 +1,503 @@ +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import { HistoryService } from "./historyService"; +import { Config } from "../config"; +import { createCmuxMessage } from "../types/message"; +import * as fs from "fs/promises"; +import * as path from "path"; +import * as os from "os"; + +describe("HistoryService", () => { + let service: HistoryService; + let config: Config; + let tempDir: string; + + beforeEach(async () => { + // Create a temporary directory for test files + tempDir = path.join(os.tmpdir(), `cmux-test-${Date.now()}-${Math.random()}`); + await fs.mkdir(tempDir, { recursive: true }); + + // Create a Config with the temp directory + config = new Config(tempDir); + service = new HistoryService(config); + }); + + afterEach(async () => { + // Clean up temp directory + try { + await fs.rm(tempDir, { recursive: true, force: true }); + } catch (error) { + // Ignore cleanup errors + } + }); + + describe("getHistory", () => { + it("should return empty array when no history exists", async () => { + const result = await service.getHistory("workspace1"); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual([]); + } + }); + + it("should read messages from chat.jsonl", async () => { + const workspaceId = "workspace1"; + const workspaceDir = config.getSessionDir(workspaceId); + await fs.mkdir(workspaceDir, { recursive: true }); + + const msg1 = createCmuxMessage("msg1", "user", "Hello", { historySequence: 0 }); + const msg2 = createCmuxMessage("msg2", "assistant", "Hi there", { + historySequence: 1, + }); + + const chatPath = path.join(workspaceDir, "chat.jsonl"); + await fs.writeFile( + chatPath, + JSON.stringify({ ...msg1, workspaceId }) + + "\n" + + JSON.stringify({ ...msg2, workspaceId }) + + "\n" + ); + + const result = await service.getHistory(workspaceId); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toHaveLength(2); + expect(result.data[0].id).toBe("msg1"); + expect(result.data[1].id).toBe("msg2"); + } + }); + + it("should skip malformed JSON lines", async () => { + const workspaceId = "workspace1"; + const workspaceDir = config.getSessionDir(workspaceId); + await fs.mkdir(workspaceDir, { recursive: true }); + + const msg1 = createCmuxMessage("msg1", "user", "Hello", { historySequence: 0 }); + + const chatPath = path.join(workspaceDir, "chat.jsonl"); + await fs.writeFile( + chatPath, + JSON.stringify({ ...msg1, workspaceId }) + + "\n" + + "invalid json line\n" + + JSON.stringify({ + ...createCmuxMessage("msg2", "user", "World", { historySequence: 1 }), + workspaceId, + }) + + "\n" + ); + + const result = await service.getHistory(workspaceId); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toHaveLength(2); + expect(result.data[0].id).toBe("msg1"); + expect(result.data[1].id).toBe("msg2"); + } + }); + + it("should handle empty lines in history file", async () => { + const workspaceId = "workspace1"; + const workspaceDir = config.getSessionDir(workspaceId); + await fs.mkdir(workspaceDir, { recursive: true }); + + const msg1 = createCmuxMessage("msg1", "user", "Hello", { historySequence: 0 }); + + const chatPath = path.join(workspaceDir, "chat.jsonl"); + await fs.writeFile( + chatPath, + JSON.stringify({ ...msg1, workspaceId }) + "\n\n\n" // Extra empty lines + ); + + const result = await service.getHistory(workspaceId); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toHaveLength(1); + expect(result.data[0].id).toBe("msg1"); + } + }); + }); + + describe("appendToHistory", () => { + it("should create workspace directory if it doesn't exist", async () => { + const workspaceId = "workspace1"; + const msg = createCmuxMessage("msg1", "user", "Hello"); + + const result = await service.appendToHistory(workspaceId, msg); + + expect(result.success).toBe(true); + const workspaceDir = config.getSessionDir(workspaceId); + const exists = await fs + .access(workspaceDir) + .then(() => true) + .catch(() => false); + expect(exists).toBe(true); + }); + + it("should assign historySequence to message without metadata", async () => { + const workspaceId = "workspace1"; + const msg = createCmuxMessage("msg1", "user", "Hello"); + + const result = await service.appendToHistory(workspaceId, msg); + + expect(result.success).toBe(true); + + const history = await service.getHistory(workspaceId); + if (history.success) { + expect(history.data[0].metadata?.historySequence).toBe(0); + } + }); + + it("should assign sequential historySequence numbers", async () => { + const workspaceId = "workspace1"; + const msg1 = createCmuxMessage("msg1", "user", "Hello"); + const msg2 = createCmuxMessage("msg2", "assistant", "Hi"); + const msg3 = createCmuxMessage("msg3", "user", "How are you?"); + + await service.appendToHistory(workspaceId, msg1); + await service.appendToHistory(workspaceId, msg2); + await service.appendToHistory(workspaceId, msg3); + + const history = await service.getHistory(workspaceId); + if (history.success) { + expect(history.data).toHaveLength(3); + expect(history.data[0].metadata?.historySequence).toBe(0); + expect(history.data[1].metadata?.historySequence).toBe(1); + expect(history.data[2].metadata?.historySequence).toBe(2); + } + }); + + it("should preserve existing historySequence if provided", async () => { + const workspaceId = "workspace1"; + const msg = createCmuxMessage("msg1", "user", "Hello", { historySequence: 5 }); + + const result = await service.appendToHistory(workspaceId, msg); + + expect(result.success).toBe(true); + + const history = await service.getHistory(workspaceId); + if (history.success) { + expect(history.data[0].metadata?.historySequence).toBe(5); + } + }); + + it("should update sequence counter when message has higher sequence", async () => { + const workspaceId = "workspace1"; + const msg1 = createCmuxMessage("msg1", "user", "Hello", { historySequence: 10 }); + const msg2 = createCmuxMessage("msg2", "user", "World"); + + await service.appendToHistory(workspaceId, msg1); + await service.appendToHistory(workspaceId, msg2); + + const history = await service.getHistory(workspaceId); + if (history.success) { + expect(history.data[0].metadata?.historySequence).toBe(10); + expect(history.data[1].metadata?.historySequence).toBe(11); + } + }); + + it("should preserve other metadata fields", async () => { + const workspaceId = "workspace1"; + const msg = createCmuxMessage("msg1", "user", "Hello", { + timestamp: 123456, + model: "claude-opus-4", + tokens: 100, + }); + + await service.appendToHistory(workspaceId, msg); + + const history = await service.getHistory(workspaceId); + if (history.success) { + expect(history.data[0].metadata?.timestamp).toBe(123456); + expect(history.data[0].metadata?.model).toBe("claude-opus-4"); + expect(history.data[0].metadata?.tokens).toBe(100); + expect(history.data[0].metadata?.historySequence).toBeDefined(); + } + }); + + it("should include workspaceId in persisted message", async () => { + const workspaceId = "workspace1"; + const msg = createCmuxMessage("msg1", "user", "Hello"); + + await service.appendToHistory(workspaceId, msg); + + const workspaceDir = config.getSessionDir(workspaceId); + const chatPath = path.join(workspaceDir, "chat.jsonl"); + const content = await fs.readFile(chatPath, "utf-8"); + const persisted = JSON.parse(content.trim()); + + expect(persisted.workspaceId).toBe(workspaceId); + }); + }); + + describe("updateHistory", () => { + it("should update message by historySequence", async () => { + const workspaceId = "workspace1"; + const msg1 = createCmuxMessage("msg1", "user", "Hello"); + const msg2 = createCmuxMessage("msg2", "assistant", "Hi"); + + await service.appendToHistory(workspaceId, msg1); + await service.appendToHistory(workspaceId, msg2); + + const history = await service.getHistory(workspaceId); + if (history.success) { + const updatedMsg = createCmuxMessage("msg1", "user", "Updated Hello", { + historySequence: history.data[0].metadata?.historySequence, + }); + + const result = await service.updateHistory(workspaceId, updatedMsg); + expect(result.success).toBe(true); + + const newHistory = await service.getHistory(workspaceId); + if (newHistory.success) { + expect(newHistory.data[0].parts[0]).toMatchObject({ + type: "text", + text: "Updated Hello", + }); + expect(newHistory.data[0].metadata?.historySequence).toBe(0); + } + } + }); + + it("should return error if message has no historySequence", async () => { + const workspaceId = "workspace1"; + const msg = createCmuxMessage("msg1", "user", "Hello"); + + const result = await service.updateHistory(workspaceId, msg); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toContain("without historySequence"); + } + }); + + it("should return error if message with historySequence not found", async () => { + const workspaceId = "workspace1"; + const msg1 = createCmuxMessage("msg1", "user", "Hello"); + + await service.appendToHistory(workspaceId, msg1); + + const msg2 = createCmuxMessage("msg2", "user", "Not found", { historySequence: 99 }); + const result = await service.updateHistory(workspaceId, msg2); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toContain("No message found"); + } + }); + + it("should preserve historySequence when updating", async () => { + const workspaceId = "workspace1"; + const msg = createCmuxMessage("msg1", "user", "Hello"); + + await service.appendToHistory(workspaceId, msg); + + const history = await service.getHistory(workspaceId); + if (history.success) { + const originalSequence = history.data[0].metadata?.historySequence; + const updatedMsg = createCmuxMessage("msg1", "user", "Updated", { + historySequence: originalSequence, + }); + + await service.updateHistory(workspaceId, updatedMsg); + + const newHistory = await service.getHistory(workspaceId); + if (newHistory.success) { + expect(newHistory.data[0].metadata?.historySequence).toBe(originalSequence); + } + } + }); + }); + + describe("truncateAfterMessage", () => { + it("should remove message and all subsequent messages", async () => { + const workspaceId = "workspace1"; + const msg1 = createCmuxMessage("msg1", "user", "First"); + const msg2 = createCmuxMessage("msg2", "assistant", "Second"); + const msg3 = createCmuxMessage("msg3", "user", "Third"); + const msg4 = createCmuxMessage("msg4", "assistant", "Fourth"); + + await service.appendToHistory(workspaceId, msg1); + await service.appendToHistory(workspaceId, msg2); + await service.appendToHistory(workspaceId, msg3); + await service.appendToHistory(workspaceId, msg4); + + const result = await service.truncateAfterMessage(workspaceId, "msg2"); + + expect(result.success).toBe(true); + + const history = await service.getHistory(workspaceId); + if (history.success) { + expect(history.data).toHaveLength(1); + expect(history.data[0].id).toBe("msg1"); + } + }); + + it("should update sequence counter after truncation", async () => { + const workspaceId = "workspace1"; + const msg1 = createCmuxMessage("msg1", "user", "First"); + const msg2 = createCmuxMessage("msg2", "assistant", "Second"); + const msg3 = createCmuxMessage("msg3", "user", "Third"); + + await service.appendToHistory(workspaceId, msg1); + await service.appendToHistory(workspaceId, msg2); + await service.appendToHistory(workspaceId, msg3); + + await service.truncateAfterMessage(workspaceId, "msg2"); + + // Append a new message and check its sequence + const msg4 = createCmuxMessage("msg4", "user", "New message"); + await service.appendToHistory(workspaceId, msg4); + + const history = await service.getHistory(workspaceId); + if (history.success) { + expect(history.data).toHaveLength(2); + expect(history.data[0].metadata?.historySequence).toBe(0); + expect(history.data[1].metadata?.historySequence).toBe(1); + } + }); + + it("should reset sequence counter when truncating all messages", async () => { + const workspaceId = "workspace1"; + const msg1 = createCmuxMessage("msg1", "user", "First"); + const msg2 = createCmuxMessage("msg2", "assistant", "Second"); + + await service.appendToHistory(workspaceId, msg1); + await service.appendToHistory(workspaceId, msg2); + + await service.truncateAfterMessage(workspaceId, "msg1"); + + const msg3 = createCmuxMessage("msg3", "user", "New"); + await service.appendToHistory(workspaceId, msg3); + + const history = await service.getHistory(workspaceId); + if (history.success) { + expect(history.data).toHaveLength(1); + expect(history.data[0].metadata?.historySequence).toBe(0); + } + }); + + it("should return error if message not found", async () => { + const workspaceId = "workspace1"; + const msg = createCmuxMessage("msg1", "user", "Hello"); + + await service.appendToHistory(workspaceId, msg); + + const result = await service.truncateAfterMessage(workspaceId, "nonexistent"); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toContain("not found"); + } + }); + }); + + describe("clearHistory", () => { + it("should delete chat.jsonl file", async () => { + const workspaceId = "workspace1"; + const msg = createCmuxMessage("msg1", "user", "Hello"); + + await service.appendToHistory(workspaceId, msg); + + const result = await service.clearHistory(workspaceId); + + expect(result.success).toBe(true); + + const workspaceDir = config.getSessionDir(workspaceId); + const chatPath = path.join(workspaceDir, "chat.jsonl"); + const exists = await fs + .access(chatPath) + .then(() => true) + .catch(() => false); + expect(exists).toBe(false); + }); + + it("should reset sequence counter", async () => { + const workspaceId = "workspace1"; + const msg1 = createCmuxMessage("msg1", "user", "Hello"); + + await service.appendToHistory(workspaceId, msg1); + await service.clearHistory(workspaceId); + + const msg2 = createCmuxMessage("msg2", "user", "New message"); + await service.appendToHistory(workspaceId, msg2); + + const history = await service.getHistory(workspaceId); + if (history.success) { + expect(history.data[0].metadata?.historySequence).toBe(0); + } + }); + + it("should succeed when clearing non-existent history", async () => { + const workspaceId = "workspace-no-history"; + + const result = await service.clearHistory(workspaceId); + + expect(result.success).toBe(true); + }); + + it("should reset sequence counter even when file doesn't exist", async () => { + const workspaceId = "workspace-no-history"; + + await service.clearHistory(workspaceId); + + const msg = createCmuxMessage("msg1", "user", "First"); + await service.appendToHistory(workspaceId, msg); + + const history = await service.getHistory(workspaceId); + if (history.success) { + expect(history.data[0].metadata?.historySequence).toBe(0); + } + }); + }); + + describe("sequence number initialization", () => { + it("should initialize sequence from existing history", async () => { + const workspaceId = "workspace1"; + const workspaceDir = config.getSessionDir(workspaceId); + await fs.mkdir(workspaceDir, { recursive: true }); + + // Manually create history with specific sequences + const msg1 = createCmuxMessage("msg1", "user", "Hello", { historySequence: 0 }); + const msg2 = createCmuxMessage("msg2", "assistant", "Hi", { historySequence: 1 }); + + const chatPath = path.join(workspaceDir, "chat.jsonl"); + await fs.writeFile( + chatPath, + JSON.stringify({ ...msg1, workspaceId }) + + "\n" + + JSON.stringify({ ...msg2, workspaceId }) + + "\n" + ); + + // Create new service instance to ensure fresh initialization + const newService = new HistoryService(config); + + // Append a new message - should get sequence 2 + const msg3 = createCmuxMessage("msg3", "user", "How are you?"); + await newService.appendToHistory(workspaceId, msg3); + + const history = await newService.getHistory(workspaceId); + if (history.success) { + expect(history.data).toHaveLength(3); + expect(history.data[2].metadata?.historySequence).toBe(2); + } + }); + + it("should start from 0 for new workspace", async () => { + const workspaceId = "new-workspace"; + const msg = createCmuxMessage("msg1", "user", "First message"); + + await service.appendToHistory(workspaceId, msg); + + const history = await service.getHistory(workspaceId); + if (history.success) { + expect(history.data[0].metadata?.historySequence).toBe(0); + } + }); + }); +}); diff --git a/src/services/historyService.ts b/src/services/historyService.ts index 62486ccf74..d9130a607f 100644 --- a/src/services/historyService.ts +++ b/src/services/historyService.ts @@ -2,7 +2,7 @@ import * as fs from "fs/promises"; import * as path from "path"; import { Result, Ok, Err } from "../types/result"; import { CmuxMessage } from "../types/message"; -import { getSessionDir } from "../config"; +import { Config } from "../config"; import { MutexMap } from "../utils/mutexMap"; /** @@ -19,9 +19,14 @@ export class HistoryService { private sequenceCounters = new Map(); // File operation locks per workspace to prevent race conditions private fileLocks = new MutexMap(); + private config: Config; + + constructor(config: Config) { + this.config = config; + } private getChatHistoryPath(workspaceId: string): string { - return path.join(getSessionDir(workspaceId), this.CHAT_FILE); + return path.join(this.config.getSessionDir(workspaceId), this.CHAT_FILE); } /** @@ -112,7 +117,7 @@ export class HistoryService { message: CmuxMessage ): Promise> { try { - const workspaceDir = getSessionDir(workspaceId); + const workspaceDir = this.config.getSessionDir(workspaceId); await fs.mkdir(workspaceDir, { recursive: true }); const historyPath = this.getChatHistoryPath(workspaceId); diff --git a/src/services/log.ts b/src/services/log.ts index fc5ba8f80e..49dd64bd48 100644 --- a/src/services/log.ts +++ b/src/services/log.ts @@ -10,9 +10,9 @@ import * as fs from "fs"; import * as path from "path"; -import { CONFIG_DIR } from "../config"; +import { defaultConfig } from "../config"; -const DEBUG_OBJ_DIR = path.join(CONFIG_DIR, "debug_obj"); +const DEBUG_OBJ_DIR = path.join(defaultConfig.rootDir, "debug_obj"); /** * Check if debug mode is enabled diff --git a/src/services/partialService.ts b/src/services/partialService.ts index 917912c6cd..9b20bdb859 100644 --- a/src/services/partialService.ts +++ b/src/services/partialService.ts @@ -2,7 +2,7 @@ import * as fs from "fs/promises"; import * as path from "path"; import { Result, Ok, Err } from "../types/result"; import { CmuxMessage } from "../types/message"; -import { getSessionDir } from "../config"; +import { Config } from "../config"; import { HistoryService } from "./historyService"; import { MutexMap } from "../utils/mutexMap"; @@ -27,13 +27,15 @@ export class PartialService { private readonly PARTIAL_FILE = "partial.json"; private readonly historyService: HistoryService; private readonly fileLocks = new MutexMap(); + private config: Config; - constructor(historyService: HistoryService) { + constructor(config: Config, historyService: HistoryService) { + this.config = config; this.historyService = historyService; } private getPartialPath(workspaceId: string): string { - return path.join(getSessionDir(workspaceId), this.PARTIAL_FILE); + return path.join(this.config.getSessionDir(workspaceId), this.PARTIAL_FILE); } /** @@ -60,7 +62,7 @@ export class PartialService { async writePartial(workspaceId: string, message: CmuxMessage): Promise> { return this.fileLocks.withLock(workspaceId, async () => { try { - const workspaceDir = getSessionDir(workspaceId); + const workspaceDir = this.config.getSessionDir(workspaceId); await fs.mkdir(workspaceDir, { recursive: true }); const partialPath = this.getPartialPath(workspaceId); From 56fc8f2cd3afafa07fac5e6a574c129c144d1264 Mon Sep 17 00:00:00 2001 From: Ammar Bandukwala Date: Thu, 2 Oct 2025 10:48:57 -0500 Subject: [PATCH 3/3] Resolve lint errors --- src/git.ts | 2 +- src/services/historyService.test.ts | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/git.ts b/src/git.ts index 48df9f716d..7a9495936c 100644 --- a/src/git.ts +++ b/src/git.ts @@ -2,7 +2,7 @@ import { exec } from "child_process"; import { promisify } from "util"; import * as fs from "fs"; import * as path from "path"; -import { Config } from "./config"; +import type { Config } from "./config"; const execAsync = promisify(exec); diff --git a/src/services/historyService.test.ts b/src/services/historyService.test.ts index cc2c9f6c4d..f222ee9d5e 100644 --- a/src/services/historyService.test.ts +++ b/src/services/historyService.test.ts @@ -25,7 +25,7 @@ describe("HistoryService", () => { // Clean up temp directory try { await fs.rm(tempDir, { recursive: true, force: true }); - } catch (error) { + } catch { // Ignore cleanup errors } }); @@ -228,7 +228,11 @@ describe("HistoryService", () => { const workspaceDir = config.getSessionDir(workspaceId); const chatPath = path.join(workspaceDir, "chat.jsonl"); const content = await fs.readFile(chatPath, "utf-8"); - const persisted = JSON.parse(content.trim()); + const persisted = JSON.parse(content.trim()) as { + workspaceId: string; + id: string; + role: string; + }; expect(persisted.workspaceId).toBe(workspaceId); });