diff --git a/package-lock.json b/package-lock.json index 8907b3ee..e75175e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4036,13 +4036,13 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.4.tgz", - "integrity": "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==", + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", + "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, @@ -4472,9 +4472,9 @@ } }, "node_modules/codemie-sdk": { - "version": "0.1.330", - "resolved": "https://registry.npmjs.org/codemie-sdk/-/codemie-sdk-0.1.330.tgz", - "integrity": "sha512-aEDXUsnvYo2/t5acVDHudlSXwlZogL+cxk6BEkxAOv/279itvFlTqlwHMKvyCPl1a6lCRYIpOajbWpCKGIpHgA==", + "version": "0.1.333", + "resolved": "https://registry.npmjs.org/codemie-sdk/-/codemie-sdk-0.1.333.tgz", + "integrity": "sha512-xbzB24bd366073UhJWVh0zrMal5iu0DSSU8aC1VQvW3DFNOxdqfDCdrUDdVSCJt5wti5WOv3D51koOz/L1IhOA==", "license": "Apache-2.0", "dependencies": { "axios": "^1.9.0", diff --git a/src/agents/codemie-code/tools/assistant-invocation.ts b/src/agents/codemie-code/tools/assistant-invocation.ts index 0853c853..330560d1 100644 --- a/src/agents/codemie-code/tools/assistant-invocation.ts +++ b/src/agents/codemie-code/tools/assistant-invocation.ts @@ -17,6 +17,7 @@ import type { CodemieAssistant } from '@/env/types.js'; interface HistoryMessage { role: 'User' | 'Assistant'; message?: string; + message_raw?: string; } /** diff --git a/src/agents/plugins/claude/session/processors/claude.conversations-processor.ts b/src/agents/plugins/claude/session/processors/claude.conversations-processor.ts index 9d3cb3ed..3192e644 100644 --- a/src/agents/plugins/claude/session/processors/claude.conversations-processor.ts +++ b/src/agents/plugins/claude/session/processors/claude.conversations-processor.ts @@ -14,6 +14,7 @@ import type { SessionProcessor, ProcessingContext, ProcessingResult } from '../../../../core/session/BaseProcessor.js'; import type { ParsedSession } from '../../../../core/session/BaseSessionAdapter.js'; +import { CONVERSATION_SYNC_STATUS } from '../../../../../providers/plugins/sso/session/processors/conversations/types.js'; import { logger } from '../../../../../utils/logger.js'; import { getSessionConversationPath } from '../../../../core/session/session-config.js'; @@ -124,7 +125,7 @@ export class ConversationsProcessor implements SessionProcessor { conversationId: context.agentSessionId, history: result.history }, - status: 'pending' as const + status: CONVERSATION_SYNC_STATUS.PENDING }; await appendFile(conversationsPath, JSON.stringify(payloadRecord) + '\n'); diff --git a/src/agents/plugins/gemini/session/processors/gemini.conversations-processor.ts b/src/agents/plugins/gemini/session/processors/gemini.conversations-processor.ts index b039d3b3..6c64e399 100644 --- a/src/agents/plugins/gemini/session/processors/gemini.conversations-processor.ts +++ b/src/agents/plugins/gemini/session/processors/gemini.conversations-processor.ts @@ -16,7 +16,8 @@ import type { SessionProcessor, ProcessingContext, ProcessingResult } from '../../../../core/session/BaseProcessor.js'; import type { ParsedSession } from '../../../../core/session/BaseSessionAdapter.js'; -import type { ConversationPayloadRecord } from '../../../../../providers/plugins/sso/session/processors/conversations/conversation-types.js'; +import type { ConversationPayloadRecord } from '../../../../../providers/plugins/sso/session/processors/conversations/types.js'; +import { CONVERSATION_SYNC_STATUS } from '../../../../../providers/plugins/sso/session/processors/conversations/types.js'; import { logger } from '../../../../../utils/logger.js'; import { getSessionConversationPath } from '../../../../core/session/session-config.js'; import { SessionStore } from '../../../../core/session/SessionStore.js'; @@ -175,7 +176,7 @@ export class GeminiConversationsProcessor implements SessionProcessor { conversationId: context.agentSessionId!, // From processing context history: [userRecord, assistantRecord] }, - status: 'pending' + status: CONVERSATION_SYNC_STATUS.PENDING }; payloads.push(payload); diff --git a/src/cli/commands/assistants/__tests__/chat.test.ts b/src/cli/commands/assistants/__tests__/chat.test.ts index ce1cef33..b81824ef 100644 --- a/src/cli/commands/assistants/__tests__/chat.test.ts +++ b/src/cli/commands/assistants/__tests__/chat.test.ts @@ -3,7 +3,7 @@ */ import { describe, it, expect, beforeEach } from 'vitest'; -import { createAssistantsChatCommand } from '../chat.js'; +import { createAssistantsChatCommand } from '../chat/index.js'; import { MESSAGES } from '../constants.js'; describe('Assistants Chat Command', () => { @@ -74,9 +74,9 @@ describe('Assistants Chat Command', () => { }); describe('Command Options', () => { - it('should have verbose and conversation-id options', () => { + it('should have verbose, conversation-id, and load-history options', () => { const command = createAssistantsChatCommand(); - expect(command.options).toHaveLength(2); + expect(command.options).toHaveLength(3); }); it('should accept --verbose flag', () => { diff --git a/src/cli/commands/assistants/__tests__/index.test.ts b/src/cli/commands/assistants/__tests__/index.test.ts index 38e97ca2..fc13279b 100644 --- a/src/cli/commands/assistants/__tests__/index.test.ts +++ b/src/cli/commands/assistants/__tests__/index.test.ts @@ -19,7 +19,7 @@ describe('Assistants Command (Parent)', () => { }); it('should have correct description', () => { - expect(command.description()).toBe('Manage CodeMie assistants'); + expect(command.description()).toBe('Chat with CodeMie assistant'); }); it('should be configured as a Commander command', () => { @@ -89,7 +89,7 @@ describe('Assistants Command (Parent)', () => { // Assistants command is now focused on chat operations // Setup has been moved to `codemie setup assistants` expect(command.name()).toBe('assistants'); - expect(command.description()).toContain('assistants'); + expect(command.description()).toContain('assistant'); }); }); diff --git a/src/cli/commands/assistants/chat/historyLoader.ts b/src/cli/commands/assistants/chat/historyLoader.ts new file mode 100644 index 00000000..23296288 --- /dev/null +++ b/src/cli/commands/assistants/chat/historyLoader.ts @@ -0,0 +1,97 @@ +/** + * Conversation History Loader + * + * Loads conversation history from session files stored in ~/.codemie/sessions + */ + +import { existsSync } from 'fs'; + +/** + * Maximum number of history messages to load from previous sessions + * This prevents sending excessively large context to the API + */ +const MAX_HISTORY_MESSAGES = 20; +import { logger } from '@/utils/logger.js'; +import { getSessionConversationPath } from '@/agents/core/session/session-config.js'; +import { readJSONL } from '@/providers/plugins/sso/session/utils/jsonl-reader.js'; +import { + type ConversationPayloadRecord, + CONVERSATION_SYNC_STATUS +} from '@/providers/plugins/sso/session/processors/conversations/types.js'; +import type { HistoryMessage } from '../constants.js'; + +/** + * Load conversation history from session files + * + * @param conversationId - Optional conversation ID to load history for + * @returns Array of history messages, or empty array if none found or on error + * + * @example + * ```ts + * const history = await loadConversationHistory('abc-123'); + * console.log(`Loaded ${history.length} messages`); + * ``` + */ +export async function loadConversationHistory( + conversationId: string | undefined +): Promise { + if (!conversationId) return []; + + try { + const filePath = getSessionConversationPath(conversationId); + + // File doesn't exist yet - normal for first-time conversations + if (!existsSync(filePath)) { + logger.debug('Conversation history file not found (first-time conversation)', { + conversationId, + filePath + }); + return []; + } + + const records = await readJSONL(filePath); + const validRecords = records.filter( + record => record.status === CONVERSATION_SYNC_STATUS.SUCCESS || + record.status === CONVERSATION_SYNC_STATUS.PENDING + ); + + if (validRecords.length === 0) { + logger.debug('No valid conversation records found', { + conversationId, + totalRecords: records.length + }); + return []; + } + + const allMessages = validRecords + .flatMap(record => record.payload?.history ?? []) + .reduce((map, msg) => { + const key = `${msg.role}:${msg.message}:${msg.history_index ?? 0}`; + if (!map.has(key)) { + map.set(key, { + role: msg.role, + message: msg.message, + message_raw: msg.message + }); + } + return map; + }, new Map()); + + if (allMessages.size === 0) { + logger.debug('No history messages found in conversation records', { + conversationId + }); + return []; + } + + const allHistory: HistoryMessage[] = Array.from(allMessages.values()); + + return allHistory.slice(-MAX_HISTORY_MESSAGES); + } catch (error) { + logger.error('Failed to load conversation history', { + conversationId, + error: error instanceof Error ? error.message : String(error) + }); + return []; + } +} diff --git a/src/cli/commands/assistants/chat.ts b/src/cli/commands/assistants/chat/index.ts similarity index 67% rename from src/cli/commands/assistants/chat.ts rename to src/cli/commands/assistants/chat/index.ts index 9ddbcdf0..1ec31a3b 100644 --- a/src/cli/commands/assistants/chat.ts +++ b/src/cli/commands/assistants/chat/index.ts @@ -11,10 +11,13 @@ import inquirer from 'inquirer'; import { logger } from '@/utils/logger.js'; import { ConfigLoader } from '@/utils/config.js'; import { createErrorContext, formatErrorForUser } from '@/utils/errors.js'; +import { getAuthenticatedClient, promptReauthentication } from '@/utils/auth.js'; import type { CodemieAssistant, ProviderProfile } from '@/env/types.js'; import type { CodeMieClient } from 'codemie-sdk'; -import { EXIT_PROMPTS, ROLES, MESSAGES, type HistoryMessage } from './constants.js'; -import { getAuthenticatedClient, promptReauthentication } from '@/utils/auth.js'; +import { ROLES, MESSAGES, type HistoryMessage } from '../constants.js'; +import { loadConversationHistory } from './historyLoader.js'; +import { isExitCommand, enableVerboseMode } from './utils.js'; +import type { ChatCommandOptions, SingleMessageOptions } from './types.js'; /** Assistant label color */ const ASSISTANT_LABEL_COLOR = [177, 185, 249] as const; @@ -31,20 +34,18 @@ export function createAssistantsChatCommand(): Command { .argument('[message]', MESSAGES.CHAT.ARGUMENT_MESSAGE) .option('-v, --verbose', MESSAGES.SHARED.OPTION_VERBOSE) .option('--conversation-id ', 'Conversation ID for maintaining context across calls') - .action(async (assistantId: string | undefined, message: string | undefined, options: { - verbose?: boolean; - conversationId?: string; - }) => { + .option('--load-history', 'Load conversation history from previous sessions (default: true)', true) + .action(async ( + assistantId: string | undefined, + message: string | undefined, + options: ChatCommandOptions + ) => { if (options.verbose) { - process.env.CODEMIE_DEBUG = 'true'; - const logFilePath = logger.getLogFilePath(); - if (logFilePath) { - console.log(chalk.dim(`Debug logs: ${logFilePath}\n`)); - } + enableVerboseMode(); } try { - await chatWithAssistant(assistantId, message, options.conversationId); + await chatWithAssistant(assistantId, message, options); } catch (error: unknown) { const context = createErrorContext(error); logger.error('Failed to chat with assistant', context); @@ -62,26 +63,28 @@ export function createAssistantsChatCommand(): Command { async function chatWithAssistant( assistantId: string | undefined, message: string | undefined, - conversationId?: string + options: ChatCommandOptions ): Promise { const config = await ConfigLoader.load(); const registeredAssistants = config.codemieAssistants || []; - const client = await getAuthenticatedClient(config); - const resolvedConversationId = conversationId || process.env.CODEMIE_SESSION_ID; - - if (assistantId && message !== undefined) { - if (message.trim().length === 0) { - console.error(chalk.red('Error: Message cannot be empty')); - process.exit(1); - } + const conversationId = options.conversationId || process.env.CODEMIE_SESSION_ID; + if (assistantId && message) { // Single-message mode (for Claude Code) const assistant = findAssistant(registeredAssistants, assistantId); - await sendSingleMessage(client, assistant, message, { quiet: true }, config, resolvedConversationId); + await sendSingleMessage( + client, + assistant, + message, + { quiet: true }, + config, + conversationId, + options.loadHistory + ); } else { const assistant = await promptAssistantSelection(registeredAssistants); - await interactiveChat(client, assistant, config, resolvedConversationId); + await interactiveChat(client, assistant, config, conversationId, options.loadHistory); } } @@ -90,15 +93,22 @@ async function chatWithAssistant( */ function findAssistant(assistants: CodemieAssistant[], assistantId: string): CodemieAssistant { if (assistants.length === 0) { - console.error(chalk.red(MESSAGES.SHARED.ERROR_NO_ASSISTANTS)); - console.log(chalk.dim(MESSAGES.SHARED.HINT_REGISTER) + chalk.cyan(MESSAGES.SHARED.SETUP_ASSISTANTS_COMMAND) + chalk.dim(MESSAGES.SHARED.HINT_REGISTER_SUFFIX)); + console.log( + chalk.dim(MESSAGES.SHARED.HINT_REGISTER) + + chalk.cyan(MESSAGES.SHARED.SETUP_ASSISTANTS_COMMAND) + + chalk.dim(MESSAGES.SHARED.HINT_REGISTER_SUFFIX) + ); process.exit(1); } const assistant = assistants.find(a => a.id === assistantId); if (!assistant) { console.error(chalk.red(MESSAGES.SHARED.ERROR_ASSISTANT_NOT_FOUND(assistantId))); - console.log(chalk.dim(MESSAGES.SHARED.HINT_REGISTER) + chalk.cyan(MESSAGES.SHARED.SETUP_ASSISTANTS_COMMAND) + chalk.dim(MESSAGES.SHARED.HINT_SEE_ASSISTANTS)); + console.log( + chalk.dim(MESSAGES.SHARED.HINT_REGISTER) + + chalk.cyan(MESSAGES.SHARED.SETUP_ASSISTANTS_COMMAND) + + chalk.dim(MESSAGES.SHARED.HINT_SEE_ASSISTANTS) + ); process.exit(1); } return assistant; @@ -110,17 +120,18 @@ function findAssistant(assistants: CodemieAssistant[], assistantId: string): Cod async function promptAssistantSelection(assistants: CodemieAssistant[]): Promise { if (assistants.length === 0) { console.error(chalk.red(MESSAGES.SHARED.ERROR_NO_ASSISTANTS)); - console.log(chalk.dim(MESSAGES.SHARED.HINT_REGISTER) + chalk.cyan(MESSAGES.SHARED.SETUP_ASSISTANTS_COMMAND) + chalk.dim(MESSAGES.SHARED.HINT_REGISTER_SUFFIX)); + console.log( + chalk.dim(MESSAGES.SHARED.HINT_REGISTER) + + chalk.cyan(MESSAGES.SHARED.SETUP_ASSISTANTS_COMMAND) + + chalk.dim(MESSAGES.SHARED.HINT_REGISTER_SUFFIX) + ); process.exit(1); } - const choices = assistants.map(assistant => { - const slugText = chalk.dim(`(/${assistant.slug})`); - return { - name: `${assistant.name} ${slugText}`, - value: assistant.id - }; - }); + const choices = assistants.map(assistant => ({ + name: `${assistant.name} ${chalk.dim(`(/${assistant.slug})`)}`, + value: assistant.id + })); const { selectedId } = await inquirer.prompt<{ selectedId: string }>([ { @@ -141,13 +152,26 @@ async function interactiveChat( client: CodeMieClient, assistant: CodemieAssistant, config: ProviderProfile, - conversationId?: string + conversationId?: string, + loadHistory: boolean = true ): Promise { - const history: HistoryMessage[] = []; + // Load existing conversation history if enabled + const history: HistoryMessage[] = loadHistory + ? await loadConversationHistory(conversationId) + : []; + + if (history.length > 0) { + logger.debug('Loaded conversation history', { + conversationId, + messageCount: history.length + }); + console.log(chalk.dim(`Loaded ${history.length} previous message(s)\n`)); + } console.log(chalk.bold.cyan(MESSAGES.CHAT.HEADER(assistant.name))); console.log(chalk.dim(MESSAGES.CHAT.INSTRUCTIONS)); + // Chat loop while (true) { const { message } = await inquirer.prompt<{ message: string }>([ { @@ -170,7 +194,10 @@ async function interactiveChat( const response = await sendMessageWithHistory(client, assistant, message, history, conversationId); spinner.stop(); - console.log(chalk.rgb(...ASSISTANT_LABEL_COLOR)(`[Assistant @${assistant.slug}]`), response || MESSAGES.CHAT.FALLBACK_NO_RESPONSE); + console.log( + chalk.rgb(...ASSISTANT_LABEL_COLOR)(`[Assistant @${assistant.slug}]`), + response || MESSAGES.CHAT.FALLBACK_NO_RESPONSE + ); console.log(''); history.push( @@ -180,7 +207,6 @@ async function interactiveChat( } catch (error) { spinner.fail(chalk.red(MESSAGES.CHAT.ERROR_SEND_FAILED)); await handleChatError(error, config); - console.log(chalk.yellow(MESSAGES.CHAT.RETRY_PROMPT)); } } @@ -193,12 +219,22 @@ async function sendSingleMessage( client: CodeMieClient, assistant: CodemieAssistant, message: string, - options: { quiet?: boolean }, + options: SingleMessageOptions, config: ProviderProfile, - conversationId?: string + conversationId?: string, + loadHistory: boolean = true ): Promise { try { - const response = await sendMessageWithHistory(client, assistant, message, [], conversationId); + const history = loadHistory ? await loadConversationHistory(conversationId) : []; + + if (history.length > 0) { + logger.debug('Loaded conversation history for single message', { + conversationId, + messageCount: history.length + }); + } + + const response = await sendMessageWithHistory(client, assistant, message, history, conversationId); if (options.quiet) { console.log(response || MESSAGES.CHAT.FALLBACK_NO_RESPONSE); @@ -228,25 +264,18 @@ async function sendMessageWithHistory( assistantName: assistant.name, messageLength: message.length, historyLength: history.length, - conversationId: conversationId + conversationId }); const response = await client.assistants.chat(assistant.id, { conversation_id: conversationId, text: message, - history: history, + content_raw: message, + history, stream: false }); - return (response.generated as string) ?? '' -} - -/** - * Check if message is an exit prompt - */ -function isExitCommand(message: string): boolean { - const normalized = message.toLowerCase().trim(); - return EXIT_PROMPTS.includes(normalized as any); + return (response.generated as string) ?? ''; } /** diff --git a/src/cli/commands/assistants/chat/types.ts b/src/cli/commands/assistants/chat/types.ts new file mode 100644 index 00000000..3c5e2620 --- /dev/null +++ b/src/cli/commands/assistants/chat/types.ts @@ -0,0 +1,30 @@ +/** + * Chat Command Types + */ + +import type { HistoryMessage } from '../constants.js'; + +/** + * Chat command options from CLI + */ +export interface ChatCommandOptions { + verbose?: boolean; + conversationId?: string; + loadHistory?: boolean; +} + +/** + * Single message options + */ +export interface SingleMessageOptions { + quiet?: boolean; +} + +/** + * Message send request + */ +export interface MessageSendRequest { + message: string; + history: HistoryMessage[]; + conversationId?: string; +} diff --git a/src/cli/commands/assistants/chat/utils.ts b/src/cli/commands/assistants/chat/utils.ts new file mode 100644 index 00000000..c8be7f87 --- /dev/null +++ b/src/cli/commands/assistants/chat/utils.ts @@ -0,0 +1,26 @@ +/** + * Chat Utility Functions + */ + +import chalk from 'chalk'; +import { logger } from '@/utils/logger.js'; +import { EXIT_PROMPTS } from '../constants.js'; + +/** + * Check if message is an exit command + */ +export function isExitCommand(message: string): boolean { + const normalized = message.toLowerCase().trim(); + return EXIT_PROMPTS.includes(normalized as any); +} + +/** + * Enable verbose/debug mode + */ +export function enableVerboseMode(): void { + process.env.CODEMIE_DEBUG = 'true'; + const logFilePath = logger.getLogFilePath(); + if (logFilePath) { + console.log(chalk.dim(`Debug logs: ${logFilePath}\n`)); + } +} diff --git a/src/cli/commands/assistants/constants.ts b/src/cli/commands/assistants/constants.ts index 70c1d173..45a393ff 100644 --- a/src/cli/commands/assistants/constants.ts +++ b/src/cli/commands/assistants/constants.ts @@ -23,6 +23,7 @@ export type MessageRole = typeof ROLES.USER | typeof ROLES.ASSISTANT; export interface HistoryMessage { role: MessageRole; message?: string; + message_raw?: string; } export const MESSAGES = { diff --git a/src/cli/commands/assistants/index.ts b/src/cli/commands/assistants/index.ts index 25f7994f..9e390810 100644 --- a/src/cli/commands/assistants/index.ts +++ b/src/cli/commands/assistants/index.ts @@ -5,7 +5,7 @@ */ import { Command } from 'commander'; -import { createAssistantsChatCommand } from '@/cli/commands/assistants/chat.js'; +import { createAssistantsChatCommand } from '@/cli/commands/assistants/chat/index.js'; /** * Create assistants command with subcommands @@ -14,7 +14,7 @@ export function createAssistantsCommand(): Command { const command = new Command('assistants'); command - .description('Manage CodeMie assistants') + .description('Chat with CodeMie assistant') .addCommand(createAssistantsChatCommand()); return command; diff --git a/src/cli/commands/assistants/setup/generators/claude-agent-generator.ts b/src/cli/commands/assistants/setup/generators/claude-agent-generator.ts index 1990ad06..a94b5029 100644 --- a/src/cli/commands/assistants/setup/generators/claude-agent-generator.ts +++ b/src/cli/commands/assistants/setup/generators/claude-agent-generator.ts @@ -57,7 +57,7 @@ export function createClaudeSubagentContent(assistant: Assistant): string { \`\`\` 3. **Return the response** directly to the user - The \`codemie assistants chat\` command communicates with the CodeMie platform to get responses from the ${assistant.name} assistant. + The \`codemie assistants chat\` command communicates with the CodeMie platform to get responses from the ${assistant.name} assistant. The command automatically includes the last 10 messages from the current conversation session as context. ## Example diff --git a/src/cli/commands/assistants/setup/generators/claude-skill-generator.ts b/src/cli/commands/assistants/setup/generators/claude-skill-generator.ts index 000d433d..b8a46c2c 100644 --- a/src/cli/commands/assistants/setup/generators/claude-skill-generator.ts +++ b/src/cli/commands/assistants/setup/generators/claude-skill-generator.ts @@ -51,7 +51,7 @@ function createSkillContent(assistant: Assistant): string { codemie assistants chat "${assistantId}" "$ARGUMENTS" \`\`\` - The assistant will process the request and return a response. + The assistant will process the request and return a response. The command automatically includes the last 10 messages from the current conversation session as context. `; } diff --git a/src/providers/plugins/sso/session/SessionSyncer.ts b/src/providers/plugins/sso/session/SessionSyncer.ts index 2f0c4ca5..00b3e12b 100644 --- a/src/providers/plugins/sso/session/SessionSyncer.ts +++ b/src/providers/plugins/sso/session/SessionSyncer.ts @@ -18,7 +18,7 @@ import type { ParsedSession } from '../../../../agents/core/session/BaseSessionA import { logger } from '../../../../utils/logger.js'; import { SessionStore } from '../../../../agents/core/session/SessionStore.js'; import { MetricsSyncProcessor } from './processors/metrics/metrics-sync-processor.js'; -import { ConversationSyncProcessor } from './processors/conversations/conversation-sync-processor.js'; +import { createSyncProcessor as createConversationSyncProcessor } from './processors/conversations/syncProcessor.js'; export interface SessionSyncResult { success: boolean; @@ -35,7 +35,7 @@ export class SessionSyncer { // Initialize processors (sorted by priority) this.processors = [ new MetricsSyncProcessor(), - new ConversationSyncProcessor() + createConversationSyncProcessor() ].sort((a, b) => a.priority - b.priority); } diff --git a/src/providers/plugins/sso/session/processors/conversations/apiClient.ts b/src/providers/plugins/sso/session/processors/conversations/apiClient.ts new file mode 100644 index 00000000..a9446bba --- /dev/null +++ b/src/providers/plugins/sso/session/processors/conversations/apiClient.ts @@ -0,0 +1,213 @@ +/** + * Conversation API Client (Factory Pattern) + * + * Sends conversation history to Codemie API + * Uses SSO cookie authentication + * Supports retry with exponential backoff + */ + +import type { + ConversationApiConfig, + ConversationSyncResponse, + CodemieHistoryEntry +} from './types.js'; +import { logger } from '@/utils/logger.js'; +import { + DEFAULT_API_TIMEOUT_MS, + DEFAULT_RETRY_ATTEMPTS, + DEFAULT_CONVERSATION_FOLDER, + NON_RETRYABLE_HTTP_CODES, + RETRY_DELAY_MS +} from './constants.js'; + +/** + * Conversation API Client interface + */ +export interface ConversationApiClient { + upsertConversation( + conversationId: string, + history: CodemieHistoryEntry[], + assistantId?: string, + folder?: string + ): Promise; +} + +/** + * Create a conversation API client instance + * @param config - API configuration + * @returns ConversationApiClient instance + */ +export function createApiClient(config: ConversationApiConfig): ConversationApiClient { + // Private state (closure) + const apiConfig: Required = { + baseUrl: config.baseUrl, + cookies: config.cookies || '', + apiKey: config.apiKey || '', + timeout: config.timeout || DEFAULT_API_TIMEOUT_MS, + retryAttempts: config.retryAttempts || DEFAULT_RETRY_ATTEMPTS, + version: config.version || '0.0.0', + clientType: config.clientType || 'codemie-cli', + dryRun: config.dryRun || false + }; + + // Private helper functions + + /** + * Get retry delay with exponential backoff + */ + function getRetryDelay(attempt: number): number { + return RETRY_DELAY_MS[attempt] || RETRY_DELAY_MS[RETRY_DELAY_MS.length - 1]; + } + + /** + * Sleep utility + */ + function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); + } + + /** + * Check if HTTP status code should NOT trigger retry + */ + function isNonRetryableError(statusCode: number): boolean { + return NON_RETRYABLE_HTTP_CODES.includes(statusCode as any); + } + + // Public interface + return { + /** + * Upsert conversation history via API + * PUT /v1/conversations/{conversation_id}/history + */ + async upsertConversation( + conversationId: string, + history: CodemieHistoryEntry[], + assistantId: string = 'claude-code-import', + folder: string = DEFAULT_CONVERSATION_FOLDER + ): Promise { + const url = `${apiConfig.baseUrl}/v1/conversations/${conversationId}/history`; + + const payload = { + assistant_id: assistantId, + folder, + history + }; + + // Dry-run mode: Log payload and return success + if (apiConfig.dryRun) { + logger.info('[ConversationApiClient] DRY-RUN: Would send conversation', { + url, + conversationId, + historyCount: history.length, + payload: JSON.stringify(payload, null, 2) + }); + + return { + success: true, + message: '[DRY-RUN] Conversation logged (not sent)', + conversation_id: conversationId, + new_messages: history.length, + total_messages: history.length, + created: true + }; + } + + // Actual API call with retry + let lastError: Error | null = null; + + for (let attempt = 0; attempt < apiConfig.retryAttempts; attempt++) { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), apiConfig.timeout); + + const headers: Record = { + 'Content-Type': 'application/json', + 'X-CodeMie-CLI': `${apiConfig.clientType}/${apiConfig.version}`, + 'X-CodeMie-Client': apiConfig.clientType + }; + + // Add authentication headers + if (apiConfig.apiKey) { + // Localhost development: user-id header only + headers['user-id'] = apiConfig.apiKey; + } else if (apiConfig.cookies) { + // SSO: Cookie header + headers['Cookie'] = apiConfig.cookies; + } + + const response = await fetch(url, { + method: 'PUT', + headers, + body: JSON.stringify(payload), + signal: controller.signal + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + const errorText = await response.text(); + let errorData; + + try { + errorData = JSON.parse(errorText); + } catch { + errorData = { message: errorText }; + } + + // Check for non-retryable errors + if (isNonRetryableError(response.status)) { + logger.error(`[ConversationApiClient] Non-retryable error (${response.status}):`, errorData); + return { + success: false, + message: `API error: ${response.status} ${errorData.message || response.statusText}` + }; + } + + // Retryable error - throw to retry + throw new Error(`API error: ${response.status} ${errorData.message || response.statusText}`); + } + + // Success + const data = await response.json() as { + conversation_id: string; + new_messages: number; + total_messages: number; + created: boolean; + }; + logger.debug('[ConversationApiClient] Conversation synced successfully', { + conversationId: data.conversation_id, + newMessages: data.new_messages, + totalMessages: data.total_messages, + created: data.created + }); + + return { + success: true, + message: 'Conversation synced successfully', + conversation_id: data.conversation_id, + new_messages: data.new_messages, + total_messages: data.total_messages, + created: data.created + }; + + } catch (error: any) { + lastError = error; + + // Log retry attempt + if (attempt < apiConfig.retryAttempts - 1) { + const delay = getRetryDelay(attempt); + logger.warn(`[ConversationApiClient] Sync failed (attempt ${attempt + 1}/${apiConfig.retryAttempts}), retrying in ${delay}ms:`, error.message); + await sleep(delay); + } + } + } + + // All retries failed + logger.error(`[ConversationApiClient] Sync failed after ${apiConfig.retryAttempts} attempts:`, lastError); + return { + success: false, + message: `Sync failed: ${lastError?.message || 'Unknown error'}` + }; + } + }; +} diff --git a/src/providers/plugins/sso/session/processors/conversations/constants.ts b/src/providers/plugins/sso/session/processors/conversations/constants.ts new file mode 100644 index 00000000..34fed6ea --- /dev/null +++ b/src/providers/plugins/sso/session/processors/conversations/constants.ts @@ -0,0 +1,48 @@ +/** + * Conversation Sync Constants + * + * Centralized configuration values for conversation processing. + */ + +// ============================================================================ +// API Configuration +// ============================================================================ + +/** Default timeout for API requests (milliseconds) */ +export const DEFAULT_API_TIMEOUT_MS = 30000; + +/** Default number of retry attempts for failed API calls */ +export const DEFAULT_RETRY_ATTEMPTS = 3; + +/** Retry delays for exponential backoff (milliseconds) */ +export const RETRY_DELAY_MS = [1000, 2000, 5000] as const; + +// ============================================================================ +// CodeMie API Configuration +// ============================================================================ + +/** CodeMie Assistant ID for conversation imports */ +export const CODEMIE_ASSISTANT_ID = '5a430368-9e91-4564-be20-989803bf4da2'; + +/** API endpoint path for conversation history */ +export const CONVERSATIONS_API_PATH = 'v1/conversations'; + +/** Default folder name for imported conversations */ +export const DEFAULT_CONVERSATION_FOLDER = 'Claude Imports'; + +// ============================================================================ +// HTTP Status Codes +// ============================================================================ + +/** HTTP status codes that should NOT trigger retry logic */ +export const NON_RETRYABLE_HTTP_CODES = [400, 401, 403] as const; + +// ============================================================================ +// Processor Configuration +// ============================================================================ + +/** Priority of conversation sync processor (lower runs first) */ +export const CONVERSATION_PROCESSOR_PRIORITY = 2; + +/** Processor name identifier for logging and tracking */ +export const CONVERSATION_PROCESSOR_NAME = 'conversation-sync'; diff --git a/src/providers/plugins/sso/session/processors/conversations/conversation-api-client.ts b/src/providers/plugins/sso/session/processors/conversations/conversation-api-client.ts deleted file mode 100644 index cfc68490..00000000 --- a/src/providers/plugins/sso/session/processors/conversations/conversation-api-client.ts +++ /dev/null @@ -1,181 +0,0 @@ -/** - * Conversation API Client - * - * Sends conversation history to Codemie API - * Uses SSO cookie authentication - * Supports retry with exponential backoff - */ - -import type { - ConversationApiConfig, - ConversationSyncResponse, - CodemieHistoryEntry -} from './conversation-types.js'; -import { logger } from '../../../../../../utils/logger.js'; - -export class ConversationApiClient { - private config: Required; - - constructor(config: ConversationApiConfig) { - this.config = { - baseUrl: config.baseUrl, - cookies: config.cookies || '', - apiKey: config.apiKey || '', - timeout: config.timeout || 30000, - retryAttempts: config.retryAttempts || 3, - version: config.version || '0.0.0', - clientType: config.clientType || 'codemie-cli', - dryRun: config.dryRun || false - }; - } - - /** - * Upsert conversation history via API - * PUT /v1/conversations/{conversation_id}/history - */ - async upsertConversation( - conversationId: string, - history: CodemieHistoryEntry[], - assistantId: string = 'claude-code-import', - folder: string = 'Claude Imports' - ): Promise { - const url = `${this.config.baseUrl}/v1/conversations/${conversationId}/history`; - - const payload = { - assistant_id: assistantId, - folder, - history - }; - - // Dry-run mode: Log payload and return success - if (this.config.dryRun) { - logger.info('[ConversationApiClient] DRY-RUN: Would send conversation', { - url, - conversationId, - historyCount: history.length, - payload: JSON.stringify(payload, null, 2) - }); - - return { - success: true, - message: '[DRY-RUN] Conversation logged (not sent)', - conversation_id: conversationId, - new_messages: history.length, - total_messages: history.length, - created: true - }; - } - - // Actual API call with retry - let lastError: Error | null = null; - - for (let attempt = 0; attempt < this.config.retryAttempts; attempt++) { - try { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), this.config.timeout); - - const headers: Record = { - 'Content-Type': 'application/json', - 'X-CodeMie-CLI': `${this.config.clientType}/${this.config.version}`, - 'X-CodeMie-Client': this.config.clientType - }; - - // Add authentication headers - if (this.config.apiKey) { - // Localhost development: user-id header only - headers['user-id'] = this.config.apiKey; - } else if (this.config.cookies) { - // SSO: Cookie header - headers['Cookie'] = this.config.cookies; - } - - const response = await fetch(url, { - method: 'PUT', - headers, - body: JSON.stringify(payload), - signal: controller.signal - }); - - clearTimeout(timeoutId); - - if (!response.ok) { - const errorText = await response.text(); - let errorData; - - try { - errorData = JSON.parse(errorText); - } catch { - errorData = { message: errorText }; - } - - // Check for non-retryable errors - if (response.status === 401 || response.status === 403 || response.status === 400) { - logger.error(`[ConversationApiClient] Non-retryable error (${response.status}):`, errorData); - return { - success: false, - message: `API error: ${response.status} ${errorData.message || response.statusText}` - }; - } - - // Retryable error - throw to retry - throw new Error(`API error: ${response.status} ${errorData.message || response.statusText}`); - } - - // Success - const data = await response.json() as { - conversation_id: string; - new_messages: number; - total_messages: number; - created: boolean; - }; - logger.debug('[ConversationApiClient] Conversation synced successfully', { - conversationId: data.conversation_id, - newMessages: data.new_messages, - totalMessages: data.total_messages, - created: data.created - }); - - return { - success: true, - message: 'Conversation synced successfully', - conversation_id: data.conversation_id, - new_messages: data.new_messages, - total_messages: data.total_messages, - created: data.created - }; - - } catch (error: any) { - lastError = error; - - // Log retry attempt - if (attempt < this.config.retryAttempts - 1) { - const delay = this.getRetryDelay(attempt); - logger.warn(`[ConversationApiClient] Sync failed (attempt ${attempt + 1}/${this.config.retryAttempts}), retrying in ${delay}ms:`, error.message); - await this.sleep(delay); - } - } - } - - // All retries failed - logger.error(`[ConversationApiClient] Sync failed after ${this.config.retryAttempts} attempts:`, lastError); - return { - success: false, - message: `Sync failed: ${lastError?.message || 'Unknown error'}` - }; - } - - /** - * Get retry delay with exponential backoff - */ - private getRetryDelay(attempt: number): number { - const delays = [1000, 2000, 5000]; // 1s, 2s, 5s - return delays[attempt] || delays[delays.length - 1]; - } - - /** - * Sleep utility - */ - private sleep(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)); - } -} diff --git a/src/providers/plugins/sso/session/processors/conversations/conversation-sync-processor.ts b/src/providers/plugins/sso/session/processors/conversations/syncProcessor.ts similarity index 58% rename from src/providers/plugins/sso/session/processors/conversations/conversation-sync-processor.ts rename to src/providers/plugins/sso/session/processors/conversations/syncProcessor.ts index 7da3ff57..8adfbe10 100644 --- a/src/providers/plugins/sso/session/processors/conversations/conversation-sync-processor.ts +++ b/src/providers/plugins/sso/session/processors/conversations/syncProcessor.ts @@ -1,5 +1,5 @@ /** - * Conversation Sync Processor (SSO Provider) + * Conversation Sync Processor (Factory Pattern) * * Lightweight processor that syncs conversation payloads to CodeMie API. * @@ -11,53 +11,61 @@ * Note: Message transformation is handled by agent adapters (e.g., Claude's ConversationsProcessor) */ -import type { SessionProcessor, ProcessingContext, ProcessingResult } from '../../BaseProcessor.js'; -import type { ParsedSession } from '../../BaseSessionAdapter.js'; -import { logger } from '../../../../../../utils/logger.js'; -import { ConversationApiClient } from './conversation-api-client.js'; -import type { ConversationPayloadRecord } from './conversation-types.js'; -import { getSessionConversationPath } from '../../../../../../agents/core/session/session-config.js'; +import type { SessionProcessor, ProcessingContext, ProcessingResult } from '@/providers/plugins/sso/session/BaseProcessor.js'; +import type { ParsedSession } from '@/providers/plugins/sso/session/BaseSessionAdapter.js'; +import type { ConversationPayloadRecord } from './types.js'; +import { CONVERSATION_SYNC_STATUS } from './types.js'; +import { logger } from '@/utils/logger.js'; +import { createApiClient as createConversationApiClient } from './apiClient.js'; +import { getSessionConversationPath } from '@/agents/core/session/session-config.js'; import { readJSONL } from '../../utils/jsonl-reader.js'; import { writeJSONLAtomic } from '../../utils/jsonl-writer.js'; +import { + DEFAULT_API_TIMEOUT_MS, + DEFAULT_RETRY_ATTEMPTS, + CODEMIE_ASSISTANT_ID, + CONVERSATION_PROCESSOR_PRIORITY, + CONVERSATION_PROCESSOR_NAME +} from './constants.js'; -export class ConversationSyncProcessor implements SessionProcessor { - readonly name = 'conversation-sync'; - readonly priority = 2; // Run after metrics (priority 1) - - private isSyncing = false; // Concurrency guard - - shouldProcess(_session: ParsedSession): boolean { - // Always try to process - will check for pending payloads inside - return true; - } - - async process(session: ParsedSession, context: ProcessingContext): Promise { - if (this.isSyncing) { +/** + * Create a conversation sync processor instance + * @returns SessionProcessor instance + */ +export function createSyncProcessor(): SessionProcessor { + // Private state (closure) + let isSyncing = false; // Concurrency guard + + /** + * Process conversations for sync + */ + async function processConversations(session: ParsedSession, context: ProcessingContext): Promise { + if (isSyncing) { return { success: true, message: 'Sync in progress' }; } - this.isSyncing = true; + isSyncing = true; try { // Read conversation payloads from JSONL const conversationsFile = getSessionConversationPath(session.sessionId); const allPayloads = await readJSONL(conversationsFile); - const pendingPayloads = allPayloads.filter(p => p.status === 'pending'); + const pendingPayloads = allPayloads.filter(p => p.status === CONVERSATION_SYNC_STATUS.PENDING); if (pendingPayloads.length === 0) { - logger.debug(`[${this.name}] No pending conversation payloads for session ${session.sessionId}`); + logger.debug(`[${CONVERSATION_PROCESSOR_NAME}] No pending conversation payloads for session ${session.sessionId}`); return { success: true, message: 'No pending payloads' }; } - logger.info(`[${this.name}] Syncing ${pendingPayloads.length} conversation payload${pendingPayloads.length !== 1 ? 's' : ''}`); + logger.info(`[${CONVERSATION_PROCESSOR_NAME}] Syncing ${pendingPayloads.length} conversation payload${pendingPayloads.length !== 1 ? 's' : ''}`); // Initialize API client - const apiClient = new ConversationApiClient({ + const apiClient = createConversationApiClient({ baseUrl: context.apiBaseUrl, cookies: context.cookies, apiKey: context.apiKey, - timeout: 30000, - retryAttempts: 3, + timeout: DEFAULT_API_TIMEOUT_MS, + retryAttempts: DEFAULT_RETRY_ATTEMPTS, version: context.version, clientType: context.clientType, dryRun: context.dryRun @@ -71,7 +79,7 @@ export class ConversationSyncProcessor implements SessionProcessor { const { conversationId, history } = pendingPayload.payload; logger.debug( - `[${this.name}] Sending payload: conversationId=${conversationId}, ` + + `[${CONVERSATION_PROCESSOR_NAME}] Sending payload: conversationId=${conversationId}, ` + `messages=${history.length}, isTurnContinuation=${pendingPayload.isTurnContinuation}` ); @@ -81,22 +89,22 @@ export class ConversationSyncProcessor implements SessionProcessor { const response = await apiClient.upsertConversation( conversationId, history, - '5a430368-9e91-4564-be20-989803bf4da2', // Assistant ID + CODEMIE_ASSISTANT_ID, session.agentName // Agent display name (e.g., "Claude Code") ); if (!response.success) { - logger.error(`[${this.name}] Failed to sync conversation ${conversationId}: ${response.message}`); + logger.error(`[${CONVERSATION_PROCESSOR_NAME}] Failed to sync conversation ${conversationId}: ${response.message}`); // Continue with other payloads even if one fails continue; } - logger.info(`[${this.name}] Successfully synced conversation ${conversationId} (${response.new_messages} new, ${response.total_messages} total)`); + logger.info(`[${CONVERSATION_PROCESSOR_NAME}] Successfully synced conversation ${conversationId} (${response.new_messages} new, ${response.total_messages} total)`); successCount++; totalMessages += history.length; } catch (error: any) { - logger.error(`[${this.name}] Error syncing conversation ${conversationId}:`, error.message); + logger.error(`[${CONVERSATION_PROCESSOR_NAME}] Error syncing conversation ${conversationId}:`, error.message); // Continue with other payloads } } @@ -109,7 +117,7 @@ export class ConversationSyncProcessor implements SessionProcessor { pendingTimestamps.has(p.timestamp) ? { ...p, - status: 'success' as const, + status: CONVERSATION_SYNC_STATUS.SUCCESS, response: { syncedCount: p.payload.history.length } @@ -120,7 +128,7 @@ export class ConversationSyncProcessor implements SessionProcessor { await writeJSONLAtomic(conversationsFile, updatedPayloads); logger.info( - `[${this.name}] Successfully synced ${successCount}/${pendingPayloads.length} conversations (${totalMessages} messages)` + `[${CONVERSATION_PROCESSOR_NAME}] Successfully synced ${successCount}/${pendingPayloads.length} conversations (${totalMessages} messages)` ); // Calculate sync updates for the adapter to persist @@ -144,12 +152,12 @@ export class ConversationSyncProcessor implements SessionProcessor { } // Debug: Log which payloads were marked as synced - logger.debug(`[${this.name}] Marked payloads as synced:`, { + logger.debug(`[${CONVERSATION_PROCESSOR_NAME}] Marked payloads as synced:`, { syncedAt: new Date(syncedAt).toISOString(), timestamps: Array.from(pendingTimestamps), totalPayloadsInFile: updatedPayloads.length, - syncedCount: updatedPayloads.filter(p => p.status === 'success').length, - pendingCount: updatedPayloads.filter(p => p.status === 'pending').length + syncedCount: updatedPayloads.filter(p => p.status === CONVERSATION_SYNC_STATUS.SUCCESS).length, + pendingCount: updatedPayloads.filter(p => p.status === CONVERSATION_SYNC_STATUS.PENDING).length }); return { @@ -172,13 +180,26 @@ export class ConversationSyncProcessor implements SessionProcessor { }; } catch (error) { - logger.error(`[${this.name}] Processing failed:`, error); + logger.error(`[${CONVERSATION_PROCESSOR_NAME}] Processing failed:`, error); return { success: false, message: error instanceof Error ? error.message : 'Unknown error' }; } finally { - this.isSyncing = false; + isSyncing = false; } } + + // Public interface (SessionProcessor) + return { + name: CONVERSATION_PROCESSOR_NAME, + priority: CONVERSATION_PROCESSOR_PRIORITY, + shouldProcess(_session: ParsedSession): boolean { + // Always try to process - will check for pending payloads inside + return true; + }, + async process(session: ParsedSession, context: ProcessingContext): Promise { + return processConversations(session, context); + } + }; } diff --git a/src/providers/plugins/sso/session/processors/conversations/conversation-types.ts b/src/providers/plugins/sso/session/processors/conversations/types.ts similarity index 83% rename from src/providers/plugins/sso/session/processors/conversations/conversation-types.ts rename to src/providers/plugins/sso/session/processors/conversations/types.ts index c50508d6..43c72621 100644 --- a/src/providers/plugins/sso/session/processors/conversations/conversation-types.ts +++ b/src/providers/plugins/sso/session/processors/conversations/types.ts @@ -4,6 +4,20 @@ * Type definitions for conversation payloads and API client. */ +/** + * Conversation sync status constants + */ +export const CONVERSATION_SYNC_STATUS = { + PENDING: 'pending', + SUCCESS: 'success', + FAILED: 'failed' +} as const; + +/** + * Conversation sync status type + */ +export type ConversationSyncStatus = 'pending' | 'success' | 'failed'; + /** * Conversation payload record stored in JSONL * Used for tracking conversation sync status @@ -31,7 +45,7 @@ export interface ConversationPayloadRecord { }; /** Sync result status */ - status: 'pending' | 'success' | 'failed'; + status: ConversationSyncStatus; /** Error message if failed */ error?: string; diff --git a/tests/unit/cli/commands/assistants/chat/historyLoader.test.ts b/tests/unit/cli/commands/assistants/chat/historyLoader.test.ts new file mode 100644 index 00000000..6b50de2d --- /dev/null +++ b/tests/unit/cli/commands/assistants/chat/historyLoader.test.ts @@ -0,0 +1,193 @@ +/** + * History Loader Unit Tests + * + * Tests the conversation history loading functionality + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { existsSync } from 'fs'; + +// Mock dependencies before importing the module +vi.mock('fs', () => ({ + existsSync: vi.fn() +})); + +vi.mock('@/utils/logger.js', () => ({ + logger: { + debug: vi.fn(), + error: vi.fn() + } +})); + +vi.mock('@/agents/core/session/session-config.js', () => ({ + getSessionConversationPath: vi.fn((id: string) => `/mock/sessions/${id}_conversation.jsonl`) +})); + +vi.mock('@/providers/plugins/sso/session/utils/jsonl-reader.js', () => ({ + readJSONL: vi.fn() +})); + +vi.mock('@/providers/plugins/sso/session/processors/conversations/conversation-types.js', async () => { + const actual = await vi.importActual('@/providers/plugins/sso/session/processors/conversations/conversation-types.js') as any; + return { + ...actual, + CONVERSATION_SYNC_STATUS: { + PENDING: 'pending', + SUCCESS: 'success', + FAILED: 'failed' + } + }; +}); + +describe('loadConversationHistory', () => { + let loadConversationHistory: any; + let readJSONL: any; + + beforeEach(async () => { + vi.clearAllMocks(); + + // Dynamic import to get fresh mocks + const module = await import('@/cli/commands/assistants/chat/historyLoader.js'); + loadConversationHistory = module.loadConversationHistory; + + const jsonlModule = await import('@/providers/plugins/sso/session/utils/jsonl-reader.js'); + readJSONL = jsonlModule.readJSONL; + }); + + describe('when no conversation ID is provided', () => { + it('should return empty array', async () => { + const result = await loadConversationHistory(undefined); + expect(result).toEqual([]); + }); + }); + + describe('when conversation file does not exist', () => { + beforeEach(() => { + vi.mocked(existsSync).mockReturnValue(false); + }); + + it('should return empty array', async () => { + const result = await loadConversationHistory('test-id'); + expect(result).toEqual([]); + }); + + it('should not attempt to read file', async () => { + await loadConversationHistory('test-id'); + expect(readJSONL).not.toHaveBeenCalled(); + }); + }); + + describe('when conversation file exists', () => { + beforeEach(() => { + vi.mocked(existsSync).mockReturnValue(true); + }); + + describe('with no success records', () => { + beforeEach(() => { + readJSONL.mockResolvedValue([ + { status: 'pending', payload: { history: [] } }, + { status: 'failed', payload: { history: [] } } + ]); + }); + + it('should return empty array', async () => { + const result = await loadConversationHistory('test-id'); + expect(result).toEqual([]); + }); + }); + + describe('with success records but no history', () => { + beforeEach(() => { + readJSONL.mockResolvedValue([ + { status: 'success', payload: { history: [] } } + ]); + }); + + it('should return empty array', async () => { + const result = await loadConversationHistory('test-id'); + expect(result).toEqual([]); + }); + }); + + describe('with valid success records and history', () => { + beforeEach(() => { + readJSONL.mockResolvedValue([ + { + status: 'success', + timestamp: 1000, + payload: { + conversationId: 'test-id', + history: [ + { role: 'User', message: 'Hello', history_index: 0 }, + { role: 'Assistant', message: 'Hi there', history_index: 0 } + ] + } + } + ]); + }); + + it('should return transformed history', async () => { + const result = await loadConversationHistory('test-id'); + expect(result).toEqual([ + { role: 'User', message: 'Hello', message_raw: 'Hello' }, + { role: 'Assistant', message: 'Hi there', message_raw: 'Hi there' } + ]); + }); + + it('should only include role, message, and message_raw fields', async () => { + const result = await loadConversationHistory('test-id'); + result.forEach(msg => { + expect(Object.keys(msg).sort()).toEqual(['message', 'message_raw', 'role']); + }); + }); + }); + + describe('with multiple success records', () => { + beforeEach(() => { + readJSONL.mockResolvedValue([ + { + status: 'success', + timestamp: 1000, + payload: { + history: [ + { role: 'User', message: 'First', history_index: 0 } + ] + } + }, + { + status: 'success', + timestamp: 2000, + payload: { + history: [ + { role: 'User', message: 'First', history_index: 0 }, + { role: 'Assistant', message: 'Second', history_index: 0 }, + { role: 'User', message: 'Third', history_index: 1 } + ] + } + } + ]); + }); + + it('should use the most recent (last) success record', async () => { + const result = await loadConversationHistory('test-id'); + expect(result).toHaveLength(3); + expect(result[2].message).toBe('Third'); + }); + }); + + describe('error handling', () => { + beforeEach(() => { + readJSONL.mockRejectedValue(new Error('Read error')); + }); + + it('should return empty array on error', async () => { + const result = await loadConversationHistory('test-id'); + expect(result).toEqual([]); + }); + + it('should not throw error', async () => { + await expect(loadConversationHistory('test-id')).resolves.not.toThrow(); + }); + }); + }); +}); diff --git a/tests/unit/cli/commands/assistants/chat/index.test.ts b/tests/unit/cli/commands/assistants/chat/index.test.ts new file mode 100644 index 00000000..82b3509d --- /dev/null +++ b/tests/unit/cli/commands/assistants/chat/index.test.ts @@ -0,0 +1,79 @@ +/** + * Chat Command Unit Tests + * + * Tests the main chat command structure and configuration + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { createAssistantsChatCommand } from '@/cli/commands/assistants/chat/index.js'; +import { MESSAGES } from '@/cli/commands/assistants/constants.js'; + +describe('Chat Command Structure', () => { + let command: ReturnType; + + beforeEach(() => { + command = createAssistantsChatCommand(); + }); + + describe('Command Configuration', () => { + it('should create a command with name "chat"', () => { + expect(command.name()).toBe('chat'); + }); + + it('should have correct description', () => { + expect(command.description()).toBe(MESSAGES.CHAT.COMMAND_DESCRIPTION); + }); + + it('should accept optional assistant-id argument', () => { + const args = command.registeredArguments; + expect(args).toHaveLength(2); + expect(args[0].name()).toBe('assistant-id'); + expect(args[0].required).toBe(false); + }); + + it('should accept optional message argument', () => { + const args = command.registeredArguments; + expect(args).toHaveLength(2); + expect(args[1].name()).toBe('message'); + expect(args[1].required).toBe(false); + }); + }); + + describe('Command Options', () => { + it('should have verbose option', () => { + const verboseOption = command.options.find(opt => opt.long === '--verbose'); + expect(verboseOption).toBeDefined(); + expect(verboseOption?.short).toBe('-v'); + expect(verboseOption?.description).toBe(MESSAGES.SHARED.OPTION_VERBOSE); + }); + + it('should have conversation-id option', () => { + const convOption = command.options.find(opt => opt.long === '--conversation-id'); + expect(convOption).toBeDefined(); + expect(convOption?.description).toBe('Conversation ID for maintaining context across calls'); + }); + + it('should have load-history option', () => { + const historyOption = command.options.find(opt => opt.long === '--load-history'); + expect(historyOption).toBeDefined(); + expect(historyOption?.defaultValue).toBe(true); + }); + + it('should have all expected options', () => { + expect(command.options).toHaveLength(3); + }); + }); + + describe('Command Modes', () => { + it('should support interactive mode (no arguments)', () => { + const args = command.registeredArguments; + // Both arguments are optional, allowing interactive mode + expect(args.every(arg => !arg.required)).toBe(true); + }); + + it('should support single-message mode (both arguments)', () => { + // Command should be able to accept both arguments + expect(command.registeredArguments).toHaveLength(2); + }); + }); +}); diff --git a/tests/unit/cli/commands/assistants/chat/utils.test.ts b/tests/unit/cli/commands/assistants/chat/utils.test.ts new file mode 100644 index 00000000..1b6f6a40 --- /dev/null +++ b/tests/unit/cli/commands/assistants/chat/utils.test.ts @@ -0,0 +1,101 @@ +/** + * Chat Utils Unit Tests + * + * Tests utility functions for the chat command + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +// Mock logger before importing +vi.mock('@/utils/logger.js', () => ({ + logger: { + getLogFilePath: vi.fn(() => '/mock/log/path.log') + } +})); + +// Mock EXIT_PROMPTS constant +vi.mock('@/cli/commands/assistants/constants.js', () => ({ + EXIT_PROMPTS: ['exit', 'quit', '/exit', '/quit', 'bye'] +})); + +describe('Chat Utils', () => { + let isExitCommand: any; + let enableVerboseMode: any; + + beforeEach(async () => { + vi.clearAllMocks(); + delete process.env.CODEMIE_DEBUG; + + // Dynamic import to get fresh module + const module = await import('@/cli/commands/assistants/chat/utils.js'); + isExitCommand = module.isExitCommand; + enableVerboseMode = module.enableVerboseMode; + }); + + describe('isExitCommand', () => { + describe('with valid exit commands', () => { + const exitCommands = ['exit', 'quit', '/exit', '/quit', 'bye']; + + exitCommands.forEach(cmd => { + it(`should return true for "${cmd}"`, () => { + expect(isExitCommand(cmd)).toBe(true); + }); + + it(`should be case-insensitive for "${cmd}"`, () => { + expect(isExitCommand(cmd.toUpperCase())).toBe(true); + expect(isExitCommand(cmd.toLowerCase())).toBe(true); + }); + + it(`should handle whitespace for "${cmd}"`, () => { + expect(isExitCommand(` ${cmd} `)).toBe(true); + expect(isExitCommand(`\t${cmd}\t`)).toBe(true); + }); + }); + }); + + describe('with non-exit commands', () => { + const nonExitCommands = [ + 'hello', + 'help', + 'exiting', + 'quitting', + 'not exit', + '', + 'EXIT NOW' // includes EXIT but not exact match + ]; + + nonExitCommands.forEach(cmd => { + it(`should return false for "${cmd}"`, () => { + expect(isExitCommand(cmd)).toBe(false); + }); + }); + }); + }); + + describe('enableVerboseMode', () => { + let consoleLogSpy: any; + + beforeEach(() => { + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + it('should set CODEMIE_DEBUG environment variable', () => { + enableVerboseMode(); + expect(process.env.CODEMIE_DEBUG).toBe('true'); + }); + + it('should log the log file path', () => { + enableVerboseMode(); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('/mock/log/path.log') + ); + }); + + it('should display dimmed log message', () => { + enableVerboseMode(); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('Debug logs:') + ); + }); + }); +});