From e25ec80fc299e2deed7beb4530dbeac1bf273674 Mon Sep 17 00:00:00 2001 From: Volodymyr Vreshch Date: Tue, 6 Jan 2026 00:03:31 +0100 Subject: [PATCH 1/2] chore: improve conversation format --- .../services/conversation.store.service.ts | 61 ++++++--- src/shared/schemas/conversation.schema.ts | 58 ++++---- src/shared/types/conversation.types.ts | 125 ++++++++++++------ 3 files changed, 156 insertions(+), 88 deletions(-) diff --git a/src/main/services/conversation.store.service.ts b/src/main/services/conversation.store.service.ts index ef8d8e3..eb7849b 100644 --- a/src/main/services/conversation.store.service.ts +++ b/src/main/services/conversation.store.service.ts @@ -11,6 +11,7 @@ import { import type { ChatMessage } from '../../shared/types/chat.types.js'; import type { AssistantMessage, + Attachment, ConversationIndex, ConversationMessage, ConversationRef, @@ -79,7 +80,7 @@ const getDateFolder = (date: Date = new Date()): string => date.toISOString().sp * Derive title from first user message */ const deriveTitle = (snapshot: ConversationSnapshot): string => { - const firstUser = snapshot.messages.find((m) => m.type === 'user'); + const firstUser = snapshot.messages.find((m) => m.role === 'user'); if (!firstUser) return 'New conversation'; // Get first 50 chars, trim at word boundary @@ -342,7 +343,7 @@ export const appendMessage = async (id: string, message: ChatMessage): Promise 0) { for (const tr of message.toolResults) { const toolMsg: ToolMessage = { - type: 'tool', + role: 'tool', id: generateMessageId(), content: tr.result, timestamp, @@ -357,12 +358,20 @@ export const appendMessage = async (id: string, message: ChatMessage): Promise ({ + type: ref.type, + uri: ref.uri, + content: ref.content, + range: ref.range, + })); + const userMsg: UserMessage = { - type: 'user', + role: 'user', id: generateMessageId(), content: message.content, timestamp, - references: message.references, + attachments, config: message.config ? { model: message.config.model, @@ -374,19 +383,21 @@ export const appendMessage = async (id: string, message: ChatMessage): Promise ({ id: tc.id, - name: tc.name, - input: tc.input, - status: 'completed', + type: 'function' as const, + function: { + name: tc.name, + arguments: typeof tc.input === 'string' ? tc.input : JSON.stringify(tc.input), + }, + status: 'completed' as const, })), - finishReason: message.toolCalls && message.toolCalls.length > 0 ? 'tool_use' : 'end_turn', }; snapshot.messages.push(assistantMsg); } @@ -394,8 +405,8 @@ export const appendMessage = async (id: string, message: ChatMessage): Promise m.type === 'user'); - if (firstUserMsg && snapshot.messages.filter((m) => m.type === 'user').length === 1) { + const firstUserMsg = snapshot.messages.find((m) => m.role === 'user'); + if (firstUserMsg && snapshot.messages.filter((m) => m.role === 'user').length === 1) { snapshot.title = deriveTitle(snapshot); } @@ -449,16 +460,24 @@ const convertMessagesToLegacy = (messages: ConversationMessage[]): ChatMessage[] const pendingToolResults: ToolMessage[] = []; for (const msg of messages) { - if (msg.type === 'tool') { + if (msg.role === 'tool') { // Collect tool messages pendingToolResults.push(msg); - } else if (msg.type === 'user') { + } else if (msg.role === 'user') { // Add user message + // Convert Attachment[] back to ChatReference[] + const references = msg.attachments?.map((att) => ({ + type: att.type, + uri: att.uri, + content: att.content, + range: att.range, + })); + const userMsg: ChatMessage = { role: 'user', content: msg.content, timestamp: msg.timestamp, - references: msg.references, + references, config: msg.config ? { model: msg.config.model ?? '', @@ -474,7 +493,7 @@ const convertMessagesToLegacy = (messages: ConversationMessage[]): ChatMessage[] if (pendingToolResults.length > 0) { userMsg.toolResults = pendingToolResults.map((tm) => ({ id: tm.tool_call_id, - name: tm.name, + name: tm.name ?? '', result: tm.content, isError: tm.isError, })); @@ -483,15 +502,15 @@ const convertMessagesToLegacy = (messages: ConversationMessage[]): ChatMessage[] legacy.push(userMsg); } else { - // Assistant message + // Assistant message - convert from OpenAI format to legacy legacy.push({ role: 'assistant', - content: msg.content, + content: msg.content ?? '', timestamp: msg.timestamp, toolCalls: msg.tool_calls?.map((tc) => ({ id: tc.id, - name: tc.name, - input: tc.input, + name: tc.function.name, + input: JSON.parse(tc.function.arguments) as unknown, })), }); } diff --git a/src/shared/schemas/conversation.schema.ts b/src/shared/schemas/conversation.schema.ts index b7577b0..e4bbffb 100644 --- a/src/shared/schemas/conversation.schema.ts +++ b/src/shared/schemas/conversation.schema.ts @@ -13,11 +13,12 @@ const isoDatetime = z.string().refine((val) => !isNaN(Date.parse(val)), { const providerSchema = z.enum(['openai', 'anthropic', 'custom']); /** - * Reference/attachment schema + * Attachment schema (Agentage extension of OpenAI format) */ -const referenceSchema = z.object({ - type: z.enum(['file', 'selection', 'image']), +const attachmentSchema = z.object({ + type: z.enum(['file', 'image', 'selection']), uri: z.string(), + name: z.string().optional(), content: z.string().optional(), range: z .object({ @@ -26,28 +27,31 @@ const referenceSchema = z.object({ }) .optional(), mimeType: z.string().optional(), - name: z.string().optional(), }); /** - * Tool call schema + * Tool call schema (OpenAI-compatible format) */ const toolCallSchema = z.object({ id: z.string(), - name: z.string(), - input: z.unknown(), + type: z.literal('function'), + function: z.object({ + name: z.string(), + arguments: z.string(), // JSON-encoded + }), status: z.enum(['pending', 'running', 'completed', 'error']).optional(), }); /** - * User message schema + * User message schema (OpenAI-compatible) */ const userMessageSchema = z.object({ - type: z.literal('user'), + role: z.literal('user'), id: z.string(), content: z.string(), timestamp: isoDatetime, - references: z.array(referenceSchema).optional(), + name: z.string().optional(), + attachments: z.array(attachmentSchema).optional(), config: z .object({ model: z.string().optional(), @@ -58,38 +62,46 @@ const userMessageSchema = z.object({ }); /** - * Assistant message schema + * Assistant message schema (OpenAI-compatible) */ const assistantMessageSchema = z.object({ - type: z.literal('assistant'), + role: z.literal('assistant'), id: z.string(), - content: z.string(), + content: z.string().nullable(), timestamp: isoDatetime, - finishReason: z - .enum(['end_turn', 'max_tokens', 'stop_sequence', 'pause_turn', 'refusal', 'tool_use']) - .optional(), - thinking: z.string().optional(), tool_calls: z.array(toolCallSchema).optional(), + refusal: z.string().nullable().optional(), + _anthropic: z + .object({ + thinking: z.string().optional(), + stopReason: z.enum(['end_turn', 'max_tokens', 'stop_sequence', 'tool_use']).optional(), + }) + .optional(), + _openai: z + .object({ + finishReason: z.enum(['stop', 'length', 'tool_calls', 'content_filter']).optional(), + }) + .optional(), }); /** - * Tool message schema + * Tool message schema (OpenAI-compatible) */ const toolMessageSchema = z.object({ - type: z.literal('tool'), + role: z.literal('tool'), id: z.string(), content: z.string(), timestamp: isoDatetime, tool_call_id: z.string(), - name: z.string(), + name: z.string().optional(), isError: z.boolean().optional(), duration: z.number().optional(), }); /** - * Conversation message union schema + * Conversation message union schema (OpenAI-compatible, discriminated by 'role') */ -export const conversationMessageSchema = z.discriminatedUnion('type', [ +export const conversationMessageSchema = z.discriminatedUnion('role', [ userMessageSchema, assistantMessageSchema, toolMessageSchema, @@ -238,7 +250,7 @@ export type ConversationMessageSchema = z.infer; export type AssistantMessageSchema = z.infer; export type ToolMessageSchema = z.infer; -export type ReferenceSchema = z.infer; +export type AttachmentSchema = z.infer; export type ToolCallSchema = z.infer; export type SessionConfigSchema = z.infer; export type ConversationMetadataSchema = z.infer; diff --git a/src/shared/types/conversation.types.ts b/src/shared/types/conversation.types.ts index cfb349a..fab9ae9 100644 --- a/src/shared/types/conversation.types.ts +++ b/src/shared/types/conversation.types.ts @@ -85,42 +85,51 @@ export interface Reference { } /** - * Tool call in assistant message + * Tool call in assistant message (OpenAI-compatible format) */ export interface ToolCall { /** Tool call ID (generated by model or backend) */ id: string; - /** Tool name */ - name: string; + /** Type (always 'function' for OpenAI compatibility) */ + type: 'function'; - /** Tool input parameters (JSON-serializable) */ - input: unknown; + /** Function call details */ + function: { + /** Function/tool name */ + name: string; - /** UI state (not sent to API) */ + /** JSON-encoded arguments string */ + arguments: string; + }; + + /** UI state (not sent to API, Agentage extension) */ status?: 'pending' | 'running' | 'completed' | 'error'; } /** - * User message in conversation + * User message in conversation (OpenAI-compatible) */ export interface UserMessage { - /** Message type */ - type: 'user'; + /** Message role (OpenAI format) */ + role: 'user'; - /** Unique message ID (for UI correlation) */ + /** Unique message ID (for UI correlation, Agentage extension) */ id: string; /** User's text prompt */ content: string; - /** ISO 8601 timestamp */ + /** ISO 8601 timestamp (Agentage extension) */ timestamp: string; - /** Attached references (files, selections, images) */ - references?: Reference[]; + /** Optional identifier for multi-user scenarios */ + name?: string; - /** Model configuration at time of message (optional) */ + /** Attached references - called 'attachments' in spec (files, selections, images) */ + attachments?: Attachment[]; + + /** Model configuration at time of message (optional, Agentage extension) */ config?: { model?: string; temperature?: number; @@ -129,63 +138,91 @@ export interface UserMessage { } /** - * Assistant message in conversation + * Attachment in user message (Agentage extension of OpenAI format) + */ +export interface Attachment { + /** Attachment type */ + type: 'file' | 'image' | 'selection'; + + /** Resource URI */ + uri: string; + + /** Display name */ + name?: string; + + /** Inline content (for small files/selections) */ + content?: string; + + /** Selection range (for code selections) */ + range?: { + start: number; + end: number; + }; + + /** MIME type (for images) */ + mimeType?: string; +} + +/** + * Assistant message in conversation (OpenAI-compatible) */ export interface AssistantMessage { - /** Message type */ - type: 'assistant'; + /** Message role (OpenAI format) */ + role: 'assistant'; - /** Unique message ID */ + /** Unique message ID (Agentage extension) */ id: string; - /** Assistant's text response (may be empty if only tool calls) */ - content: string; + /** Assistant's text response (may be null if only tool calls or refusal) */ + content: string | null; - /** ISO 8601 timestamp */ + /** ISO 8601 timestamp (Agentage extension) */ timestamp: string; - /** Stop reason (why response ended) */ - finishReason?: - | 'end_turn' - | 'max_tokens' - | 'stop_sequence' - | 'pause_turn' - | 'refusal' - | 'tool_use'; + /** Tool calls made by assistant (OpenAI format) */ + tool_calls?: ToolCall[]; - /** Thinking/reasoning text (Claude extended thinking) */ - thinking?: string; + /** Refusal message if assistant declined to respond (OpenAI format) */ + refusal?: string | null; - /** Tool calls made by assistant (only present when finishReason is 'tool_use') */ - tool_calls?: ToolCall[]; + /** Thinking/reasoning text (Anthropic extended thinking, provider extension) */ + _anthropic?: { + thinking?: string; + stopReason?: 'end_turn' | 'max_tokens' | 'stop_sequence' | 'tool_use'; + }; + + /** OpenAI-specific extensions */ + _openai?: { + finishReason?: 'stop' | 'length' | 'tool_calls' | 'content_filter'; + }; } /** - * Tool message in conversation + * Tool message in conversation (OpenAI-compatible) */ export interface ToolMessage { - /** Message type */ - type: 'tool'; + /** Message role (OpenAI format) */ + role: 'tool'; - /** Unique message ID */ + /** Unique message ID (Agentage extension) */ id: string; - /** Tool execution result (JSON string) */ + /** Tool execution result (typically JSON string) */ content: string; - /** ISO 8601 timestamp */ + /** ISO 8601 timestamp (Agentage extension) */ timestamp: string; - /** Tool call ID this result corresponds to */ + /** Tool call ID this result corresponds to (must match assistant's tool_calls[].id) */ tool_call_id: string; - /** Tool name (for validation) */ - name: string; + /** Tool name (optional, for validation/debugging) */ + name?: string; - /** Whether execution failed */ + /** Whether execution failed (Agentage extension) */ isError?: boolean; - /** Duration in milliseconds (for debugging) */ + /** Duration in milliseconds (Agentage extension, for debugging) */ duration?: number; } From 621e70477435d8e78228a915eca74aca5dff780f Mon Sep 17 00:00:00 2001 From: Volodymyr Vreshch Date: Tue, 6 Jan 2026 00:08:10 +0100 Subject: [PATCH 2/2] chore: correct file format --- src/main/ipc/handlers/chat.handlers.ts | 2 +- src/main/services/chat.service.ts | 78 ++++++++++++--- .../services/conversation.store.service.ts | 95 ++++++++++++++++--- 3 files changed, 148 insertions(+), 27 deletions(-) diff --git a/src/main/ipc/handlers/chat.handlers.ts b/src/main/ipc/handlers/chat.handlers.ts index 7f42a26..baf8b91 100644 --- a/src/main/ipc/handlers/chat.handlers.ts +++ b/src/main/ipc/handlers/chat.handlers.ts @@ -17,7 +17,7 @@ export const registerChatHandlers = ( /** * Send chat message and stream response */ - ipcMain.handle('chat:send', (_event, request: ChatSendRequest) => { + ipcMain.handle('chat:send', async (_event, request: ChatSendRequest) => { const mainWindow = getMainWindow(); if (!mainWindow) { throw new Error('No active window'); diff --git a/src/main/services/chat.service.ts b/src/main/services/chat.service.ts index 3e75fb3..f867854 100644 --- a/src/main/services/chat.service.ts +++ b/src/main/services/chat.service.ts @@ -14,6 +14,12 @@ import type { import { toAnthropicTools } from '../tools/converter.js'; import { executeTool, listTools } from '../tools/index.js'; import type { ToolContext } from '../tools/types.js'; +import { + appendMessage, + createConversation as createPersistedConversation, + restoreConversation, + updateUsageStats, +} from './conversation.store.service.js'; import { loadProviders, resolveProviderToken } from './model.providers.service.js'; import { getActiveWorkspace } from './workspace.service.js'; @@ -97,28 +103,65 @@ const getAnthropicClient = async (): Promise<{ client: Anthropic; isOAuth: boole /** * Get or create conversation + * Creates in-memory conversation and persists to disk */ -const getOrCreateConversation = ( +const getOrCreateConversation = async ( sessionConfig: SessionConfig, conversationId?: string -): Conversation => { +): Promise => { + // Try to get existing conversation from memory if (conversationId && conversations.has(conversationId)) { const existing = conversations.get(conversationId); - if (existing) return existing; + if (existing) { + console.log('[ChatService] Using existing in-memory conversation', { id: conversationId }); + return existing; + } + } + + // Try to restore from disk if we have an ID + if (conversationId) { + console.log('[ChatService] Trying to restore conversation from disk', { id: conversationId }); + const restored = await restoreConversation(conversationId); + if (restored) { + console.log('[ChatService] Restored conversation from disk', { + id: restored.id, + messages: restored.messages.length, + }); + const conversation: Conversation = { + id: restored.id, + config: restored.config, + messages: restored.messages, + createdAt: restored.createdAt, + updatedAt: restored.updatedAt, + }; + conversations.set(conversation.id, conversation); + return conversation; + } } - const id = conversationId ?? generateId('conv'); - const now = new Date().toISOString(); + // Create new conversation - persist to disk first + console.log('[ChatService] Creating new conversation', { model: sessionConfig.model }); + + const persisted = await createPersistedConversation({ + system: sessionConfig.system ?? '', + model: sessionConfig.model, + provider: 'anthropic', // TODO: detect from model + agentId: sessionConfig.agent, + tools: sessionConfig.tools, + modelConfig: sessionConfig.modelConfig, + }); + + console.log('[ChatService] Conversation persisted', { id: persisted.id }); const conversation: Conversation = { - id, + id: persisted.id, config: sessionConfig, messages: [], - createdAt: now, - updatedAt: now, + createdAt: persisted.metadata.createdAt, + updatedAt: persisted.metadata.updatedAt, }; - conversations.set(id, conversation); + conversations.set(conversation.id, conversation); return conversation; }; @@ -278,15 +321,15 @@ const executeToolCall = async ( /** * Send a chat message and stream response */ -export const sendMessage = ( +export const sendMessage = async ( request: ChatSendRequest, emitEvent: (event: ChatEvent) => void -): ChatSendResponse => { +): Promise => { const validated = chatSendRequestSchema.parse(request); const config = validated.config; const requestId = generateId('req'); - const conversation = getOrCreateConversation(config, config.conversationId); + const conversation = await getOrCreateConversation(config, config.conversationId); const abortController = new AbortController(); activeRequests.set(requestId, abortController); @@ -301,6 +344,10 @@ export const sendMessage = ( conversation.messages.push(userMessage); conversation.updatedAt = userMessage.timestamp; + // Persist user message to disk + console.log('[ChatService] Persisting user message', { conversationId: conversation.id }); + await appendMessage(conversation.id, userMessage); + // Start streaming in background void streamResponse( requestId, @@ -423,6 +470,13 @@ const streamResponse = async ( conversation.messages.push(assistantMessage); conversation.updatedAt = assistantMessage.timestamp; + // Persist assistant message and usage to disk + console.log('[ChatService] Persisting assistant message', { + conversationId: conversation.id, + }); + await appendMessage(conversation.id, assistantMessage); + await updateUsageStats(conversation.id, totalInputTokens, totalOutputTokens); + // Emit done emitEvent({ requestId, diff --git a/src/main/services/conversation.store.service.ts b/src/main/services/conversation.store.service.ts index eb7849b..0d088df 100644 --- a/src/main/services/conversation.store.service.ts +++ b/src/main/services/conversation.store.service.ts @@ -93,23 +93,33 @@ const deriveTitle = (snapshot: ConversationSnapshot): string => { * Initialize conversations directory */ export const initConversationStore = async (): Promise => { + console.log('[ConversationStore] Initializing...', { dir: CONVERSATIONS_DIR }); await mkdir(CONVERSATIONS_DIR, { recursive: true }); await loadIndex(); + console.log('[ConversationStore] Initialized successfully'); }; /** * Load index from disk (with caching) */ const loadIndex = async (): Promise => { - if (indexCache) return indexCache; + if (indexCache) { + console.log('[ConversationStore] Using cached index', { + count: indexCache.conversations.length, + }); + return indexCache; + } try { + console.log('[ConversationStore] Loading index from disk...', { file: INDEX_FILE }); const content = await readFile(INDEX_FILE, 'utf-8'); const parsed = JSON.parse(content) as unknown; indexCache = conversationIndexSchema.parse(parsed); + console.log('[ConversationStore] Index loaded', { count: indexCache.conversations.length }); return indexCache; - } catch { + } catch (error) { // Create default index if file doesn't exist + console.log('[ConversationStore] Index not found, creating default', { error }); const defaultIndex: ConversationIndex = { version: 1, conversations: [], @@ -125,10 +135,17 @@ const loadIndex = async (): Promise => { * Save index to disk */ const saveIndex = async (index: ConversationIndex): Promise => { + console.log('[ConversationStore] Saving index...', { count: index.conversations.length }); index.updatedAt = new Date().toISOString(); - const validated = conversationIndexSchema.parse(index); - await writeFile(INDEX_FILE, JSON.stringify(validated, null, 2), 'utf-8'); - indexCache = validated; + try { + const validated = conversationIndexSchema.parse(index); + await writeFile(INDEX_FILE, JSON.stringify(validated, null, 2), 'utf-8'); + indexCache = validated; + console.log('[ConversationStore] Index saved successfully'); + } catch (error) { + console.error('[ConversationStore] Failed to save index', { error }); + throw error; + } }; /** @@ -148,9 +165,21 @@ const getAbsolutePath = (relativePath: string): string => join(CONVERSATIONS_DIR * Load conversation snapshot from disk */ const loadSnapshot = async (relativePath: string): Promise => { - const content = await readFile(getAbsolutePath(relativePath), 'utf-8'); - const parsed = JSON.parse(content) as unknown; - return conversationSnapshotSchema.parse(parsed); + const absolutePath = getAbsolutePath(relativePath); + console.log('[ConversationStore] Loading snapshot...', { path: absolutePath }); + try { + const content = await readFile(absolutePath, 'utf-8'); + const parsed = JSON.parse(content) as unknown; + const snapshot = conversationSnapshotSchema.parse(parsed); + console.log('[ConversationStore] Snapshot loaded', { + id: snapshot.id, + messages: snapshot.messages.length, + }); + return snapshot; + } catch (error) { + console.error('[ConversationStore] Failed to load snapshot', { path: absolutePath, error }); + throw error; + } }; /** @@ -160,14 +189,30 @@ const saveSnapshot = async ( relativePath: string, snapshot: ConversationSnapshot ): Promise => { - const validated = conversationSnapshotSchema.parse(snapshot); const absolutePath = getAbsolutePath(relativePath); + console.log('[ConversationStore] Saving snapshot...', { + id: snapshot.id, + path: absolutePath, + messages: snapshot.messages.length, + }); - // Ensure date folder exists - const folder = join(CONVERSATIONS_DIR, relativePath.split('/')[0]); - await mkdir(folder, { recursive: true }); - - await writeFile(absolutePath, JSON.stringify(validated, null, 2), 'utf-8'); + try { + const validated = conversationSnapshotSchema.parse(snapshot); + + // Ensure date folder exists + const folder = join(CONVERSATIONS_DIR, relativePath.split('/')[0]); + await mkdir(folder, { recursive: true }); + + await writeFile(absolutePath, JSON.stringify(validated, null, 2), 'utf-8'); + console.log('[ConversationStore] Snapshot saved successfully', { id: snapshot.id }); + } catch (error) { + console.error('[ConversationStore] Failed to save snapshot', { + id: snapshot.id, + path: absolutePath, + error, + }); + throw error; + } }; /** @@ -176,6 +221,12 @@ const saveSnapshot = async ( export const createConversation = async ( options: CreateConversationOptions ): Promise => { + console.log('[ConversationStore] Creating conversation...', { + model: options.model, + provider: options.provider, + agentId: options.agentId, + }); + const validated = createConversationOptionsSchema.parse(options); const id = validated.id ?? generateId(); @@ -229,6 +280,7 @@ export const createConversation = async ( // Emit change event emitConversationsChanged(); + console.log('[ConversationStore] Conversation created', { id, path: relativePath }); return snapshot; }; @@ -327,10 +379,19 @@ export const listConversations = async ( * Accepts ChatMessage and converts to ConversationMessage format for storage */ export const appendMessage = async (id: string, message: ChatMessage): Promise => { + console.log('[ConversationStore] Appending message...', { + conversationId: id, + role: message.role, + contentLength: message.content?.length, + hasToolCalls: !!message.toolCalls?.length, + hasToolResults: !!message.toolResults?.length, + }); + const index = await loadIndex(); const ref = index.conversations.find((c) => c.id === id); if (!ref) { + console.error('[ConversationStore] Conversation not found', { id }); throw new Error(`Conversation ${id} not found`); } @@ -422,6 +483,12 @@ export const appendMessage = async (id: string, message: ChatMessage): Promise