diff --git a/src/main/apiServer/routes/agents/handlers/messages.ts b/src/main/apiServer/routes/agents/handlers/messages.ts index 859329112e..fde9a07a0e 100644 --- a/src/main/apiServer/routes/agents/handlers/messages.ts +++ b/src/main/apiServer/routes/agents/handlers/messages.ts @@ -24,13 +24,13 @@ const verifyAgentAndSession = async (agentId: string, sessionId: string) => { return session } -export const createMessageStream = async (req: Request, res: Response): Promise => { +export const createMessage = async (req: Request, res: Response): Promise => { try { const { agentId, sessionId } = req.params const session = await verifyAgentAndSession(agentId, sessionId) - const messageData = { ...req.body, session_id: sessionId } + const messageData = req.body logger.info(`Creating streaming message for session: ${sessionId}`) logger.debug('Streaming message data:', messageData) @@ -45,7 +45,7 @@ export const createMessageStream = async (req: Request, res: Response): Promise< // Send initial connection event res.write('data: {"type":"start"}\n\n') - const messageStream = sessionMessageService.createSessionMessageStream(session, messageData) + const messageStream = sessionMessageService.createSessionMessage(session, messageData) // Track if the response has ended to prevent further writes let responseEnded = false diff --git a/src/main/apiServer/routes/agents/handlers/sessions.ts b/src/main/apiServer/routes/agents/handlers/sessions.ts index aa09be0f75..c933c0242e 100644 --- a/src/main/apiServer/routes/agents/handlers/sessions.ts +++ b/src/main/apiServer/routes/agents/handlers/sessions.ts @@ -1,19 +1,18 @@ import { loggerService } from '@logger' +import { sessionMessageService, sessionService } from '@main/services/agents' import { Request, Response } from 'express' -import { sessionMessageService, sessionService } from '../../../../services/agents' - const logger = loggerService.withContext('ApiServerSessionsHandlers') export const createSession = async (req: Request, res: Response): Promise => { try { const { agentId } = req.params - const sessionData = { ...req.body, main_agent_id: agentId } + const sessionData = req.body logger.info(`Creating new session for agent: ${agentId}`) logger.debug('Session data:', sessionData) - const session = await sessionService.createSession(sessionData) + const session = await sessionService.createSession(agentId, sessionData) logger.info(`Session created successfully: ${session.id}`) return res.status(201).json(session) @@ -38,7 +37,7 @@ export const listSessions = async (req: Request, res: Response): Promise }) } - // Verify session belongs to the agent - logger.warn(`Session ${sessionId} does not belong to agent ${agentId}`) - return res.status(404).json({ - error: { - message: 'Session not found for this agent', - type: 'not_found', - code: 'session_not_found' - } - }) - } + // // Verify session belongs to the agent + // logger.warn(`Session ${sessionId} does not belong to agent ${agentId}`) + // return res.status(404).json({ + // error: { + // message: 'Session not found for this agent', + // type: 'not_found', + // code: 'session_not_found' + // } + // }) + // } // Fetch session messages logger.info(`Fetching messages for session: ${sessionId}`) @@ -261,7 +260,7 @@ export const listAllSessions = async (req: Request, res: Response): Promise { const messagesRouter = express.Router({ mergeParams: true }) // Message CRUD routes (nested under agent/session) - messagesRouter.post('/', validateSessionMessage, handleValidationErrors, messageHandlers.createMessageStream) + messagesRouter.post('/', validateSessionMessage, handleValidationErrors, messageHandlers.createMessage) return messagesRouter } diff --git a/src/main/apiServer/routes/agents/validators/messages.ts b/src/main/apiServer/routes/agents/validators/messages.ts index b210938520..84de744620 100644 --- a/src/main/apiServer/routes/agents/validators/messages.ts +++ b/src/main/apiServer/routes/agents/validators/messages.ts @@ -1,6 +1,5 @@ import { body } from 'express-validator' export const validateSessionMessage = [ - body('role').notEmpty().isIn(['user', 'agent', 'system', 'tool']).withMessage('Valid role is required'), body('content').notEmpty().isString().withMessage('Content must be a valid string') ] diff --git a/src/main/services/agents/BaseService.ts b/src/main/services/agents/BaseService.ts index de33e0899d..2da1a8aa96 100644 --- a/src/main/services/agents/BaseService.ts +++ b/src/main/services/agents/BaseService.ts @@ -1,11 +1,11 @@ import { type Client, createClient } from '@libsql/client' import { loggerService } from '@logger' -import { drizzle } from 'drizzle-orm/libsql' +import { drizzle, type LibSQLDatabase } from 'drizzle-orm/libsql' import fs from 'fs' import path from 'path' -import * as schema from './database/schema' import { MigrationService } from './database/MigrationService' +import * as schema from './database/schema' import { dbPath } from './drizzle.config' const logger = loggerService.withContext('BaseService') @@ -24,7 +24,7 @@ const logger = loggerService.withContext('BaseService') */ export abstract class BaseService { protected static client: Client | null = null - protected static db: ReturnType | null = null + protected static db: LibSQLDatabase | null = null protected static isInitialized = false protected static initializationPromise: Promise | null = null protected jsonFields: string[] = ['built_in_tools', 'mcps', 'configuration', 'accessible_paths'] @@ -110,7 +110,7 @@ export abstract class BaseService { } } - protected get database(): ReturnType { + protected get database(): LibSQLDatabase { this.ensureInitialized() return BaseService.db! } diff --git a/src/main/services/agents/database/schema/index.ts b/src/main/services/agents/database/schema/index.ts index c8d3a38012..553f94a038 100644 --- a/src/main/services/agents/database/schema/index.ts +++ b/src/main/services/agents/database/schema/index.ts @@ -3,6 +3,6 @@ */ export * from './agents.schema' -export * from './sessions.schema' export * from './messages.schema' export * from './migrations.schema' +export * from './sessions.schema' diff --git a/src/main/services/agents/database/schema/messages.schema.ts b/src/main/services/agents/database/schema/messages.schema.ts index a544633da3..d2754d6ec4 100644 --- a/src/main/services/agents/database/schema/messages.schema.ts +++ b/src/main/services/agents/database/schema/messages.schema.ts @@ -1,4 +1,5 @@ import { foreignKey, index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core' + import { sessionsTable } from './sessions.schema' // session_messages table to log all messages, thoughts, actions, observations in a session diff --git a/src/main/services/agents/database/schema/sessions.schema.ts b/src/main/services/agents/database/schema/sessions.schema.ts index fee8983165..21ac2fe2c6 100644 --- a/src/main/services/agents/database/schema/sessions.schema.ts +++ b/src/main/services/agents/database/schema/sessions.schema.ts @@ -3,10 +3,12 @@ */ import { foreignKey, index, sqliteTable, text } from 'drizzle-orm/sqlite-core' + import { agentsTable } from './agents.schema' export const sessionsTable = sqliteTable('sessions', { id: text('id').primaryKey(), + agent_type: text('agent_type').notNull(), agent_id: text('agent_id').notNull(), // Primary agent ID for the session name: text('name').notNull(), description: text('description'), diff --git a/src/main/services/agents/drizzle.config.ts b/src/main/services/agents/drizzle.config.ts index af31934f56..93752a2c01 100644 --- a/src/main/services/agents/drizzle.config.ts +++ b/src/main/services/agents/drizzle.config.ts @@ -16,12 +16,16 @@ function getDbPath() { return path.join(app.getPath('userData'), 'agents.db') } +const resolvedDbPath = getDbPath() + +export const dbPath = resolvedDbPath + export default defineConfig({ dialect: 'sqlite', schema: './src/main/services/agents/database/schema/index.ts', out: './src/main/services/agents/database/drizzle', dbCredentials: { - url: `file:${getDbPath()}` + url: `file:${resolvedDbPath}` }, verbose: true, strict: true diff --git a/src/main/services/agents/services/SessionMessageService.ts b/src/main/services/agents/services/SessionMessageService.ts index ec98d112e3..57a427a8be 100644 --- a/src/main/services/agents/services/SessionMessageService.ts +++ b/src/main/services/agents/services/SessionMessageService.ts @@ -11,7 +11,7 @@ import { UIMessageChunk } from 'ai' import { count, eq } from 'drizzle-orm' import { BaseService } from '../BaseService' -import { type InsertSessionMessageRow, sessionMessagesTable } from '../database/schema' +import { sessionMessagesTable } from '../database/schema' import ClaudeCodeService from './claudecode' const logger = loggerService.withContext('SessionMessageService') @@ -76,19 +76,19 @@ export class SessionMessageService extends BaseService { return { messages, total } } - createSessionMessageStream(session: GetAgentSessionResponse, messageData: CreateSessionMessageRequest): EventEmitter { + createSessionMessage(session: GetAgentSessionResponse, messageData: CreateSessionMessageRequest): EventEmitter { this.ensureInitialized() // Create a new EventEmitter to manage the session message lifecycle const sessionStream = new EventEmitter() // No parent validation needed, start immediately - this.startClaudeCodeStream(session, messageData, sessionStream) + this.startSessionMessageStream(session, messageData, sessionStream) return sessionStream } - private startClaudeCodeStream( + private startSessionMessageStream( session: GetAgentSessionResponse, req: CreateSessionMessageRequest, sessionStream: EventEmitter @@ -99,7 +99,12 @@ export class SessionMessageService extends BaseService { session_id = previousMessages[0].session_id } - logger.debug('Claude Code stream message data:', { message: req, session_id }) + logger.debug('Session Message stream message data:', { message: req, session_id }) + + if (session.agent_type !== 'claude-code') { + logger.error('Unsupported agent type for streaming:', { agent_type: session.agent_type }) + throw new Error('Unsupported agent type for streaming') + } // Create the streaming agent invocation (using invokeStream for streaming) const claudeStream = this.cc.invoke(req.content, session.accessible_paths[0], session_id, { @@ -107,7 +112,6 @@ export class SessionMessageService extends BaseService { maxTurns: session.configuration?.maxTurns || 10 }) - let sessionMessage: AgentSessionMessageEntity | null = null const streamedChunks: UIMessageChunk[] = [] const rawAgentMessages: any[] = [] // Generic agent messages storage @@ -202,6 +206,10 @@ export class SessionMessageService extends BaseService { // error: new Error('Failed to save session message to database') // }) // } + sessionStream.emit('data', { + type: 'complete', + result: structuredContent + }) break } diff --git a/src/main/services/agents/services/SessionService.ts b/src/main/services/agents/services/SessionService.ts index 6e13656e3d..6b057f0f99 100644 --- a/src/main/services/agents/services/SessionService.ts +++ b/src/main/services/agents/services/SessionService.ts @@ -1,4 +1,5 @@ import type { + AgentEntity, AgentSessionEntity, CreateSessionRequest, GetAgentSessionResponse, @@ -25,18 +26,18 @@ export class SessionService extends BaseService { await BaseService.initialize() } - async createSession(req: CreateSessionRequest): Promise { + async createSession(agentId: string, req: CreateSessionRequest): Promise { this.ensureInitialized() // Validate agent exists - we'll need to import AgentService for this check // For now, we'll skip this validation to avoid circular dependencies // The database foreign key constraint will handle this - const agents = await this.database.select().from(agentsTable).where(eq(agentsTable.id, req.agent_id)).limit(1) + const agents = await this.database.select().from(agentsTable).where(eq(agentsTable.id, agentId)).limit(1) if (!agents[0]) { throw new Error('Agent not found') } - const agent = this.deserializeJsonFields(agents[0]) as AgentSessionEntity + const agent = this.deserializeJsonFields(agents[0]) as AgentEntity const id = `session_${Date.now()}_${Math.random().toString(36).substring(2, 11)}` const now = new Date().toISOString() @@ -51,15 +52,17 @@ export class SessionService extends BaseService { const insertData: InsertSessionRow = { id, + agent_id: agentId, + agent_type: agent.type, name: serializedData.name || null, - agent_id: serializedData.agent_id, description: serializedData.description || null, + accessible_paths: serializedData.accessible_paths || null, + instructions: serializedData.instructions || null, model: serializedData.model || null, plan_model: serializedData.plan_model || null, small_model: serializedData.small_model || null, mcps: serializedData.mcps || null, configuration: serializedData.configuration || null, - accessible_paths: serializedData.accessible_paths || null, created_at: now, updated_at: now } diff --git a/src/main/services/agents/services/claudecode/index.ts b/src/main/services/agents/services/claudecode/index.ts index 73b737c4f1..19bb97b6dd 100644 --- a/src/main/services/agents/services/claudecode/index.ts +++ b/src/main/services/agents/services/claudecode/index.ts @@ -242,155 +242,6 @@ class ClaudeCodeService implements AgentServiceInterface { process.on('error', () => clearTimeout(timeout)) } - /** - * Set up process event handlers and return a promise that resolves with complete output - */ - private setupProcessHandlers(process: ChildProcess): Promise { - return new Promise((resolve, reject) => { - let stdoutData = '' - let stderrData = '' - const jsonOutput: any[] = [] - let hasResolved = false - - const startTime = Date.now() - - // Handle stdout with proper encoding and buffering - if (process.stdout) { - process.stdout.setEncoding('utf8') - process.stdout.on('data', (data: string) => { - stdoutData += data - logger.debug('Agent stdout chunk:', { length: data.length }) - - // Parse JSON stream output line by line - const lines = data.split('\n') - for (const line of lines) { - if (line.trim()) { - try { - const parsed = JSON.parse(line.trim()) - jsonOutput.push(parsed) - logger.silly('Parsed JSON output:', parsed) - } catch (e) { - // Not JSON, might be plain text output - logger.debug('Non-JSON stdout line:', { line: line.trim() }) - } - } - } - }) - - process.stdout.on('end', () => { - logger.debug('Agent stdout stream ended') - }) - } - - // Handle stderr with proper encoding - if (process.stderr) { - process.stderr.setEncoding('utf8') - process.stderr.on('data', (data: string) => { - stderrData += data - logger.warn('Agent stderr chunk:', { data: data.trim() }) - }) - - process.stderr.on('end', () => { - logger.debug('Agent stderr stream ended') - }) - } - - // Handle process exit - process.on('exit', (code, signal) => { - const duration = Date.now() - startTime - const success = code === 0 - const status = success ? 'completed' : 'failed' - - logger.info('Agent process exited', { - code, - signal, - success, - status, - duration, - stdoutLength: stdoutData.length, - stderrLength: stderrData.length, - jsonItems: jsonOutput.length - }) - - if (!hasResolved) { - hasResolved = true - resolve({ - success, - stdout: stdoutData, - stderr: stderrData, - jsonOutput, - exitCode: code || undefined - }) - } - }) - - // Handle process errors - process.on('error', (error) => { - const duration = Date.now() - startTime - logger.error('Agent process error:', { - error: error.message, - duration, - stdoutLength: stdoutData.length, - stderrLength: stderrData.length - }) - - if (!hasResolved) { - hasResolved = true - reject({ - success: false, - stdout: stdoutData, - stderr: stderrData, - jsonOutput, - error - }) - } - }) - - // Handle close event as a fallback - process.on('close', (code, signal) => { - const duration = Date.now() - startTime - logger.debug('Agent process closed', { code, signal, duration }) - - // Only resolve here if exit event hasn't fired - if (!hasResolved) { - hasResolved = true - const success = code === 0 - resolve({ - success, - stdout: stdoutData, - stderr: stderrData, - jsonOutput, - exitCode: code || undefined - }) - } - }) - - // Set a timeout to prevent hanging indefinitely (reduced for debugging) - const timeout = setTimeout(() => { - if (!hasResolved) { - hasResolved = true - logger.error('Agent process timeout after 30 seconds', { - pid: process.pid, - stdoutLength: stdoutData.length, - stderrLength: stderrData.length, - jsonItems: jsonOutput.length - }) - process.kill('SIGTERM') - reject({ - success: false, - stdout: stdoutData, - stderr: stderrData, - jsonOutput, - error: new Error('Process timeout after 30 seconds') - }) - } - }, 30 * 1000) // 30 seconds timeout for debugging - - // Clear timeout when process ends - process.on('exit', () => clearTimeout(timeout)) - process.on('error', () => clearTimeout(timeout)) - }) - } } export default ClaudeCodeService diff --git a/src/main/services/agents/services/index.ts b/src/main/services/agents/services/index.ts index 33bb352a04..0ee284a502 100644 --- a/src/main/services/agents/services/index.ts +++ b/src/main/services/agents/services/index.ts @@ -16,5 +16,10 @@ export { sessionMessageService } from './SessionMessageService' export { sessionService } from './SessionService' // Type definitions for service requests and responses - -export type { CreateSessionRequest, ListSessionsOptions, UpdateSessionRequest } from './SessionService' +export type { AgentEntity, AgentSessionEntity,CreateAgentRequest, UpdateAgentRequest } from '@types' +export type { + AgentSessionMessageEntity, + CreateSessionRequest, + GetAgentSessionResponse, + ListOptions as SessionListOptions, + UpdateSessionRequest} from '@types' diff --git a/src/renderer/src/components/Popups/AgentModal.tsx b/src/renderer/src/components/Popups/AgentModal.tsx index 1e0199833e..21fb68058e 100644 --- a/src/renderer/src/components/Popups/AgentModal.tsx +++ b/src/renderer/src/components/Popups/AgentModal.tsx @@ -23,7 +23,7 @@ import { useTimer } from '@renderer/hooks/useTimer' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import { AgentEntity, AgentType, isAgentType } from '@renderer/types' import { uuid } from '@renderer/utils' -import { ChangeEvent, FormEvent, ReactNode, useCallback, useMemo, useRef, useState } from 'react' +import { ChangeEvent, FormEvent, ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { ErrorBoundary } from '../ErrorBoundary' @@ -45,13 +45,23 @@ interface AgentTypeOption extends Option { type ModelOption = Option type AgentForm = { - type: AgentEntity['type'] - name: AgentEntity['name'] - description?: AgentEntity['description'] - instructions?: AgentEntity['instructions'] - model?: AgentEntity['model'] + type: AgentType + name: string + description?: string + instructions?: string + model: string + accessible_paths: string[] } +const buildAgentForm = (existing?: AgentEntity): AgentForm => ({ + type: existing?.type ?? 'claude-code', + name: existing?.name ?? 'Claude Code', + description: existing?.description, + instructions: existing?.instructions, + model: existing?.model ?? 'claude-4-sonnet', + accessible_paths: existing?.accessible_paths ? [...existing.accessible_paths] : [] +}) + interface BaseProps { agent?: AgentEntity } @@ -88,16 +98,13 @@ export const AgentModal: React.FC = ({ agent, trigger, isOpen: _isOpen, o const { addAgent, updateAgent } = useAgents() const isEditing = (agent?: AgentEntity) => agent !== undefined - // default values. may change to undefined. - const [form, setForm] = useState( - isEditing(agent) - ? agent - : { - type: 'claude-code', - name: 'Claude Code', - model: 'claude-4-sonnet' - } - ) + const [form, setForm] = useState(() => buildAgentForm(agent)) + + useEffect(() => { + if (isOpen) { + setForm(buildAgentForm(agent)) + } + }, [agent, isOpen]) const Option = useCallback( ({ option }: { option?: Option | null }) => { @@ -222,44 +229,51 @@ export const AgentModal: React.FC = ({ agent, trigger, isOpen: _isOpen, o // Additional validation check besides native HTML validation to ensure security if (!isAgentType(form.type)) { window.toast.error(t('agent.add.error.invalid_agent')) + loadingRef.current = false return } - if (form.model === undefined) { + if (!form.model) { window.toast.error(t('error.model.not_exists')) + loadingRef.current = false return } - let _agent: AgentEntity + let resultAgent: AgentEntity if (isEditing(agent)) { - _agent = { - ...agent, - // type: form.type, + if (!agent) { + throw new Error('Agent is required for editing mode') + } + + const updatePayload: Partial & { id: string } = { + id: agent.id, name: form.name, description: form.description, instructions: form.instructions, updated_at: new Date().toISOString(), model: form.model - // avatar: getAvatar(form.type) - } satisfies AgentEntity - updateAgent(_agent) + } + + updateAgent(updatePayload) + resultAgent = { ...agent, ...updatePayload } window.toast.success(t('common.update_success')) } else { - _agent = { + const now = new Date().toISOString() + resultAgent = { id: uuid(), type: form.type, name: form.name, description: form.description, instructions: form.instructions, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), + created_at: now, + updated_at: now, model: form.model, - avatar: getAvatar(form.type) - } satisfies AgentEntity - addAgent(_agent) + accessible_paths: [...form.accessible_paths] + } + addAgent(resultAgent) window.toast.success(t('common.add_success')) } - logger.debug('Agent', _agent) + logger.debug('Agent mutation payload', { agent: resultAgent }) loadingRef.current = false setTimeoutTimer('onCreateAgent', () => EventEmitter.emit(EVENT_NAMES.SHOW_ASSISTANTS), 0) @@ -271,6 +285,7 @@ export const AgentModal: React.FC = ({ agent, trigger, isOpen: _isOpen, o form.name, form.description, form.instructions, + form.accessible_paths, agent, setTimeoutTimer, onClose, @@ -339,8 +354,16 @@ export const AgentModal: React.FC = ({ agent, trigger, isOpen: _isOpen, o )} -