mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-24 19:41:14 +02:00
Merge pull request #63 from multica-ai/forrestchang/arch-review
refactor(conductor): split monolithic class into focused modules
This commit is contained in:
181
src/main/conductor/AgentProcessManager.ts
Normal file
181
src/main/conductor/AgentProcessManager.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* AgentProcessManager - Manages agent process lifecycle
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Starting agent subprocesses for sessions
|
||||
* - Managing agent process pool (sessions Map)
|
||||
* - Stopping individual or all agent processes
|
||||
* - Tracking running sessions
|
||||
*/
|
||||
import { ClientSideConnection, ndJsonStream, PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
|
||||
import { AgentProcess } from './AgentProcess'
|
||||
import { createAcpClient } from './AcpClientFactory'
|
||||
import type { AgentConfig } from '../../shared/types'
|
||||
import type {
|
||||
IAgentProcessManager,
|
||||
AgentProcessManagerOptions,
|
||||
SessionAgent,
|
||||
AgentStartResult,
|
||||
ISessionStore
|
||||
} from './types'
|
||||
|
||||
export class AgentProcessManager implements IAgentProcessManager {
|
||||
private sessionStore: ISessionStore | null
|
||||
private events: AgentProcessManagerOptions['events']
|
||||
|
||||
/**
|
||||
* Map of Multica sessionId -> agent state (each session has its own process)
|
||||
*/
|
||||
private sessions: Map<string, SessionAgent> = new Map()
|
||||
|
||||
constructor(options: AgentProcessManagerOptions) {
|
||||
this.sessionStore = options.sessionStore
|
||||
this.events = options.events
|
||||
}
|
||||
|
||||
/**
|
||||
* Start an agent process for a session
|
||||
* @param sessionId - Multica session ID
|
||||
* @param config - Agent configuration
|
||||
* @param cwd - Working directory
|
||||
* @param isResumed - Whether this is a resumed session (needs history replay)
|
||||
*/
|
||||
async start(
|
||||
sessionId: string,
|
||||
config: AgentConfig,
|
||||
cwd: string,
|
||||
isResumed: boolean = false
|
||||
): Promise<AgentStartResult> {
|
||||
console.log(`[AgentProcessManager] Starting agent for session ${sessionId}: ${config.name}`)
|
||||
|
||||
// Start the agent subprocess
|
||||
const agentProcess = new AgentProcess(config)
|
||||
await agentProcess.start()
|
||||
|
||||
// Create ACP connection using the SDK
|
||||
const stream = ndJsonStream(agentProcess.getStdinWeb(), agentProcess.getStdoutWeb())
|
||||
|
||||
// Create client-side connection with our Client implementation
|
||||
const connection = new ClientSideConnection(
|
||||
(_agent) =>
|
||||
createAcpClient(sessionId, {
|
||||
sessionStore: this.sessionStore as any, // Cast to SessionStore for compatibility
|
||||
callbacks: {
|
||||
onSessionUpdate: this.events.onSessionUpdate,
|
||||
onPermissionRequest: this.events.onPermissionRequest
|
||||
}
|
||||
}),
|
||||
stream
|
||||
)
|
||||
|
||||
// Initialize the ACP connection
|
||||
const initResult = await connection.initialize({
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
clientCapabilities: {
|
||||
fs: {
|
||||
readTextFile: false,
|
||||
writeTextFile: false
|
||||
},
|
||||
terminal: false
|
||||
}
|
||||
})
|
||||
|
||||
console.log(
|
||||
`[AgentProcessManager] ACP connected to ${config.name} (protocol v${initResult.protocolVersion})`
|
||||
)
|
||||
|
||||
// Create ACP session
|
||||
const acpResult = await connection.newSession({
|
||||
cwd,
|
||||
mcpServers: []
|
||||
})
|
||||
|
||||
// Handle agent process exit
|
||||
agentProcess.onExit((code, signal) => {
|
||||
console.log(
|
||||
`[AgentProcessManager] Agent for session ${sessionId} exited (code: ${code}, signal: ${signal})`
|
||||
)
|
||||
this.sessions.delete(sessionId)
|
||||
})
|
||||
|
||||
// Store in sessions map
|
||||
const sessionAgent: SessionAgent = {
|
||||
agentProcess,
|
||||
connection,
|
||||
agentConfig: config,
|
||||
agentSessionId: acpResult.sessionId,
|
||||
needsHistoryReplay: isResumed // True when resuming, agent needs conversation context
|
||||
}
|
||||
this.sessions.set(sessionId, sessionAgent)
|
||||
|
||||
return { connection, agentSessionId: acpResult.sessionId }
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop a session's agent process
|
||||
*/
|
||||
async stop(sessionId: string): Promise<void> {
|
||||
const sessionAgent = this.sessions.get(sessionId)
|
||||
if (sessionAgent) {
|
||||
console.log(
|
||||
`[AgentProcessManager] Stopping session ${sessionId} agent: ${sessionAgent.agentConfig.name}`
|
||||
)
|
||||
await sessionAgent.agentProcess.stop()
|
||||
this.sessions.delete(sessionId)
|
||||
console.log(`[AgentProcessManager] Session ${sessionId} agent stopped`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop all running agent processes
|
||||
*/
|
||||
async stopAll(): Promise<void> {
|
||||
const sessionIds = Array.from(this.sessions.keys())
|
||||
for (const sessionId of sessionIds) {
|
||||
await this.stop(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session agent state by session ID
|
||||
*/
|
||||
get(sessionId: string): SessionAgent | undefined {
|
||||
return this.sessions.get(sessionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set session agent state (used by other modules when needed)
|
||||
*/
|
||||
set(sessionId: string, sessionAgent: SessionAgent): void {
|
||||
this.sessions.set(sessionId, sessionAgent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove session agent state
|
||||
*/
|
||||
remove(sessionId: string): void {
|
||||
this.sessions.delete(sessionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a session has a running agent
|
||||
*/
|
||||
isRunning(sessionId: string): boolean {
|
||||
const sessionAgent = this.sessions.get(sessionId)
|
||||
return sessionAgent?.agentProcess.isRunning() ?? false
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all running session IDs
|
||||
*/
|
||||
getRunningSessionIds(): string[] {
|
||||
return Array.from(this.sessions.keys())
|
||||
}
|
||||
|
||||
/**
|
||||
* Get agent config for a session
|
||||
*/
|
||||
getAgentConfig(sessionId: string): AgentConfig | null {
|
||||
return this.sessions.get(sessionId)?.agentConfig ?? null
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,15 @@
|
||||
/**
|
||||
* Conductor - Central orchestrator for ACP agent communication
|
||||
* Conductor - Central orchestrator for ACP agent communication (Facade)
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Session lifecycle management (create, resume, load, delete)
|
||||
* - Agent process orchestration
|
||||
* - Prompt handling with history replay
|
||||
* This class acts as a facade that coordinates the following modules:
|
||||
* - SessionLifecycle: Session CRUD operations
|
||||
* - AgentProcessManager: Agent process lifecycle
|
||||
* - PromptHandler: Prompt sending with history replay
|
||||
* - G3Workaround: AskUserQuestion answer injection
|
||||
*
|
||||
* The public API remains unchanged for backward compatibility with callers.
|
||||
*/
|
||||
import {
|
||||
ClientSideConnection,
|
||||
ndJsonStream,
|
||||
PROTOCOL_VERSION,
|
||||
type SessionNotification,
|
||||
type RequestPermissionRequest,
|
||||
type RequestPermissionResponse
|
||||
} from '@agentclientprotocol/sdk'
|
||||
import { AgentProcess } from './AgentProcess'
|
||||
import { SessionStore } from '../session/SessionStore'
|
||||
import { DEFAULT_AGENTS } from '../config/defaults'
|
||||
import { createAcpClient } from './AcpClientFactory'
|
||||
import type {
|
||||
AgentConfig,
|
||||
MulticaSession,
|
||||
@@ -25,537 +17,112 @@ import type {
|
||||
ListSessionsOptions,
|
||||
AskUserQuestionResponseData
|
||||
} from '../../shared/types'
|
||||
import type { MessageContent, MessageContentItem } from '../../shared/types/message'
|
||||
import { formatHistoryForReplay, hasReplayableHistory } from './historyReplay'
|
||||
import type { MessageContent } from '../../shared/types/message'
|
||||
|
||||
export interface SessionUpdateCallback {
|
||||
(update: SessionNotification, sequenceNumber?: number): void
|
||||
}
|
||||
// Import modules
|
||||
import { AgentProcessManager } from './AgentProcessManager'
|
||||
import { PromptHandler } from './PromptHandler'
|
||||
import { G3Workaround } from './G3Workaround'
|
||||
import { SessionLifecycle } from './SessionLifecycle'
|
||||
|
||||
export interface ConductorEvents {
|
||||
onSessionUpdate?: SessionUpdateCallback
|
||||
onPermissionRequest?: (params: RequestPermissionRequest) => Promise<RequestPermissionResponse>
|
||||
onStatusChange?: () => void
|
||||
/** Called when session metadata changes (e.g., agentSessionId after lazy start) */
|
||||
onSessionMetaUpdated?: (session: MulticaSession) => void
|
||||
}
|
||||
// Import types
|
||||
import type {
|
||||
ConductorEvents,
|
||||
ConductorOptions,
|
||||
SessionUpdateCallback,
|
||||
ISessionStore
|
||||
} from './types'
|
||||
|
||||
export interface ConductorOptions {
|
||||
events?: ConductorEvents
|
||||
/** Skip session persistence (for CLI mode) */
|
||||
skipPersistence?: boolean
|
||||
/** Custom storage path for sessions */
|
||||
storagePath?: string
|
||||
}
|
||||
|
||||
/** Per-session agent state */
|
||||
interface SessionAgent {
|
||||
agentProcess: AgentProcess
|
||||
connection: ClientSideConnection
|
||||
agentConfig: AgentConfig
|
||||
agentSessionId: string
|
||||
/** Whether the first prompt needs history prepended (for resumed sessions) */
|
||||
needsHistoryReplay: boolean
|
||||
}
|
||||
// Re-export types for backward compatibility
|
||||
export type { SessionUpdateCallback, ConductorEvents, ConductorOptions }
|
||||
|
||||
export class Conductor {
|
||||
private events: ConductorEvents
|
||||
private sessionStore: SessionStore | null = null
|
||||
private sessionStore: ISessionStore | null = null
|
||||
private skipPersistence: boolean
|
||||
|
||||
// Map of Multica sessionId -> agent state (each session has its own process)
|
||||
private sessions: Map<string, SessionAgent> = new Map()
|
||||
// Set of session IDs currently processing a request
|
||||
private processingSessions: Set<string> = new Set()
|
||||
// In-memory session for CLI mode (when persistence is skipped)
|
||||
private inMemorySession: MulticaSession | null = null
|
||||
|
||||
/**
|
||||
* Pending answers from AskUserQuestion tool (G-3 workaround)
|
||||
*
|
||||
* ACP's AskUserQuestion tool has a limitation: it only returns
|
||||
* "User has answered the question(s)" to the agent, without the actual
|
||||
* user selection. This Map stores the user's answers so they can be
|
||||
* injected into the next prompt as context.
|
||||
*
|
||||
* Flow:
|
||||
* 1. User selects an option in AskUserQuestion UI
|
||||
* 2. Answer is stored here via addPendingAnswer()
|
||||
* 3. Current agent turn is cancelled (G-3 mechanism)
|
||||
* 4. Re-prompt is sent, which injects these answers as context
|
||||
* 5. Agent now sees the actual user selection
|
||||
*/
|
||||
private pendingAnswers: Map<string, Array<{ question: string; answer: string }>> = new Map()
|
||||
// Modules
|
||||
private agentProcessManager: AgentProcessManager
|
||||
private g3Workaround: G3Workaround
|
||||
private promptHandler: PromptHandler
|
||||
private sessionLifecycle: SessionLifecycle
|
||||
|
||||
constructor(options: ConductorOptions = {}) {
|
||||
this.events = options.events ?? {}
|
||||
this.skipPersistence = options.skipPersistence ?? false
|
||||
|
||||
// Initialize session store
|
||||
if (!this.skipPersistence) {
|
||||
this.sessionStore = new SessionStore(options.storagePath)
|
||||
this.sessionStore = new SessionStore(options.storagePath) as unknown as ISessionStore
|
||||
}
|
||||
|
||||
// Initialize modules with dependencies
|
||||
this.agentProcessManager = new AgentProcessManager({
|
||||
sessionStore: this.sessionStore,
|
||||
events: this.events
|
||||
})
|
||||
|
||||
this.g3Workaround = new G3Workaround({
|
||||
sessionStore: this.sessionStore,
|
||||
events: this.events
|
||||
})
|
||||
|
||||
this.sessionLifecycle = new SessionLifecycle({
|
||||
sessionStore: this.sessionStore,
|
||||
agentProcessManager: this.agentProcessManager,
|
||||
events: this.events
|
||||
})
|
||||
|
||||
this.promptHandler = new PromptHandler({
|
||||
sessionStore: this.sessionStore,
|
||||
agentProcessManager: this.agentProcessManager,
|
||||
g3Workaround: this.g3Workaround,
|
||||
events: this.events,
|
||||
ensureAgent: (sessionId: string) => this.sessionLifecycle.ensureAgentForSession(sessionId)
|
||||
})
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Initialization
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Initialize the conductor (must be called before use in GUI mode)
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
if (this.sessionStore) {
|
||||
await this.sessionStore.initialize()
|
||||
}
|
||||
await this.sessionLifecycle.initialize()
|
||||
}
|
||||
|
||||
/**
|
||||
* Start an agent process for a session (internal helper)
|
||||
* @param isResumed - Whether this is resuming an existing session (needs history replay)
|
||||
*/
|
||||
private async startAgentForSession(
|
||||
sessionId: string,
|
||||
config: AgentConfig,
|
||||
cwd: string,
|
||||
isResumed: boolean = false
|
||||
): Promise<{ connection: ClientSideConnection; agentSessionId: string }> {
|
||||
console.log(`[Conductor] Starting agent for session ${sessionId}: ${config.name}`)
|
||||
|
||||
// Start the agent subprocess
|
||||
const agentProcess = new AgentProcess(config)
|
||||
await agentProcess.start()
|
||||
|
||||
// Create ACP connection using the SDK
|
||||
const stream = ndJsonStream(agentProcess.getStdinWeb(), agentProcess.getStdoutWeb())
|
||||
|
||||
// Create client-side connection with our Client implementation
|
||||
const connection = new ClientSideConnection(
|
||||
(_agent) =>
|
||||
createAcpClient(sessionId, {
|
||||
sessionStore: this.sessionStore,
|
||||
callbacks: {
|
||||
onSessionUpdate: this.events.onSessionUpdate,
|
||||
onPermissionRequest: this.events.onPermissionRequest
|
||||
}
|
||||
}),
|
||||
stream
|
||||
)
|
||||
|
||||
// Initialize the ACP connection
|
||||
const initResult = await connection.initialize({
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
clientCapabilities: {
|
||||
fs: {
|
||||
readTextFile: false,
|
||||
writeTextFile: false
|
||||
},
|
||||
terminal: false
|
||||
}
|
||||
})
|
||||
|
||||
console.log(
|
||||
`[Conductor] ACP connected to ${config.name} (protocol v${initResult.protocolVersion})`
|
||||
)
|
||||
|
||||
// Create ACP session
|
||||
const acpResult = await connection.newSession({
|
||||
cwd,
|
||||
mcpServers: []
|
||||
})
|
||||
|
||||
// Handle agent process exit
|
||||
agentProcess.onExit((code, signal) => {
|
||||
console.log(
|
||||
`[Conductor] Agent for session ${sessionId} exited (code: ${code}, signal: ${signal})`
|
||||
)
|
||||
this.sessions.delete(sessionId)
|
||||
})
|
||||
|
||||
// Store in sessions map
|
||||
this.sessions.set(sessionId, {
|
||||
agentProcess,
|
||||
connection,
|
||||
agentConfig: config,
|
||||
agentSessionId: acpResult.sessionId,
|
||||
needsHistoryReplay: isResumed // True when resuming, agent needs conversation context
|
||||
})
|
||||
|
||||
return { connection, agentSessionId: acpResult.sessionId }
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop a session's agent process
|
||||
*/
|
||||
async stopSession(sessionId: string): Promise<void> {
|
||||
const sessionAgent = this.sessions.get(sessionId)
|
||||
if (sessionAgent) {
|
||||
console.log(
|
||||
`[Conductor] Stopping session ${sessionId} agent: ${sessionAgent.agentConfig.name}`
|
||||
)
|
||||
await sessionAgent.agentProcess.stop()
|
||||
this.sessions.delete(sessionId)
|
||||
console.log(`[Conductor] Session ${sessionId} agent stopped`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop all session agents
|
||||
*/
|
||||
async stopAllSessions(): Promise<void> {
|
||||
const sessionIds = Array.from(this.sessions.keys())
|
||||
for (const sessionId of sessionIds) {
|
||||
await this.stopSession(sessionId)
|
||||
}
|
||||
}
|
||||
// ==========================================================================
|
||||
// Session Lifecycle (delegated to SessionLifecycle)
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Create a new session (agent starts lazily on first prompt)
|
||||
*/
|
||||
async createSession(cwd: string, agentConfig: AgentConfig): Promise<MulticaSession> {
|
||||
let session: MulticaSession
|
||||
|
||||
if (this.sessionStore) {
|
||||
// Create session record - agent will be started lazily via ensureAgentForSession
|
||||
session = await this.sessionStore.create({
|
||||
agentSessionId: '', // Will be filled by ensureAgentForSession on first prompt
|
||||
agentId: agentConfig.id,
|
||||
workingDirectory: cwd
|
||||
})
|
||||
} else {
|
||||
// CLI mode: in-memory session
|
||||
const { randomUUID } = await import('crypto')
|
||||
const now = new Date().toISOString()
|
||||
session = {
|
||||
id: randomUUID(),
|
||||
agentSessionId: '',
|
||||
agentId: agentConfig.id,
|
||||
workingDirectory: cwd,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
status: 'active',
|
||||
messageCount: 0
|
||||
}
|
||||
this.inMemorySession = session
|
||||
}
|
||||
|
||||
// Agent will be started lazily when user sends first prompt (via ensureAgentForSession)
|
||||
console.log(`[Conductor] Created session: ${session.id} (agent: pending)`)
|
||||
|
||||
return session
|
||||
return this.sessionLifecycle.create(cwd, agentConfig)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume an existing session (starts a new agent process for it)
|
||||
*/
|
||||
async resumeSession(sessionId: string): Promise<MulticaSession> {
|
||||
if (!this.sessionStore) {
|
||||
throw new Error('Session resumption not available in CLI mode')
|
||||
}
|
||||
|
||||
const data = await this.sessionStore.get(sessionId)
|
||||
if (!data) {
|
||||
throw new Error(`Session not found: ${sessionId}`)
|
||||
}
|
||||
|
||||
// If session already has a running agent, return as-is
|
||||
if (this.sessions.has(sessionId)) {
|
||||
console.log(`[Conductor] Session ${sessionId} already has a running agent`)
|
||||
return data.session
|
||||
}
|
||||
|
||||
// Get agent config
|
||||
const agentConfig = DEFAULT_AGENTS[data.session.agentId]
|
||||
if (!agentConfig) {
|
||||
throw new Error(`Unknown agent: ${data.session.agentId}`)
|
||||
}
|
||||
|
||||
// Start a new agent process for this session (isResumed = true)
|
||||
const { agentSessionId } = await this.startAgentForSession(
|
||||
sessionId,
|
||||
agentConfig,
|
||||
data.session.workingDirectory,
|
||||
true // Resumed session needs history replay
|
||||
)
|
||||
|
||||
// Update agentSessionId (new ACP session)
|
||||
const updatedSession = await this.sessionStore.updateMeta(sessionId, {
|
||||
agentSessionId,
|
||||
status: 'active'
|
||||
})
|
||||
|
||||
console.log(`[Conductor] Resumed session: ${sessionId} (new agent session: ${agentSessionId})`)
|
||||
|
||||
return updatedSession
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a prompt to the agent (supports text and images)
|
||||
* @param sessionId - Multica session ID
|
||||
* @param content - Message content (text, images)
|
||||
* @param options - Optional settings
|
||||
* @param options.internal - If true, message is sent to agent but not displayed in UI
|
||||
* Used by G-3 mechanism to send user answers without creating visible messages
|
||||
*/
|
||||
async sendPrompt(
|
||||
sessionId: string,
|
||||
content: MessageContent,
|
||||
options?: { internal?: boolean }
|
||||
): Promise<string> {
|
||||
// Ensure agent is running (lazy start if needed)
|
||||
const sessionAgent = await this.ensureAgentForSession(sessionId)
|
||||
const { connection, agentSessionId } = sessionAgent
|
||||
|
||||
// Convert MessageContent to ACP SDK format
|
||||
const convertToAcpFormat = (
|
||||
items: MessageContent
|
||||
): Array<{ type: string; text?: string; data?: string; mimeType?: string }> => {
|
||||
return items.map((item: MessageContentItem) => {
|
||||
if (item.type === 'text') {
|
||||
return { type: 'text', text: item.text }
|
||||
} else if (item.type === 'image') {
|
||||
return { type: 'image', data: item.data, mimeType: item.mimeType }
|
||||
}
|
||||
return { type: 'text', text: '' } // fallback
|
||||
})
|
||||
}
|
||||
|
||||
// Build prompt content array
|
||||
let promptContent = convertToAcpFormat(content)
|
||||
|
||||
// If this is a resumed session, prepend conversation history to first prompt
|
||||
if (sessionAgent.needsHistoryReplay && this.sessionStore) {
|
||||
try {
|
||||
const data = await this.sessionStore.get(sessionId)
|
||||
if (data && hasReplayableHistory(data.updates)) {
|
||||
const history = formatHistoryForReplay(data.updates)
|
||||
if (history) {
|
||||
console.log(
|
||||
`[Conductor] Prepending conversation history (${data.updates.length} updates)`
|
||||
)
|
||||
// Prepend history as text block before other content
|
||||
promptContent = [{ type: 'text', text: history }, ...promptContent]
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[Conductor] Failed to load history for replay:`, error)
|
||||
// Continue without history - better than blocking the prompt
|
||||
} finally {
|
||||
// Always mark as replayed to prevent repeated attempts
|
||||
sessionAgent.needsHistoryReplay = false
|
||||
}
|
||||
}
|
||||
|
||||
// Inject pending user answers from AskUserQuestion (G-3 workaround)
|
||||
// See pendingAnswers field comment for the full explanation
|
||||
const pendingAnswers = this.getPendingAnswers(sessionId)
|
||||
if (pendingAnswers.length > 0) {
|
||||
const answerContext = pendingAnswers
|
||||
.map((a) => `[User's answer to "${a.question}"]: ${a.answer}`)
|
||||
.join('\n')
|
||||
|
||||
console.log(
|
||||
`[Conductor] Injecting ${pendingAnswers.length} pending answer(s) for session ${sessionId}`
|
||||
)
|
||||
|
||||
// Prepend answers as context before user's message
|
||||
promptContent = [{ type: 'text', text: `---\n${answerContext}\n---\n` }, ...promptContent]
|
||||
|
||||
// Clear pending answers after injection
|
||||
this.clearPendingAnswers(sessionId)
|
||||
}
|
||||
|
||||
// Log prompt info
|
||||
const textContent = content.find((c: MessageContentItem) => c.type === 'text')
|
||||
const imageCount = content.filter((c: MessageContentItem) => c.type === 'image').length
|
||||
console.log(`[Conductor] Sending prompt to session ${agentSessionId}`)
|
||||
if (textContent && textContent.type === 'text') {
|
||||
console.log(
|
||||
`[Conductor] Text: ${textContent.text.slice(0, 100)}${textContent.text.length > 100 ? '...' : ''}`
|
||||
)
|
||||
}
|
||||
if (imageCount > 0) {
|
||||
console.log(`[Conductor] Images: ${imageCount}`)
|
||||
}
|
||||
|
||||
// Store user message before sending (so it appears in history)
|
||||
// Internal messages are stored with _internal flag for filtering in UI
|
||||
if (this.sessionStore) {
|
||||
const userUpdate = {
|
||||
sessionId: agentSessionId,
|
||||
update: {
|
||||
sessionUpdate: 'user_message',
|
||||
content: content, // Store full MessageContent array
|
||||
_internal: options?.internal ?? false // G-3: internal messages not shown in UI
|
||||
}
|
||||
}
|
||||
await this.sessionStore.appendUpdate(sessionId, userUpdate as any)
|
||||
}
|
||||
|
||||
// Mark session as processing and broadcast status change
|
||||
this.processingSessions.add(sessionId)
|
||||
this.events.onStatusChange?.()
|
||||
|
||||
try {
|
||||
const result = await connection.prompt({
|
||||
sessionId: agentSessionId,
|
||||
prompt: promptContent as any
|
||||
})
|
||||
|
||||
console.log(`[Conductor] Prompt completed with stopReason: ${result.stopReason}`)
|
||||
|
||||
return result.stopReason
|
||||
} catch (error) {
|
||||
console.error(`[Conductor] ACP error for session ${sessionId}:`, error)
|
||||
|
||||
// Parse ACP error to user-friendly message
|
||||
const message = this.parseAcpError(error)
|
||||
|
||||
// DESIGN DECISION: Event-driven error handling instead of throwing
|
||||
//
|
||||
// Why not throw?
|
||||
// - Throwing causes IPC handler to fail, frontend receives an error response
|
||||
// - This typically triggers a toast/dialog, interrupting user flow
|
||||
//
|
||||
// Why emit an event?
|
||||
// - Error appears inline in the chat as a message, keeping context visible
|
||||
// - User can read the error and continue the conversation naturally
|
||||
// - Consistent with how agent responses are already delivered (via events)
|
||||
//
|
||||
// This pattern is similar to how OpenWork handles task errors:
|
||||
// errors are forwarded to the renderer as updates, not thrown as exceptions.
|
||||
if (this.events.onSessionUpdate) {
|
||||
this.events.onSessionUpdate({
|
||||
sessionId: agentSessionId,
|
||||
update: {
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: {
|
||||
type: 'text',
|
||||
text: `\n\n**Error:** ${message}\n`
|
||||
}
|
||||
}
|
||||
} as SessionNotification)
|
||||
}
|
||||
|
||||
// Return 'error' as stopReason - IPC succeeds, caller knows it was an error
|
||||
return 'error'
|
||||
} finally {
|
||||
// Always remove from processing when done (success or error)
|
||||
this.processingSessions.delete(sessionId)
|
||||
this.events.onStatusChange?.()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel an ongoing request
|
||||
*/
|
||||
async cancelRequest(sessionId: string): Promise<void> {
|
||||
const sessionAgent = this.sessions.get(sessionId)
|
||||
if (!sessionAgent) {
|
||||
return
|
||||
}
|
||||
|
||||
const { connection, agentSessionId } = sessionAgent
|
||||
console.log(`[Conductor] Cancelling request for session ${agentSessionId}`)
|
||||
await connection.cancel({ sessionId: agentSessionId })
|
||||
console.log(`[Conductor] Cancel request sent`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session list
|
||||
*/
|
||||
async listSessions(options?: ListSessionsOptions): Promise<MulticaSession[]> {
|
||||
if (!this.sessionStore) {
|
||||
return this.inMemorySession ? [this.inMemorySession] : []
|
||||
}
|
||||
return this.sessionStore.list(options)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session complete data (including message history)
|
||||
*/
|
||||
async getSessionData(sessionId: string): Promise<SessionData | null> {
|
||||
if (!this.sessionStore) {
|
||||
return null
|
||||
}
|
||||
return this.sessionStore.get(sessionId)
|
||||
return this.sessionLifecycle.resume(sessionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a session without starting its agent (lazy loading)
|
||||
*/
|
||||
async loadSession(sessionId: string): Promise<MulticaSession> {
|
||||
if (!this.sessionStore) {
|
||||
throw new Error('Session loading not available in CLI mode')
|
||||
}
|
||||
|
||||
const data = await this.sessionStore.get(sessionId)
|
||||
if (!data) {
|
||||
throw new Error(`Session not found: ${sessionId}`)
|
||||
}
|
||||
|
||||
return data.session
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure an agent is running for a session (start if needed)
|
||||
*/
|
||||
private async ensureAgentForSession(sessionId: string): Promise<SessionAgent> {
|
||||
// If already running, return existing
|
||||
const existing = this.sessions.get(sessionId)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
|
||||
// Load session data
|
||||
if (!this.sessionStore) {
|
||||
throw new Error('Cannot auto-start agent in CLI mode')
|
||||
}
|
||||
|
||||
const data = await this.sessionStore.get(sessionId)
|
||||
if (!data) {
|
||||
throw new Error(`Session not found: ${sessionId}`)
|
||||
}
|
||||
|
||||
// Get agent config
|
||||
const agentConfig = DEFAULT_AGENTS[data.session.agentId]
|
||||
if (!agentConfig) {
|
||||
throw new Error(`Unknown agent: ${data.session.agentId}`)
|
||||
}
|
||||
|
||||
console.log(`[Conductor] Lazy-starting agent for session ${sessionId}`)
|
||||
|
||||
// Start agent (isResumed = true for lazy start of existing session)
|
||||
const { agentSessionId } = await this.startAgentForSession(
|
||||
sessionId,
|
||||
agentConfig,
|
||||
data.session.workingDirectory,
|
||||
true // Existing session needs history replay
|
||||
)
|
||||
|
||||
// Update session with new agentSessionId
|
||||
const updatedSession = await this.sessionStore.updateMeta(sessionId, {
|
||||
agentSessionId,
|
||||
status: 'active'
|
||||
})
|
||||
|
||||
// Notify frontend of session metadata change (important for agentSessionId update)
|
||||
// This must happen BEFORE sending the prompt so frontend can receive messages
|
||||
if (this.events.onSessionMetaUpdated) {
|
||||
this.events.onSessionMetaUpdated(updatedSession)
|
||||
}
|
||||
|
||||
return this.sessions.get(sessionId)!
|
||||
return this.sessionLifecycle.load(sessionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a session
|
||||
*/
|
||||
async deleteSession(sessionId: string): Promise<void> {
|
||||
// Stop the session's agent process first
|
||||
await this.stopSession(sessionId)
|
||||
|
||||
// Delete from store
|
||||
if (this.sessionStore) {
|
||||
await this.sessionStore.delete(sessionId)
|
||||
}
|
||||
if (this.inMemorySession?.id === sessionId) {
|
||||
this.inMemorySession = null
|
||||
}
|
||||
return this.sessionLifecycle.delete(sessionId)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -565,142 +132,136 @@ export class Conductor {
|
||||
sessionId: string,
|
||||
updates: Partial<MulticaSession>
|
||||
): Promise<MulticaSession> {
|
||||
if (!this.sessionStore) {
|
||||
throw new Error('Session update not available in CLI mode')
|
||||
}
|
||||
return this.sessionStore.updateMeta(sessionId, updates)
|
||||
return this.sessionLifecycle.updateMeta(sessionId, updates)
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch a session's agent (stops current, updates, starts new)
|
||||
*/
|
||||
async switchSessionAgent(sessionId: string, newAgentId: string): Promise<MulticaSession> {
|
||||
if (!this.sessionStore) {
|
||||
throw new Error('Session agent switch not available in CLI mode')
|
||||
}
|
||||
return this.sessionLifecycle.switchAgent(sessionId, newAgentId)
|
||||
}
|
||||
|
||||
const data = await this.sessionStore.get(sessionId)
|
||||
if (!data) {
|
||||
throw new Error(`Session not found: ${sessionId}`)
|
||||
}
|
||||
/**
|
||||
* Get session list
|
||||
*/
|
||||
async listSessions(options?: ListSessionsOptions): Promise<MulticaSession[]> {
|
||||
return this.sessionLifecycle.list(options)
|
||||
}
|
||||
|
||||
// Get new agent config
|
||||
const newAgentConfig = DEFAULT_AGENTS[newAgentId]
|
||||
if (!newAgentConfig) {
|
||||
throw new Error(`Unknown agent: ${newAgentId}`)
|
||||
}
|
||||
/**
|
||||
* Get session complete data (including message history)
|
||||
*/
|
||||
async getSessionData(sessionId: string): Promise<SessionData | null> {
|
||||
return this.sessionLifecycle.getData(sessionId)
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[Conductor] Switching session ${sessionId} from ${data.session.agentId} to ${newAgentId}`
|
||||
)
|
||||
// ==========================================================================
|
||||
// Agent Process Management (delegated to AgentProcessManager)
|
||||
// ==========================================================================
|
||||
|
||||
// Stop current agent if running
|
||||
await this.stopSession(sessionId)
|
||||
/**
|
||||
* Stop a session's agent process
|
||||
*/
|
||||
async stopSession(sessionId: string): Promise<void> {
|
||||
return this.agentProcessManager.stop(sessionId)
|
||||
}
|
||||
|
||||
// Update session's agentId
|
||||
let updatedSession = await this.sessionStore.updateMeta(sessionId, {
|
||||
agentId: newAgentId
|
||||
})
|
||||
|
||||
// Start new agent (isResumed = true to replay history)
|
||||
const { agentSessionId } = await this.startAgentForSession(
|
||||
sessionId,
|
||||
newAgentConfig,
|
||||
data.session.workingDirectory,
|
||||
true
|
||||
)
|
||||
|
||||
// Update agentSessionId
|
||||
updatedSession = await this.sessionStore.updateMeta(sessionId, {
|
||||
agentSessionId,
|
||||
status: 'active'
|
||||
})
|
||||
|
||||
// Notify frontend
|
||||
if (this.events.onSessionMetaUpdated) {
|
||||
this.events.onSessionMetaUpdated(updatedSession)
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[Conductor] Session ${sessionId} switched to ${newAgentId} (agent session: ${agentSessionId})`
|
||||
)
|
||||
|
||||
return updatedSession
|
||||
/**
|
||||
* Stop all session agents
|
||||
*/
|
||||
async stopAllSessions(): Promise<void> {
|
||||
return this.agentProcessManager.stopAll()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get agent config for a session
|
||||
*/
|
||||
getSessionAgent(sessionId: string): AgentConfig | null {
|
||||
return this.sessions.get(sessionId)?.agentConfig ?? null
|
||||
return this.agentProcessManager.getAgentConfig(sessionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a session has a running agent
|
||||
*/
|
||||
isSessionRunning(sessionId: string): boolean {
|
||||
const sessionAgent = this.sessions.get(sessionId)
|
||||
return sessionAgent?.agentProcess.isRunning() ?? false
|
||||
return this.agentProcessManager.isRunning(sessionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all running session IDs (sessions with agent process running)
|
||||
*/
|
||||
getRunningSessionIds(): string[] {
|
||||
return Array.from(this.sessions.keys())
|
||||
return this.agentProcessManager.getRunningSessionIds()
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Prompt Handling (delegated to PromptHandler)
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Send a prompt to the agent (supports text and images)
|
||||
*/
|
||||
async sendPrompt(
|
||||
sessionId: string,
|
||||
content: MessageContent,
|
||||
options?: { internal?: boolean }
|
||||
): Promise<string> {
|
||||
return this.promptHandler.send(sessionId, content, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel an ongoing request
|
||||
*/
|
||||
async cancelRequest(sessionId: string): Promise<void> {
|
||||
return this.promptHandler.cancel(sessionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all processing session IDs (sessions currently handling a request)
|
||||
*/
|
||||
getProcessingSessionIds(): string[] {
|
||||
return Array.from(this.processingSessions)
|
||||
return this.promptHandler.getProcessingSessionIds()
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a session is currently processing a request
|
||||
* Used by G-3 handler to wait for cancel to complete before re-prompting
|
||||
*/
|
||||
isSessionProcessing(sessionId: string): boolean {
|
||||
return this.processingSessions.has(sessionId)
|
||||
return this.promptHandler.isProcessing(sessionId)
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// G-3 Workaround (delegated to G3Workaround)
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Store a user's answer from AskUserQuestion for later injection (G-3 workaround)
|
||||
*/
|
||||
addPendingAnswer(sessionId: string, question: string, answer: string): void {
|
||||
this.g3Workaround.addPendingAnswer(sessionId, question, answer)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse ACP error and return user-friendly message
|
||||
* Store AskUserQuestion response for persistence
|
||||
*/
|
||||
private parseAcpError(error: unknown): string {
|
||||
const errorStr = String(error)
|
||||
|
||||
// MCP server missing environment variables
|
||||
if (errorStr.includes('Missing environment variables')) {
|
||||
const match = errorStr.match(/Missing environment variables: ([A-Z_]+)/)
|
||||
return `MCP server requires environment variable: ${match?.[1] || 'unknown'}`
|
||||
}
|
||||
|
||||
// File too large to read
|
||||
if (errorStr.includes('MaxFileReadTokenExceededError')) {
|
||||
return 'File is too large to read. Try reading smaller portions.'
|
||||
}
|
||||
|
||||
// MCP configuration invalid
|
||||
if (errorStr.includes('mcp-config-invalid')) {
|
||||
return 'MCP server configuration is invalid. Check your settings.'
|
||||
}
|
||||
|
||||
// Generic fallback
|
||||
return 'Agent encountered an error. Please try again.'
|
||||
async storeAskUserQuestionResponse(
|
||||
sessionId: string,
|
||||
toolCallId: string,
|
||||
response: AskUserQuestionResponseData
|
||||
): Promise<void> {
|
||||
return this.g3Workaround.storeResponse(sessionId, toolCallId, response)
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Session ID Lookup
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Find Multica session ID by ACP agent session ID
|
||||
*/
|
||||
getSessionIdByAgentSessionId(agentSessionId: string): string | null {
|
||||
for (const [sessionId, sessionAgent] of this.sessions) {
|
||||
if (sessionAgent.agentSessionId === agentSessionId) {
|
||||
return sessionId
|
||||
}
|
||||
}
|
||||
return null
|
||||
return this.sessionLifecycle.getSessionIdByAgentSessionId(agentSessionId)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -709,65 +270,4 @@ export class Conductor {
|
||||
getMulticaSessionIdByAcp(acpSessionId: string): string | null {
|
||||
return this.getSessionIdByAgentSessionId(acpSessionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a user's answer from AskUserQuestion for later injection (G-3 workaround)
|
||||
* The answer will be prepended to the next prompt as context
|
||||
*/
|
||||
addPendingAnswer(sessionId: string, question: string, answer: string): void {
|
||||
if (!this.pendingAnswers.has(sessionId)) {
|
||||
this.pendingAnswers.set(sessionId, [])
|
||||
}
|
||||
this.pendingAnswers.get(sessionId)!.push({ question, answer })
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pending answers for a session (internal use)
|
||||
*/
|
||||
private getPendingAnswers(sessionId: string): Array<{ question: string; answer: string }> {
|
||||
return this.pendingAnswers.get(sessionId) || []
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear pending answers for a session (internal use)
|
||||
*/
|
||||
private clearPendingAnswers(sessionId: string): void {
|
||||
this.pendingAnswers.delete(sessionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Store AskUserQuestion response for persistence
|
||||
* This allows the completed state to be restored after app restart.
|
||||
* The response is stored as a session update with type 'askuserquestion_response'.
|
||||
*/
|
||||
async storeAskUserQuestionResponse(
|
||||
sessionId: string,
|
||||
toolCallId: string,
|
||||
response: AskUserQuestionResponseData
|
||||
): Promise<void> {
|
||||
if (!this.sessionStore) {
|
||||
console.log('[Conductor] Skipping AskUserQuestion response storage (no session store)')
|
||||
return
|
||||
}
|
||||
|
||||
const update = {
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: 'askuserquestion_response',
|
||||
toolCallId,
|
||||
response
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[Conductor] Storing AskUserQuestion response for toolCallId=${toolCallId}`)
|
||||
await this.sessionStore.appendUpdate(sessionId, update as any)
|
||||
|
||||
// Also notify frontend so it appears in sessionUpdates immediately
|
||||
if (this.events.onSessionUpdate) {
|
||||
this.events.onSessionUpdate({
|
||||
sessionId,
|
||||
update: update.update
|
||||
} as SessionNotification)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
100
src/main/conductor/G3Workaround.ts
Normal file
100
src/main/conductor/G3Workaround.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* G3Workaround - Handles AskUserQuestion answer injection
|
||||
*
|
||||
* The G-3 mechanism handles a limitation in ACP's AskUserQuestion tool:
|
||||
* the tool only returns "User has answered the question(s)" to the agent,
|
||||
* without the actual user selection.
|
||||
*
|
||||
* This module stores user answers so they can be injected into the next
|
||||
* prompt as context, allowing the agent to see the actual user selection.
|
||||
*
|
||||
* Flow:
|
||||
* 1. User selects an option in AskUserQuestion UI
|
||||
* 2. Answer is stored via addPendingAnswer()
|
||||
* 3. Current agent turn is cancelled (G-3 mechanism)
|
||||
* 4. Re-prompt is sent, which injects these answers as context
|
||||
* 5. Agent now sees the actual user selection
|
||||
*/
|
||||
import type { SessionNotification } from '@agentclientprotocol/sdk'
|
||||
import type { AskUserQuestionResponseData } from '../../shared/types'
|
||||
import type { IG3Workaround, G3WorkaroundOptions, PendingAnswer, ISessionStore } from './types'
|
||||
|
||||
export class G3Workaround implements IG3Workaround {
|
||||
private sessionStore: ISessionStore | null
|
||||
private events: G3WorkaroundOptions['events']
|
||||
|
||||
/**
|
||||
* Pending answers from AskUserQuestion tool
|
||||
* Map of sessionId -> array of {question, answer}
|
||||
*/
|
||||
private pendingAnswers: Map<string, PendingAnswer[]> = new Map()
|
||||
|
||||
constructor(options: G3WorkaroundOptions) {
|
||||
this.sessionStore = options.sessionStore
|
||||
this.events = options.events
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a user's answer from AskUserQuestion for later injection
|
||||
* The answer will be prepended to the next prompt as context
|
||||
*/
|
||||
addPendingAnswer(sessionId: string, question: string, answer: string): void {
|
||||
if (!this.pendingAnswers.has(sessionId)) {
|
||||
this.pendingAnswers.set(sessionId, [])
|
||||
}
|
||||
this.pendingAnswers.get(sessionId)!.push({ question, answer })
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pending answers for a session
|
||||
*/
|
||||
getPendingAnswers(sessionId: string): PendingAnswer[] {
|
||||
return this.pendingAnswers.get(sessionId) || []
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear pending answers for a session (after injection)
|
||||
*/
|
||||
clearPendingAnswers(sessionId: string): void {
|
||||
this.pendingAnswers.delete(sessionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Store AskUserQuestion response for persistence
|
||||
* This allows the completed state to be restored after app restart.
|
||||
* The response is stored as a session update with type 'askuserquestion_response'.
|
||||
*/
|
||||
async storeResponse(
|
||||
sessionId: string,
|
||||
toolCallId: string,
|
||||
response: AskUserQuestionResponseData
|
||||
): Promise<void> {
|
||||
if (!this.sessionStore) {
|
||||
console.log('[G3Workaround] Skipping AskUserQuestion response storage (no session store)')
|
||||
return
|
||||
}
|
||||
|
||||
const update = {
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: 'askuserquestion_response',
|
||||
toolCallId,
|
||||
response
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[G3Workaround] Storing AskUserQuestion response for toolCallId=${toolCallId}`)
|
||||
await this.sessionStore.appendUpdate(sessionId, update as any)
|
||||
|
||||
// Also notify frontend so it appears in sessionUpdates immediately
|
||||
if (this.events.onSessionUpdate) {
|
||||
this.events.onSessionUpdate(
|
||||
{
|
||||
sessionId,
|
||||
update: update.update
|
||||
} as SessionNotification,
|
||||
undefined
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
279
src/main/conductor/PromptHandler.ts
Normal file
279
src/main/conductor/PromptHandler.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* PromptHandler - Handles prompt sending and processing
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Converting message content to ACP format
|
||||
* - History replay for resumed sessions
|
||||
* - Injecting G-3 pending answers
|
||||
* - Tracking processing state
|
||||
* - Error handling with user-friendly messages
|
||||
*/
|
||||
import type { SessionNotification } from '@agentclientprotocol/sdk'
|
||||
import type { MessageContent, MessageContentItem } from '../../shared/types/message'
|
||||
import { formatHistoryForReplay, hasReplayableHistory } from './historyReplay'
|
||||
import type {
|
||||
IPromptHandler,
|
||||
PromptHandlerOptions,
|
||||
SessionAgent,
|
||||
ISessionStore,
|
||||
IG3Workaround,
|
||||
IAgentProcessManager,
|
||||
ConductorEvents
|
||||
} from './types'
|
||||
|
||||
/**
|
||||
* Extended options for PromptHandler including the ensureAgent callback
|
||||
*/
|
||||
export interface PromptHandlerFullOptions extends PromptHandlerOptions {
|
||||
/**
|
||||
* Callback to ensure an agent is running for a session.
|
||||
* This is provided by the parent Conductor/SessionLifecycle module.
|
||||
* Returns the SessionAgent for the session.
|
||||
*/
|
||||
ensureAgent: (sessionId: string) => Promise<SessionAgent>
|
||||
}
|
||||
|
||||
export class PromptHandler implements IPromptHandler {
|
||||
private sessionStore: ISessionStore | null
|
||||
private agentProcessManager: IAgentProcessManager
|
||||
private g3Workaround: IG3Workaround
|
||||
private events: ConductorEvents
|
||||
private ensureAgent: (sessionId: string) => Promise<SessionAgent>
|
||||
|
||||
/**
|
||||
* Set of session IDs currently processing a request
|
||||
*/
|
||||
private processingSessions: Set<string> = new Set()
|
||||
|
||||
constructor(options: PromptHandlerFullOptions) {
|
||||
this.sessionStore = options.sessionStore
|
||||
this.agentProcessManager = options.agentProcessManager
|
||||
this.g3Workaround = options.g3Workaround
|
||||
this.events = options.events
|
||||
this.ensureAgent = options.ensureAgent
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a prompt to the agent
|
||||
* @param sessionId - Multica session ID
|
||||
* @param content - Message content (text, images)
|
||||
* @param options.internal - If true, message is not displayed in UI (G-3 mechanism)
|
||||
* @returns Stop reason from the agent
|
||||
*/
|
||||
async send(
|
||||
sessionId: string,
|
||||
content: MessageContent,
|
||||
options?: { internal?: boolean }
|
||||
): Promise<string> {
|
||||
// Mark session as processing immediately (before any await)
|
||||
// This ensures isProcessing() returns true as soon as send() is called
|
||||
this.processingSessions.add(sessionId)
|
||||
this.events.onStatusChange?.()
|
||||
|
||||
try {
|
||||
return await this.sendInternal(sessionId, content, options)
|
||||
} finally {
|
||||
// Always remove from processing when done (success or error)
|
||||
this.processingSessions.delete(sessionId)
|
||||
this.events.onStatusChange?.()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal implementation of send (separated for cleaner processing state management)
|
||||
*/
|
||||
private async sendInternal(
|
||||
sessionId: string,
|
||||
content: MessageContent,
|
||||
options?: { internal?: boolean }
|
||||
): Promise<string> {
|
||||
// Ensure agent is running (lazy start if needed)
|
||||
const sessionAgent = await this.ensureAgent(sessionId)
|
||||
const { connection, agentSessionId } = sessionAgent
|
||||
|
||||
// Convert MessageContent to ACP SDK format
|
||||
const convertToAcpFormat = (
|
||||
items: MessageContent
|
||||
): Array<{ type: string; text?: string; data?: string; mimeType?: string }> => {
|
||||
return items.map((item: MessageContentItem) => {
|
||||
if (item.type === 'text') {
|
||||
return { type: 'text', text: item.text }
|
||||
} else if (item.type === 'image') {
|
||||
return { type: 'image', data: item.data, mimeType: item.mimeType }
|
||||
}
|
||||
return { type: 'text', text: '' } // fallback
|
||||
})
|
||||
}
|
||||
|
||||
// Build prompt content array
|
||||
let promptContent = convertToAcpFormat(content)
|
||||
|
||||
// If this is a resumed session, prepend conversation history to first prompt
|
||||
if (sessionAgent.needsHistoryReplay && this.sessionStore) {
|
||||
try {
|
||||
const data = await this.sessionStore.get(sessionId)
|
||||
if (data && hasReplayableHistory(data.updates)) {
|
||||
const history = formatHistoryForReplay(data.updates)
|
||||
if (history) {
|
||||
console.log(
|
||||
`[PromptHandler] Prepending conversation history (${data.updates.length} updates)`
|
||||
)
|
||||
// Prepend history as text block before other content
|
||||
promptContent = [{ type: 'text', text: history }, ...promptContent]
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[PromptHandler] Failed to load history for replay:`, error)
|
||||
// Continue without history - better than blocking the prompt
|
||||
} finally {
|
||||
// Always mark as replayed to prevent repeated attempts
|
||||
sessionAgent.needsHistoryReplay = false
|
||||
}
|
||||
}
|
||||
|
||||
// Inject pending user answers from AskUserQuestion (G-3 workaround)
|
||||
const pendingAnswers = this.g3Workaround.getPendingAnswers(sessionId)
|
||||
if (pendingAnswers.length > 0) {
|
||||
const answerContext = pendingAnswers
|
||||
.map((a) => `[User's answer to "${a.question}"]: ${a.answer}`)
|
||||
.join('\n')
|
||||
|
||||
console.log(
|
||||
`[PromptHandler] Injecting ${pendingAnswers.length} pending answer(s) for session ${sessionId}`
|
||||
)
|
||||
|
||||
// Prepend answers as context before user's message
|
||||
promptContent = [{ type: 'text', text: `---\n${answerContext}\n---\n` }, ...promptContent]
|
||||
|
||||
// Clear pending answers after injection
|
||||
this.g3Workaround.clearPendingAnswers(sessionId)
|
||||
}
|
||||
|
||||
// Log prompt info
|
||||
const textContent = content.find((c: MessageContentItem) => c.type === 'text')
|
||||
const imageCount = content.filter((c: MessageContentItem) => c.type === 'image').length
|
||||
console.log(`[PromptHandler] Sending prompt to session ${agentSessionId}`)
|
||||
if (textContent && textContent.type === 'text') {
|
||||
console.log(
|
||||
`[PromptHandler] Text: ${textContent.text.slice(0, 100)}${textContent.text.length > 100 ? '...' : ''}`
|
||||
)
|
||||
}
|
||||
if (imageCount > 0) {
|
||||
console.log(`[PromptHandler] Images: ${imageCount}`)
|
||||
}
|
||||
|
||||
// Store user message before sending (so it appears in history)
|
||||
// Internal messages are stored with _internal flag for filtering in UI
|
||||
if (this.sessionStore) {
|
||||
const userUpdate = {
|
||||
sessionId: agentSessionId,
|
||||
update: {
|
||||
sessionUpdate: 'user_message',
|
||||
content: content, // Store full MessageContent array
|
||||
_internal: options?.internal ?? false // G-3: internal messages not shown in UI
|
||||
}
|
||||
}
|
||||
await this.sessionStore.appendUpdate(sessionId, userUpdate as any)
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await connection.prompt({
|
||||
sessionId: agentSessionId,
|
||||
prompt: promptContent as any
|
||||
})
|
||||
|
||||
console.log(`[PromptHandler] Prompt completed with stopReason: ${result.stopReason}`)
|
||||
|
||||
return result.stopReason
|
||||
} catch (error) {
|
||||
console.error(`[PromptHandler] ACP error for session ${sessionId}:`, error)
|
||||
|
||||
// Parse ACP error to user-friendly message
|
||||
const message = this.parseAcpError(error)
|
||||
|
||||
// DESIGN DECISION: Event-driven error handling instead of throwing
|
||||
//
|
||||
// Why not throw?
|
||||
// - Throwing causes IPC handler to fail, frontend receives an error response
|
||||
// - This typically triggers a toast/dialog, interrupting user flow
|
||||
//
|
||||
// Why emit an event?
|
||||
// - Error appears inline in the chat as a message, keeping context visible
|
||||
// - User can read the error and continue the conversation naturally
|
||||
// - Consistent with how agent responses are already delivered (via events)
|
||||
if (this.events.onSessionUpdate) {
|
||||
this.events.onSessionUpdate(
|
||||
{
|
||||
sessionId: agentSessionId,
|
||||
update: {
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: {
|
||||
type: 'text',
|
||||
text: `\n\n**Error:** ${message}\n`
|
||||
}
|
||||
}
|
||||
} as SessionNotification,
|
||||
undefined
|
||||
)
|
||||
}
|
||||
|
||||
// Return 'error' as stopReason - IPC succeeds, caller knows it was an error
|
||||
return 'error'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel an ongoing request
|
||||
*/
|
||||
async cancel(sessionId: string): Promise<void> {
|
||||
const sessionAgent = this.agentProcessManager.get(sessionId)
|
||||
if (!sessionAgent) {
|
||||
return
|
||||
}
|
||||
|
||||
const { connection, agentSessionId } = sessionAgent
|
||||
console.log(`[PromptHandler] Cancelling request for session ${agentSessionId}`)
|
||||
await connection.cancel({ sessionId: agentSessionId })
|
||||
console.log(`[PromptHandler] Cancel request sent`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a session is currently processing
|
||||
*/
|
||||
isProcessing(sessionId: string): boolean {
|
||||
return this.processingSessions.has(sessionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all session IDs currently processing
|
||||
*/
|
||||
getProcessingSessionIds(): string[] {
|
||||
return Array.from(this.processingSessions)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse ACP error and return user-friendly message
|
||||
*/
|
||||
private parseAcpError(error: unknown): string {
|
||||
const errorStr = String(error)
|
||||
|
||||
// MCP server missing environment variables
|
||||
if (errorStr.includes('Missing environment variables')) {
|
||||
const match = errorStr.match(/Missing environment variables: ([A-Z_]+)/)
|
||||
return `MCP server requires environment variable: ${match?.[1] || 'unknown'}`
|
||||
}
|
||||
|
||||
// File too large to read
|
||||
if (errorStr.includes('MaxFileReadTokenExceededError')) {
|
||||
return 'File is too large to read. Try reading smaller portions.'
|
||||
}
|
||||
|
||||
// MCP configuration invalid
|
||||
if (errorStr.includes('mcp-config-invalid')) {
|
||||
return 'MCP server configuration is invalid. Check your settings.'
|
||||
}
|
||||
|
||||
// Generic fallback
|
||||
return 'Agent encountered an error. Please try again.'
|
||||
}
|
||||
}
|
||||
333
src/main/conductor/SessionLifecycle.ts
Normal file
333
src/main/conductor/SessionLifecycle.ts
Normal file
@@ -0,0 +1,333 @@
|
||||
/**
|
||||
* SessionLifecycle - Manages session lifecycle operations
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Creating new sessions
|
||||
* - Loading and resuming sessions
|
||||
* - Deleting sessions
|
||||
* - Switching session agents
|
||||
* - Ensuring agents are running (lazy-start)
|
||||
*/
|
||||
import { DEFAULT_AGENTS } from '../config/defaults'
|
||||
import type {
|
||||
AgentConfig,
|
||||
MulticaSession,
|
||||
SessionData,
|
||||
ListSessionsOptions
|
||||
} from '../../shared/types'
|
||||
import type {
|
||||
ISessionLifecycle,
|
||||
SessionLifecycleOptions,
|
||||
SessionAgent,
|
||||
ISessionStore,
|
||||
IAgentProcessManager,
|
||||
ConductorEvents
|
||||
} from './types'
|
||||
|
||||
export class SessionLifecycle implements ISessionLifecycle {
|
||||
private sessionStore: ISessionStore | null
|
||||
private agentProcessManager: IAgentProcessManager
|
||||
private events: ConductorEvents
|
||||
|
||||
/**
|
||||
* In-memory session for CLI mode (when persistence is skipped)
|
||||
*/
|
||||
private inMemorySession: MulticaSession | null = null
|
||||
|
||||
constructor(options: SessionLifecycleOptions) {
|
||||
this.sessionStore = options.sessionStore
|
||||
this.agentProcessManager = options.agentProcessManager
|
||||
this.events = options.events
|
||||
this.inMemorySession = options.inMemorySession ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the lifecycle manager
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
if (this.sessionStore) {
|
||||
await this.sessionStore.initialize()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new session (agent starts lazily on first prompt)
|
||||
*/
|
||||
async create(cwd: string, agentConfig: AgentConfig): Promise<MulticaSession> {
|
||||
let session: MulticaSession
|
||||
|
||||
if (this.sessionStore) {
|
||||
// Create session record - agent will be started lazily via ensureAgentForSession
|
||||
session = await this.sessionStore.create({
|
||||
agentSessionId: '', // Will be filled by ensureAgentForSession on first prompt
|
||||
agentId: agentConfig.id,
|
||||
workingDirectory: cwd
|
||||
})
|
||||
} else {
|
||||
// CLI mode: in-memory session
|
||||
const { randomUUID } = await import('crypto')
|
||||
const now = new Date().toISOString()
|
||||
session = {
|
||||
id: randomUUID(),
|
||||
agentSessionId: '',
|
||||
agentId: agentConfig.id,
|
||||
workingDirectory: cwd,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
status: 'active',
|
||||
messageCount: 0
|
||||
}
|
||||
this.inMemorySession = session
|
||||
}
|
||||
|
||||
// Agent will be started lazily when user sends first prompt
|
||||
console.log(`[SessionLifecycle] Created session: ${session.id} (agent: pending)`)
|
||||
|
||||
return session
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a session without starting its agent (lazy loading)
|
||||
*/
|
||||
async load(sessionId: string): Promise<MulticaSession> {
|
||||
if (!this.sessionStore) {
|
||||
throw new Error('Session loading not available in CLI mode')
|
||||
}
|
||||
|
||||
const data = await this.sessionStore.get(sessionId)
|
||||
if (!data) {
|
||||
throw new Error(`Session not found: ${sessionId}`)
|
||||
}
|
||||
|
||||
return data.session
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume an existing session (starts a new agent process for it)
|
||||
*/
|
||||
async resume(sessionId: string): Promise<MulticaSession> {
|
||||
if (!this.sessionStore) {
|
||||
throw new Error('Session resumption not available in CLI mode')
|
||||
}
|
||||
|
||||
const data = await this.sessionStore.get(sessionId)
|
||||
if (!data) {
|
||||
throw new Error(`Session not found: ${sessionId}`)
|
||||
}
|
||||
|
||||
// If session already has a running agent, return as-is
|
||||
if (this.agentProcessManager.get(sessionId)) {
|
||||
console.log(`[SessionLifecycle] Session ${sessionId} already has a running agent`)
|
||||
return data.session
|
||||
}
|
||||
|
||||
// Get agent config
|
||||
const agentConfig = DEFAULT_AGENTS[data.session.agentId]
|
||||
if (!agentConfig) {
|
||||
throw new Error(`Unknown agent: ${data.session.agentId}`)
|
||||
}
|
||||
|
||||
// Start a new agent process for this session (isResumed = true)
|
||||
const { agentSessionId } = await this.agentProcessManager.start(
|
||||
sessionId,
|
||||
agentConfig,
|
||||
data.session.workingDirectory,
|
||||
true // Resumed session needs history replay
|
||||
)
|
||||
|
||||
// Update agentSessionId (new ACP session)
|
||||
const updatedSession = await this.sessionStore.updateMeta(sessionId, {
|
||||
agentSessionId,
|
||||
status: 'active'
|
||||
})
|
||||
|
||||
console.log(
|
||||
`[SessionLifecycle] Resumed session: ${sessionId} (new agent session: ${agentSessionId})`
|
||||
)
|
||||
|
||||
return updatedSession
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a session and stop its agent
|
||||
*/
|
||||
async delete(sessionId: string): Promise<void> {
|
||||
// Stop the session's agent process first
|
||||
await this.agentProcessManager.stop(sessionId)
|
||||
|
||||
// Delete from store
|
||||
if (this.sessionStore) {
|
||||
await this.sessionStore.delete(sessionId)
|
||||
}
|
||||
if (this.inMemorySession?.id === sessionId) {
|
||||
this.inMemorySession = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update session metadata
|
||||
*/
|
||||
async updateMeta(sessionId: string, updates: Partial<MulticaSession>): Promise<MulticaSession> {
|
||||
if (!this.sessionStore) {
|
||||
throw new Error('Session update not available in CLI mode')
|
||||
}
|
||||
return this.sessionStore.updateMeta(sessionId, updates)
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch a session's agent (stops current, starts new)
|
||||
*/
|
||||
async switchAgent(sessionId: string, newAgentId: string): Promise<MulticaSession> {
|
||||
if (!this.sessionStore) {
|
||||
throw new Error('Session agent switch not available in CLI mode')
|
||||
}
|
||||
|
||||
const data = await this.sessionStore.get(sessionId)
|
||||
if (!data) {
|
||||
throw new Error(`Session not found: ${sessionId}`)
|
||||
}
|
||||
|
||||
// Get new agent config
|
||||
const newAgentConfig = DEFAULT_AGENTS[newAgentId]
|
||||
if (!newAgentConfig) {
|
||||
throw new Error(`Unknown agent: ${newAgentId}`)
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[SessionLifecycle] Switching session ${sessionId} from ${data.session.agentId} to ${newAgentId}`
|
||||
)
|
||||
|
||||
// Stop current agent if running
|
||||
await this.agentProcessManager.stop(sessionId)
|
||||
|
||||
// Update session's agentId
|
||||
let updatedSession = await this.sessionStore.updateMeta(sessionId, {
|
||||
agentId: newAgentId
|
||||
})
|
||||
|
||||
// Start new agent (isResumed = true to replay history)
|
||||
const { agentSessionId } = await this.agentProcessManager.start(
|
||||
sessionId,
|
||||
newAgentConfig,
|
||||
data.session.workingDirectory,
|
||||
true
|
||||
)
|
||||
|
||||
// Update agentSessionId
|
||||
updatedSession = await this.sessionStore.updateMeta(sessionId, {
|
||||
agentSessionId,
|
||||
status: 'active'
|
||||
})
|
||||
|
||||
// Notify frontend
|
||||
if (this.events.onSessionMetaUpdated) {
|
||||
this.events.onSessionMetaUpdated(updatedSession)
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[SessionLifecycle] Session ${sessionId} switched to ${newAgentId} (agent session: ${agentSessionId})`
|
||||
)
|
||||
|
||||
return updatedSession
|
||||
}
|
||||
|
||||
/**
|
||||
* List sessions with optional filtering
|
||||
*/
|
||||
async list(options?: ListSessionsOptions): Promise<MulticaSession[]> {
|
||||
if (!this.sessionStore) {
|
||||
return this.inMemorySession ? [this.inMemorySession] : []
|
||||
}
|
||||
return this.sessionStore.list(options)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get complete session data
|
||||
*/
|
||||
async getData(sessionId: string): Promise<SessionData | null> {
|
||||
if (!this.sessionStore) {
|
||||
return null
|
||||
}
|
||||
return this.sessionStore.get(sessionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Find Multica session ID by agent session ID
|
||||
*/
|
||||
getSessionIdByAgentSessionId(agentSessionId: string): string | null {
|
||||
// First check in-memory sessions (running agents)
|
||||
for (const sessionId of this.agentProcessManager.getRunningSessionIds()) {
|
||||
const sessionAgent = this.agentProcessManager.get(sessionId)
|
||||
if (sessionAgent?.agentSessionId === agentSessionId) {
|
||||
return sessionId
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to session store lookup
|
||||
if (this.sessionStore) {
|
||||
const session = this.sessionStore.getByAgentSessionId(agentSessionId)
|
||||
return session?.id ?? null
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure an agent is running for a session (start if needed)
|
||||
* This is used by PromptHandler to lazy-start agents on first prompt.
|
||||
*/
|
||||
async ensureAgentForSession(sessionId: string): Promise<SessionAgent> {
|
||||
// If already running, return existing
|
||||
const existing = this.agentProcessManager.get(sessionId)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
|
||||
// Load session data
|
||||
if (!this.sessionStore) {
|
||||
throw new Error('Cannot auto-start agent in CLI mode')
|
||||
}
|
||||
|
||||
const data = await this.sessionStore.get(sessionId)
|
||||
if (!data) {
|
||||
throw new Error(`Session not found: ${sessionId}`)
|
||||
}
|
||||
|
||||
// Get agent config
|
||||
const agentConfig = DEFAULT_AGENTS[data.session.agentId]
|
||||
if (!agentConfig) {
|
||||
throw new Error(`Unknown agent: ${data.session.agentId}`)
|
||||
}
|
||||
|
||||
console.log(`[SessionLifecycle] Lazy-starting agent for session ${sessionId}`)
|
||||
|
||||
// Start agent (isResumed = true for lazy start of existing session)
|
||||
const { agentSessionId } = await this.agentProcessManager.start(
|
||||
sessionId,
|
||||
agentConfig,
|
||||
data.session.workingDirectory,
|
||||
true // Existing session needs history replay
|
||||
)
|
||||
|
||||
// Update session with new agentSessionId
|
||||
const updatedSession = await this.sessionStore.updateMeta(sessionId, {
|
||||
agentSessionId,
|
||||
status: 'active'
|
||||
})
|
||||
|
||||
// Notify frontend of session metadata change (important for agentSessionId update)
|
||||
// This must happen BEFORE sending the prompt so frontend can receive messages
|
||||
if (this.events.onSessionMetaUpdated) {
|
||||
this.events.onSessionMetaUpdated(updatedSession)
|
||||
}
|
||||
|
||||
return this.agentProcessManager.get(sessionId)!
|
||||
}
|
||||
|
||||
/**
|
||||
* Get in-memory session (for CLI mode)
|
||||
*/
|
||||
getInMemorySession(): MulticaSession | null {
|
||||
return this.inMemorySession
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,26 @@
|
||||
// Main facade
|
||||
export { Conductor } from './Conductor'
|
||||
export type { SessionUpdateCallback, ConductorEvents, ConductorOptions } from './Conductor'
|
||||
|
||||
// Modules (for direct use if needed)
|
||||
export { AgentProcessManager } from './AgentProcessManager'
|
||||
export { PromptHandler } from './PromptHandler'
|
||||
export { G3Workaround } from './G3Workaround'
|
||||
export { SessionLifecycle } from './SessionLifecycle'
|
||||
|
||||
// Utilities
|
||||
export { AgentProcess } from './AgentProcess'
|
||||
export { createAcpClient } from './AcpClientFactory'
|
||||
export type { SessionUpdateCallback, ConductorEvents } from './Conductor'
|
||||
export type { AcpClientCallbacks, AcpClientFactoryOptions } from './AcpClientFactory'
|
||||
|
||||
// Types
|
||||
export type {
|
||||
SessionAgent,
|
||||
PendingAnswer,
|
||||
AgentStartResult,
|
||||
ISessionStore,
|
||||
IAgentProcessManager,
|
||||
IPromptHandler,
|
||||
IG3Workaround,
|
||||
ISessionLifecycle
|
||||
} from './types'
|
||||
|
||||
296
src/main/conductor/types.ts
Normal file
296
src/main/conductor/types.ts
Normal file
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* Type definitions for Conductor modules
|
||||
*
|
||||
* This file defines interfaces for the modular Conductor architecture,
|
||||
* enabling dependency injection and easier testing.
|
||||
*/
|
||||
import type {
|
||||
ClientSideConnection,
|
||||
SessionNotification,
|
||||
RequestPermissionRequest,
|
||||
RequestPermissionResponse
|
||||
} from '@agentclientprotocol/sdk'
|
||||
import type { AgentProcess } from './AgentProcess'
|
||||
import type {
|
||||
AgentConfig,
|
||||
MulticaSession,
|
||||
SessionData,
|
||||
StoredSessionUpdate,
|
||||
ListSessionsOptions,
|
||||
CreateSessionParams,
|
||||
AskUserQuestionResponseData
|
||||
} from '../../shared/types'
|
||||
import type { MessageContent } from '../../shared/types/message'
|
||||
|
||||
// =============================================================================
|
||||
// Core Types
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Per-session agent state (internal to conductor modules)
|
||||
*/
|
||||
export interface SessionAgent {
|
||||
agentProcess: AgentProcess
|
||||
connection: ClientSideConnection
|
||||
agentConfig: AgentConfig
|
||||
agentSessionId: string
|
||||
/** Whether the first prompt needs history prepended (for resumed sessions) */
|
||||
needsHistoryReplay: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Pending answer for G-3 mechanism (AskUserQuestion workaround)
|
||||
*/
|
||||
export interface PendingAnswer {
|
||||
question: string
|
||||
answer: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of starting an agent for a session
|
||||
*/
|
||||
export interface AgentStartResult {
|
||||
connection: ClientSideConnection
|
||||
agentSessionId: string
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Event Types
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Session update callback signature
|
||||
*/
|
||||
export interface SessionUpdateCallback {
|
||||
(update: SessionNotification, sequenceNumber?: number): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Events emitted by Conductor and its modules
|
||||
*/
|
||||
export interface ConductorEvents {
|
||||
/** Called when a session update is received from the agent */
|
||||
onSessionUpdate?: SessionUpdateCallback
|
||||
/** Called when a permission request is received from the agent */
|
||||
onPermissionRequest?: (params: RequestPermissionRequest) => Promise<RequestPermissionResponse>
|
||||
/** Called when processing state changes (for UI status updates) */
|
||||
onStatusChange?: () => void
|
||||
/** Called when session metadata changes (e.g., agentSessionId after lazy start) */
|
||||
onSessionMetaUpdated?: (session: MulticaSession) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for Conductor initialization
|
||||
*/
|
||||
export interface ConductorOptions {
|
||||
events?: ConductorEvents
|
||||
/** Skip session persistence (for CLI mode) */
|
||||
skipPersistence?: boolean
|
||||
/** Custom storage path for sessions */
|
||||
storagePath?: string
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Module Interfaces
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Interface for session storage operations
|
||||
* Abstracts the persistence layer for dependency injection
|
||||
*/
|
||||
export interface ISessionStore {
|
||||
/** Initialize the storage (create directories, load index) */
|
||||
initialize(): Promise<void>
|
||||
|
||||
/** Create a new session */
|
||||
create(params: CreateSessionParams): Promise<MulticaSession>
|
||||
|
||||
/** Get complete session data including updates */
|
||||
get(sessionId: string): Promise<SessionData | null>
|
||||
|
||||
/** List sessions with optional filtering */
|
||||
list(options?: ListSessionsOptions): Promise<MulticaSession[]>
|
||||
|
||||
/** Update session metadata */
|
||||
updateMeta(sessionId: string, updates: Partial<MulticaSession>): Promise<MulticaSession>
|
||||
|
||||
/** Delete a session */
|
||||
delete(sessionId: string): Promise<void>
|
||||
|
||||
/** Append an update to a session */
|
||||
appendUpdate(sessionId: string, update: SessionNotification): Promise<StoredSessionUpdate>
|
||||
|
||||
/** Find session by agent session ID (synchronous, in-memory lookup) */
|
||||
getByAgentSessionId(agentSessionId: string): MulticaSession | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for agent process management
|
||||
*/
|
||||
export interface IAgentProcessManager {
|
||||
/**
|
||||
* Start an agent process for a session
|
||||
* @param sessionId - Multica session ID
|
||||
* @param config - Agent configuration
|
||||
* @param cwd - Working directory
|
||||
* @param isResumed - Whether this is a resumed session (needs history replay)
|
||||
*/
|
||||
start(
|
||||
sessionId: string,
|
||||
config: AgentConfig,
|
||||
cwd: string,
|
||||
isResumed?: boolean
|
||||
): Promise<AgentStartResult>
|
||||
|
||||
/** Stop a session's agent process */
|
||||
stop(sessionId: string): Promise<void>
|
||||
|
||||
/** Stop all running agent processes */
|
||||
stopAll(): Promise<void>
|
||||
|
||||
/** Get session agent state by session ID */
|
||||
get(sessionId: string): SessionAgent | undefined
|
||||
|
||||
/** Set session agent state (used by other modules) */
|
||||
set(sessionId: string, sessionAgent: SessionAgent): void
|
||||
|
||||
/** Remove session agent state */
|
||||
remove(sessionId: string): void
|
||||
|
||||
/** Check if a session has a running agent */
|
||||
isRunning(sessionId: string): boolean
|
||||
|
||||
/** Get all running session IDs */
|
||||
getRunningSessionIds(): string[]
|
||||
|
||||
/** Get agent config for a session */
|
||||
getAgentConfig(sessionId: string): AgentConfig | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for prompt handling
|
||||
*/
|
||||
export interface IPromptHandler {
|
||||
/**
|
||||
* Send a prompt to the agent
|
||||
* @param sessionId - Multica session ID
|
||||
* @param content - Message content (text, images)
|
||||
* @param options.internal - If true, message is not displayed in UI (G-3 mechanism)
|
||||
* @returns Stop reason from the agent
|
||||
*/
|
||||
send(
|
||||
sessionId: string,
|
||||
content: MessageContent,
|
||||
options?: { internal?: boolean }
|
||||
): Promise<string>
|
||||
|
||||
/** Cancel an ongoing request */
|
||||
cancel(sessionId: string): Promise<void>
|
||||
|
||||
/** Check if a session is currently processing */
|
||||
isProcessing(sessionId: string): boolean
|
||||
|
||||
/** Get all session IDs currently processing */
|
||||
getProcessingSessionIds(): string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for G-3 workaround (AskUserQuestion answer injection)
|
||||
*
|
||||
* The G-3 mechanism handles a limitation in ACP's AskUserQuestion tool:
|
||||
* the tool only returns "User has answered" without the actual selection.
|
||||
* This module stores user answers and injects them into the next prompt.
|
||||
*/
|
||||
export interface IG3Workaround {
|
||||
/** Store a user's answer for later injection */
|
||||
addPendingAnswer(sessionId: string, question: string, answer: string): void
|
||||
|
||||
/** Get all pending answers for a session */
|
||||
getPendingAnswers(sessionId: string): PendingAnswer[]
|
||||
|
||||
/** Clear pending answers for a session (after injection) */
|
||||
clearPendingAnswers(sessionId: string): void
|
||||
|
||||
/** Store AskUserQuestion response for persistence (allows state restore after restart) */
|
||||
storeResponse(
|
||||
sessionId: string,
|
||||
toolCallId: string,
|
||||
response: AskUserQuestionResponseData
|
||||
): Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for session lifecycle management
|
||||
*/
|
||||
export interface ISessionLifecycle {
|
||||
/** Initialize the lifecycle manager */
|
||||
initialize(): Promise<void>
|
||||
|
||||
/** Create a new session (agent starts lazily on first prompt) */
|
||||
create(cwd: string, agentConfig: AgentConfig): Promise<MulticaSession>
|
||||
|
||||
/** Load a session without starting its agent (lazy loading) */
|
||||
load(sessionId: string): Promise<MulticaSession>
|
||||
|
||||
/** Resume an existing session (starts a new agent process) */
|
||||
resume(sessionId: string): Promise<MulticaSession>
|
||||
|
||||
/** Delete a session and stop its agent */
|
||||
delete(sessionId: string): Promise<void>
|
||||
|
||||
/** Update session metadata */
|
||||
updateMeta(sessionId: string, updates: Partial<MulticaSession>): Promise<MulticaSession>
|
||||
|
||||
/** Switch a session's agent (stops current, starts new) */
|
||||
switchAgent(sessionId: string, newAgentId: string): Promise<MulticaSession>
|
||||
|
||||
/** List sessions with optional filtering */
|
||||
list(options?: ListSessionsOptions): Promise<MulticaSession[]>
|
||||
|
||||
/** Get complete session data */
|
||||
getData(sessionId: string): Promise<SessionData | null>
|
||||
|
||||
/** Find Multica session ID by agent session ID */
|
||||
getSessionIdByAgentSessionId(agentSessionId: string): string | null
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Module Options
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Options for AgentProcessManager
|
||||
*/
|
||||
export interface AgentProcessManagerOptions {
|
||||
sessionStore: ISessionStore | null
|
||||
events: ConductorEvents
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for PromptHandler
|
||||
*/
|
||||
export interface PromptHandlerOptions {
|
||||
sessionStore: ISessionStore | null
|
||||
agentProcessManager: IAgentProcessManager
|
||||
g3Workaround: IG3Workaround
|
||||
events: ConductorEvents
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for G3Workaround
|
||||
*/
|
||||
export interface G3WorkaroundOptions {
|
||||
sessionStore: ISessionStore | null
|
||||
events: ConductorEvents
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for SessionLifecycle
|
||||
*/
|
||||
export interface SessionLifecycleOptions {
|
||||
sessionStore: ISessionStore | null
|
||||
agentProcessManager: IAgentProcessManager
|
||||
events: ConductorEvents
|
||||
/** In-memory session for CLI mode */
|
||||
inMemorySession?: MulticaSession | null
|
||||
}
|
||||
301
tests/unit/main/conductor/AgentProcessManager.test.ts
Normal file
301
tests/unit/main/conductor/AgentProcessManager.test.ts
Normal file
@@ -0,0 +1,301 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
// Store exit callbacks and error states for testing
|
||||
const testState = {
|
||||
exitCallbacks: new Map<string, (code: number | null, signal: string | null) => void>(),
|
||||
instanceCounter: 0,
|
||||
mockStartError: null as Error | null,
|
||||
mockInitializeError: null as Error | null,
|
||||
mockNewSessionError: null as Error | null
|
||||
}
|
||||
|
||||
// Mock AgentProcess to avoid spawning real processes
|
||||
vi.mock('../../../../src/main/conductor/AgentProcess', () => {
|
||||
return {
|
||||
AgentProcess: class MockAgentProcess {
|
||||
private instanceId: string
|
||||
|
||||
constructor() {
|
||||
this.instanceId = `instance-${testState.instanceCounter++}`
|
||||
}
|
||||
|
||||
start = vi.fn().mockImplementation(() => {
|
||||
if (testState.mockStartError) {
|
||||
return Promise.reject(testState.mockStartError)
|
||||
}
|
||||
return Promise.resolve(undefined)
|
||||
})
|
||||
|
||||
stop = vi.fn().mockResolvedValue(undefined)
|
||||
isRunning = vi.fn().mockReturnValue(true)
|
||||
|
||||
onExit = vi.fn((cb: (code: number | null, signal: string | null) => void) => {
|
||||
testState.exitCallbacks.set(this.instanceId, cb)
|
||||
})
|
||||
|
||||
getPid = vi.fn().mockReturnValue(12345)
|
||||
getStdinWeb = vi.fn().mockReturnValue(new WritableStream())
|
||||
getStdoutWeb = vi.fn().mockReturnValue(new ReadableStream())
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Mock ACP SDK
|
||||
vi.mock('@agentclientprotocol/sdk', () => {
|
||||
return {
|
||||
PROTOCOL_VERSION: '1.0',
|
||||
ndJsonStream: vi.fn().mockReturnValue({}),
|
||||
ClientSideConnection: class MockClientSideConnection {
|
||||
initialize = vi.fn().mockImplementation(() => {
|
||||
if (testState.mockInitializeError) {
|
||||
return Promise.reject(testState.mockInitializeError)
|
||||
}
|
||||
return Promise.resolve({
|
||||
protocolVersion: '1.0',
|
||||
agentInfo: { name: 'mock-agent' }
|
||||
})
|
||||
})
|
||||
|
||||
newSession = vi.fn().mockImplementation(() => {
|
||||
if (testState.mockNewSessionError) {
|
||||
return Promise.reject(testState.mockNewSessionError)
|
||||
}
|
||||
return Promise.resolve({
|
||||
sessionId: 'mock-acp-session-id'
|
||||
})
|
||||
})
|
||||
|
||||
prompt = vi.fn().mockResolvedValue({
|
||||
stopReason: 'end_turn'
|
||||
})
|
||||
|
||||
cancel = vi.fn().mockResolvedValue(undefined)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Import after mocks are set up
|
||||
import { AgentProcessManager } from '../../../../src/main/conductor/AgentProcessManager'
|
||||
import type { ISessionStore, ConductorEvents } from '../../../../src/main/conductor/types'
|
||||
import type { AgentConfig } from '../../../../src/shared/types'
|
||||
|
||||
describe('AgentProcessManager', () => {
|
||||
let manager: AgentProcessManager
|
||||
let mockSessionStore: ISessionStore
|
||||
let mockEvents: ConductorEvents
|
||||
|
||||
const mockAgentConfig: AgentConfig = {
|
||||
id: 'opencode',
|
||||
name: 'OpenCode',
|
||||
command: 'opencode',
|
||||
args: ['--mcp'],
|
||||
enabled: true
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset all test state
|
||||
testState.exitCallbacks.clear()
|
||||
testState.instanceCounter = 0
|
||||
testState.mockStartError = null
|
||||
testState.mockInitializeError = null
|
||||
testState.mockNewSessionError = null
|
||||
|
||||
mockSessionStore = {
|
||||
initialize: vi.fn(),
|
||||
create: vi.fn(),
|
||||
get: vi.fn(),
|
||||
list: vi.fn(),
|
||||
updateMeta: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
appendUpdate: vi.fn().mockResolvedValue({ timestamp: new Date().toISOString() }),
|
||||
getByAgentSessionId: vi.fn()
|
||||
}
|
||||
|
||||
mockEvents = {
|
||||
onSessionUpdate: vi.fn(),
|
||||
onStatusChange: vi.fn()
|
||||
}
|
||||
|
||||
manager = new AgentProcessManager({
|
||||
sessionStore: mockSessionStore,
|
||||
events: mockEvents
|
||||
})
|
||||
})
|
||||
|
||||
describe('start', () => {
|
||||
it('should start agent process and return connection info', async () => {
|
||||
const result = await manager.start('session-1', mockAgentConfig, '/test/project')
|
||||
|
||||
expect(result.agentSessionId).toBe('mock-acp-session-id')
|
||||
expect(result.connection).toBeDefined()
|
||||
})
|
||||
|
||||
it('should store session in sessions map', async () => {
|
||||
await manager.start('session-1', mockAgentConfig, '/test/project')
|
||||
|
||||
expect(manager.get('session-1')).toBeDefined()
|
||||
expect(manager.get('session-1')?.agentConfig).toBe(mockAgentConfig)
|
||||
})
|
||||
|
||||
it('should set needsHistoryReplay for resumed sessions', async () => {
|
||||
await manager.start('session-1', mockAgentConfig, '/test/project', true)
|
||||
|
||||
const session = manager.get('session-1')
|
||||
expect(session?.needsHistoryReplay).toBe(true)
|
||||
})
|
||||
|
||||
it('should not set needsHistoryReplay for new sessions', async () => {
|
||||
await manager.start('session-1', mockAgentConfig, '/test/project', false)
|
||||
|
||||
const session = manager.get('session-1')
|
||||
expect(session?.needsHistoryReplay).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('stop', () => {
|
||||
it('should stop agent process and remove from map', async () => {
|
||||
await manager.start('session-1', mockAgentConfig, '/test/project')
|
||||
expect(manager.get('session-1')).toBeDefined()
|
||||
|
||||
await manager.stop('session-1')
|
||||
expect(manager.get('session-1')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should handle stopping non-existent session gracefully', async () => {
|
||||
await expect(manager.stop('non-existent')).resolves.not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('stopAll', () => {
|
||||
it('should stop all running agent processes', async () => {
|
||||
await manager.start('session-1', mockAgentConfig, '/test/project')
|
||||
await manager.start('session-2', mockAgentConfig, '/test/project2')
|
||||
expect(manager.getRunningSessionIds()).toHaveLength(2)
|
||||
|
||||
await manager.stopAll()
|
||||
expect(manager.getRunningSessionIds()).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('state queries', () => {
|
||||
it('should return running session IDs', async () => {
|
||||
await manager.start('session-1', mockAgentConfig, '/test/project')
|
||||
await manager.start('session-2', mockAgentConfig, '/test/project2')
|
||||
|
||||
const runningIds = manager.getRunningSessionIds()
|
||||
expect(runningIds).toContain('session-1')
|
||||
expect(runningIds).toContain('session-2')
|
||||
})
|
||||
|
||||
it('should check if session is running', async () => {
|
||||
await manager.start('session-1', mockAgentConfig, '/test/project')
|
||||
|
||||
expect(manager.isRunning('session-1')).toBe(true)
|
||||
expect(manager.isRunning('non-existent')).toBe(false)
|
||||
})
|
||||
|
||||
it('should return agent config for session', async () => {
|
||||
await manager.start('session-1', mockAgentConfig, '/test/project')
|
||||
|
||||
expect(manager.getAgentConfig('session-1')).toBe(mockAgentConfig)
|
||||
expect(manager.getAgentConfig('non-existent')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('set and remove', () => {
|
||||
it('should allow setting session agent directly', async () => {
|
||||
const mockSessionAgent = {
|
||||
agentProcess: {} as any,
|
||||
connection: {} as any,
|
||||
agentConfig: mockAgentConfig,
|
||||
agentSessionId: 'acp-123',
|
||||
needsHistoryReplay: false
|
||||
}
|
||||
|
||||
manager.set('session-1', mockSessionAgent)
|
||||
expect(manager.get('session-1')).toBe(mockSessionAgent)
|
||||
})
|
||||
|
||||
it('should allow removing session agent directly', async () => {
|
||||
await manager.start('session-1', mockAgentConfig, '/test/project')
|
||||
expect(manager.get('session-1')).toBeDefined()
|
||||
|
||||
manager.remove('session-1')
|
||||
expect(manager.get('session-1')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('onExit callback', () => {
|
||||
it('should clean up session when agent process exits', async () => {
|
||||
await manager.start('session-1', mockAgentConfig, '/test/project')
|
||||
expect(manager.get('session-1')).toBeDefined()
|
||||
|
||||
// Get the exit callback that was registered
|
||||
const exitCallback = testState.exitCallbacks.get('instance-0')
|
||||
expect(exitCallback).toBeDefined()
|
||||
|
||||
// Simulate agent process exit with code 0
|
||||
exitCallback!(0, null)
|
||||
|
||||
// Session should be cleaned up
|
||||
expect(manager.get('session-1')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should clean up session on signal exit', async () => {
|
||||
await manager.start('session-1', mockAgentConfig, '/test/project')
|
||||
expect(manager.get('session-1')).toBeDefined()
|
||||
|
||||
// Simulate signal exit (e.g., SIGTERM)
|
||||
const exitCallback = testState.exitCallbacks.get('instance-0')
|
||||
exitCallback!(null, 'SIGTERM')
|
||||
|
||||
expect(manager.get('session-1')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should clean up session on non-zero exit code', async () => {
|
||||
await manager.start('session-1', mockAgentConfig, '/test/project')
|
||||
expect(manager.get('session-1')).toBeDefined()
|
||||
|
||||
// Simulate crash with exit code 1
|
||||
const exitCallback = testState.exitCallbacks.get('instance-0')
|
||||
exitCallback!(1, null)
|
||||
|
||||
expect(manager.get('session-1')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should propagate error when agent process fails to start', async () => {
|
||||
testState.mockStartError = new Error('Failed to spawn process')
|
||||
|
||||
await expect(manager.start('session-1', mockAgentConfig, '/test/project')).rejects.toThrow(
|
||||
'Failed to spawn process'
|
||||
)
|
||||
|
||||
// Session should not be in the map
|
||||
expect(manager.get('session-1')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should propagate error when ACP initialization fails', async () => {
|
||||
testState.mockInitializeError = new Error('Protocol version mismatch')
|
||||
|
||||
await expect(manager.start('session-1', mockAgentConfig, '/test/project')).rejects.toThrow(
|
||||
'Protocol version mismatch'
|
||||
)
|
||||
|
||||
// Session should not be in the map
|
||||
expect(manager.get('session-1')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should propagate error when ACP session creation fails', async () => {
|
||||
testState.mockNewSessionError = new Error('Session limit exceeded')
|
||||
|
||||
await expect(manager.start('session-1', mockAgentConfig, '/test/project')).rejects.toThrow(
|
||||
'Session limit exceeded'
|
||||
)
|
||||
|
||||
// Session should not be in the map
|
||||
expect(manager.get('session-1')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
147
tests/unit/main/conductor/G3Workaround.test.ts
Normal file
147
tests/unit/main/conductor/G3Workaround.test.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { G3Workaround } from '../../../../src/main/conductor/G3Workaround'
|
||||
import type { ISessionStore, ConductorEvents } from '../../../../src/main/conductor/types'
|
||||
|
||||
describe('G3Workaround', () => {
|
||||
let g3: G3Workaround
|
||||
let mockSessionStore: ISessionStore
|
||||
let mockEvents: ConductorEvents
|
||||
|
||||
beforeEach(() => {
|
||||
mockSessionStore = {
|
||||
initialize: vi.fn(),
|
||||
create: vi.fn(),
|
||||
get: vi.fn(),
|
||||
list: vi.fn(),
|
||||
updateMeta: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
appendUpdate: vi.fn().mockResolvedValue({ timestamp: new Date().toISOString() }),
|
||||
getByAgentSessionId: vi.fn()
|
||||
}
|
||||
|
||||
mockEvents = {
|
||||
onSessionUpdate: vi.fn(),
|
||||
onStatusChange: vi.fn()
|
||||
}
|
||||
|
||||
g3 = new G3Workaround({
|
||||
sessionStore: mockSessionStore,
|
||||
events: mockEvents
|
||||
})
|
||||
})
|
||||
|
||||
describe('pendingAnswers', () => {
|
||||
it('should add pending answer for session', () => {
|
||||
g3.addPendingAnswer('session-1', 'What color?', 'Blue')
|
||||
|
||||
const answers = g3.getPendingAnswers('session-1')
|
||||
expect(answers).toHaveLength(1)
|
||||
expect(answers[0]).toEqual({ question: 'What color?', answer: 'Blue' })
|
||||
})
|
||||
|
||||
it('should accumulate multiple answers for same session', () => {
|
||||
g3.addPendingAnswer('session-1', 'Question 1?', 'Answer 1')
|
||||
g3.addPendingAnswer('session-1', 'Question 2?', 'Answer 2')
|
||||
|
||||
const answers = g3.getPendingAnswers('session-1')
|
||||
expect(answers).toHaveLength(2)
|
||||
expect(answers[0]).toEqual({ question: 'Question 1?', answer: 'Answer 1' })
|
||||
expect(answers[1]).toEqual({ question: 'Question 2?', answer: 'Answer 2' })
|
||||
})
|
||||
|
||||
it('should return empty array for unknown session', () => {
|
||||
const answers = g3.getPendingAnswers('unknown-session')
|
||||
expect(answers).toEqual([])
|
||||
})
|
||||
|
||||
it('should keep answers separate per session', () => {
|
||||
g3.addPendingAnswer('session-1', 'Q1?', 'A1')
|
||||
g3.addPendingAnswer('session-2', 'Q2?', 'A2')
|
||||
|
||||
expect(g3.getPendingAnswers('session-1')).toHaveLength(1)
|
||||
expect(g3.getPendingAnswers('session-2')).toHaveLength(1)
|
||||
expect(g3.getPendingAnswers('session-1')[0].answer).toBe('A1')
|
||||
expect(g3.getPendingAnswers('session-2')[0].answer).toBe('A2')
|
||||
})
|
||||
|
||||
it('should clear pending answers for session', () => {
|
||||
g3.addPendingAnswer('session-1', 'Q?', 'A')
|
||||
expect(g3.getPendingAnswers('session-1')).toHaveLength(1)
|
||||
|
||||
g3.clearPendingAnswers('session-1')
|
||||
expect(g3.getPendingAnswers('session-1')).toEqual([])
|
||||
})
|
||||
|
||||
it('should not affect other sessions when clearing', () => {
|
||||
g3.addPendingAnswer('session-1', 'Q1?', 'A1')
|
||||
g3.addPendingAnswer('session-2', 'Q2?', 'A2')
|
||||
|
||||
g3.clearPendingAnswers('session-1')
|
||||
|
||||
expect(g3.getPendingAnswers('session-1')).toEqual([])
|
||||
expect(g3.getPendingAnswers('session-2')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('storeResponse', () => {
|
||||
it('should persist response when sessionStore is available', async () => {
|
||||
const response = {
|
||||
selectedOption: 'Option A',
|
||||
answers: [{ question: 'Q?', answer: 'A' }]
|
||||
}
|
||||
|
||||
await g3.storeResponse('session-1', 'tool-call-123', response)
|
||||
|
||||
expect(mockSessionStore.appendUpdate).toHaveBeenCalledWith('session-1', {
|
||||
sessionId: 'session-1',
|
||||
update: {
|
||||
sessionUpdate: 'askuserquestion_response',
|
||||
toolCallId: 'tool-call-123',
|
||||
response
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('should emit onSessionUpdate event after storing', async () => {
|
||||
const response = { selectedOption: 'Option B' }
|
||||
|
||||
await g3.storeResponse('session-1', 'tool-call-456', response)
|
||||
|
||||
expect(mockEvents.onSessionUpdate).toHaveBeenCalledWith(
|
||||
{
|
||||
sessionId: 'session-1',
|
||||
update: {
|
||||
sessionUpdate: 'askuserquestion_response',
|
||||
toolCallId: 'tool-call-456',
|
||||
response
|
||||
}
|
||||
},
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
it('should skip persistence when sessionStore is null', async () => {
|
||||
const g3NoStore = new G3Workaround({
|
||||
sessionStore: null,
|
||||
events: mockEvents
|
||||
})
|
||||
|
||||
await g3NoStore.storeResponse('session-1', 'tool-call', { selectedOption: 'A' })
|
||||
|
||||
// Should not throw and should not call appendUpdate
|
||||
expect(mockSessionStore.appendUpdate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not emit event when sessionStore is null', async () => {
|
||||
const g3NoStore = new G3Workaround({
|
||||
sessionStore: null,
|
||||
events: mockEvents
|
||||
})
|
||||
|
||||
await g3NoStore.storeResponse('session-1', 'tool-call', { selectedOption: 'A' })
|
||||
|
||||
// Should not emit since we can't persist
|
||||
expect(mockEvents.onSessionUpdate).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
424
tests/unit/main/conductor/PromptHandler.test.ts
Normal file
424
tests/unit/main/conductor/PromptHandler.test.ts
Normal file
@@ -0,0 +1,424 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { PromptHandler } from '../../../../src/main/conductor/PromptHandler'
|
||||
import type {
|
||||
ISessionStore,
|
||||
IAgentProcessManager,
|
||||
IG3Workaround,
|
||||
ConductorEvents,
|
||||
SessionAgent
|
||||
} from '../../../../src/main/conductor/types'
|
||||
import type { AgentConfig } from '../../../../src/shared/types'
|
||||
|
||||
describe('PromptHandler', () => {
|
||||
let handler: PromptHandler
|
||||
let mockSessionStore: ISessionStore
|
||||
let mockAgentProcessManager: IAgentProcessManager
|
||||
let mockG3Workaround: IG3Workaround
|
||||
let mockEvents: ConductorEvents
|
||||
let mockEnsureAgent: ReturnType<typeof vi.fn>
|
||||
let mockSessionAgent: SessionAgent
|
||||
|
||||
const mockAgentConfig: AgentConfig = {
|
||||
id: 'opencode',
|
||||
name: 'OpenCode',
|
||||
command: 'opencode',
|
||||
args: ['--mcp'],
|
||||
enabled: true
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// Create mock connection with prompt method
|
||||
const mockConnection = {
|
||||
prompt: vi.fn().mockResolvedValue({ stopReason: 'end_turn' }),
|
||||
cancel: vi.fn().mockResolvedValue(undefined)
|
||||
}
|
||||
|
||||
mockSessionAgent = {
|
||||
agentProcess: {} as any,
|
||||
connection: mockConnection as any,
|
||||
agentConfig: mockAgentConfig,
|
||||
agentSessionId: 'acp-session-123',
|
||||
needsHistoryReplay: false
|
||||
}
|
||||
|
||||
mockSessionStore = {
|
||||
initialize: vi.fn(),
|
||||
create: vi.fn(),
|
||||
get: vi.fn().mockResolvedValue({ session: {}, updates: [] }),
|
||||
list: vi.fn(),
|
||||
updateMeta: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
appendUpdate: vi.fn().mockResolvedValue({ timestamp: new Date().toISOString() }),
|
||||
getByAgentSessionId: vi.fn()
|
||||
}
|
||||
|
||||
mockAgentProcessManager = {
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
stopAll: vi.fn(),
|
||||
get: vi.fn().mockReturnValue(mockSessionAgent),
|
||||
set: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
isRunning: vi.fn().mockReturnValue(true),
|
||||
getRunningSessionIds: vi.fn().mockReturnValue(['session-1']),
|
||||
getAgentConfig: vi.fn().mockReturnValue(mockAgentConfig)
|
||||
}
|
||||
|
||||
mockG3Workaround = {
|
||||
addPendingAnswer: vi.fn(),
|
||||
getPendingAnswers: vi.fn().mockReturnValue([]),
|
||||
clearPendingAnswers: vi.fn(),
|
||||
storeResponse: vi.fn()
|
||||
}
|
||||
|
||||
mockEvents = {
|
||||
onSessionUpdate: vi.fn(),
|
||||
onStatusChange: vi.fn()
|
||||
}
|
||||
|
||||
mockEnsureAgent = vi.fn().mockResolvedValue(mockSessionAgent)
|
||||
|
||||
handler = new PromptHandler({
|
||||
sessionStore: mockSessionStore,
|
||||
agentProcessManager: mockAgentProcessManager,
|
||||
g3Workaround: mockG3Workaround,
|
||||
events: mockEvents,
|
||||
ensureAgent: mockEnsureAgent
|
||||
})
|
||||
})
|
||||
|
||||
describe('send', () => {
|
||||
it('should send prompt and return stop reason', async () => {
|
||||
const result = await handler.send('session-1', [{ type: 'text', text: 'Hello' }])
|
||||
|
||||
expect(result).toBe('end_turn')
|
||||
expect(mockEnsureAgent).toHaveBeenCalledWith('session-1')
|
||||
})
|
||||
|
||||
it('should convert MessageContent to ACP format', async () => {
|
||||
await handler.send('session-1', [
|
||||
{ type: 'text', text: 'Hello' },
|
||||
{ type: 'image', data: 'base64data', mimeType: 'image/png' }
|
||||
])
|
||||
|
||||
const connection = mockSessionAgent.connection as any
|
||||
expect(connection.prompt).toHaveBeenCalledWith({
|
||||
sessionId: 'acp-session-123',
|
||||
prompt: [
|
||||
{ type: 'text', text: 'Hello' },
|
||||
{ type: 'image', data: 'base64data', mimeType: 'image/png' }
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
it('should store user message before sending', async () => {
|
||||
await handler.send('session-1', [{ type: 'text', text: 'Hello' }])
|
||||
|
||||
expect(mockSessionStore.appendUpdate).toHaveBeenCalledWith('session-1', {
|
||||
sessionId: 'acp-session-123',
|
||||
update: {
|
||||
sessionUpdate: 'user_message',
|
||||
content: [{ type: 'text', text: 'Hello' }],
|
||||
_internal: false
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('should mark internal messages with _internal flag', async () => {
|
||||
await handler.send('session-1', [{ type: 'text', text: 'Hello' }], { internal: true })
|
||||
|
||||
expect(mockSessionStore.appendUpdate).toHaveBeenCalledWith('session-1', {
|
||||
sessionId: 'acp-session-123',
|
||||
update: {
|
||||
sessionUpdate: 'user_message',
|
||||
content: [{ type: 'text', text: 'Hello' }],
|
||||
_internal: true
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('should update processing state', async () => {
|
||||
expect(handler.isProcessing('session-1')).toBe(false)
|
||||
|
||||
const sendPromise = handler.send('session-1', [{ type: 'text', text: 'Hello' }])
|
||||
|
||||
// During send, should be processing
|
||||
expect(handler.isProcessing('session-1')).toBe(true)
|
||||
expect(mockEvents.onStatusChange).toHaveBeenCalled()
|
||||
|
||||
await sendPromise
|
||||
|
||||
// After send, should not be processing
|
||||
expect(handler.isProcessing('session-1')).toBe(false)
|
||||
})
|
||||
|
||||
it('should inject pending G-3 answers', async () => {
|
||||
mockG3Workaround.getPendingAnswers = vi.fn().mockReturnValue([
|
||||
{ question: 'What color?', answer: 'Blue' },
|
||||
{ question: 'What size?', answer: 'Large' }
|
||||
])
|
||||
|
||||
await handler.send('session-1', [{ type: 'text', text: 'Hello' }])
|
||||
|
||||
const connection = mockSessionAgent.connection as any
|
||||
const callArgs = connection.prompt.mock.calls[0][0]
|
||||
|
||||
// First element should be the injected answers
|
||||
expect(callArgs.prompt[0].type).toBe('text')
|
||||
expect(callArgs.prompt[0].text).toContain('[User\'s answer to "What color?"]: Blue')
|
||||
expect(callArgs.prompt[0].text).toContain('[User\'s answer to "What size?"]: Large')
|
||||
|
||||
// Pending answers should be cleared
|
||||
expect(mockG3Workaround.clearPendingAnswers).toHaveBeenCalledWith('session-1')
|
||||
})
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
const mockConnection = mockSessionAgent.connection as any
|
||||
mockConnection.prompt.mockRejectedValue(new Error('Test error'))
|
||||
|
||||
const result = await handler.send('session-1', [{ type: 'text', text: 'Hello' }])
|
||||
|
||||
expect(result).toBe('error')
|
||||
expect(mockEvents.onSessionUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sessionId: 'acp-session-123',
|
||||
update: expect.objectContaining({
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: expect.objectContaining({
|
||||
type: 'text',
|
||||
text: expect.stringContaining('Error:')
|
||||
})
|
||||
})
|
||||
}),
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
it('should clear processing state on error', async () => {
|
||||
const mockConnection = mockSessionAgent.connection as any
|
||||
mockConnection.prompt.mockRejectedValue(new Error('Test error'))
|
||||
|
||||
await handler.send('session-1', [{ type: 'text', text: 'Hello' }])
|
||||
|
||||
expect(handler.isProcessing('session-1')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cancel', () => {
|
||||
it('should cancel ongoing request', async () => {
|
||||
await handler.cancel('session-1')
|
||||
|
||||
const connection = mockSessionAgent.connection as any
|
||||
expect(connection.cancel).toHaveBeenCalledWith({ sessionId: 'acp-session-123' })
|
||||
})
|
||||
|
||||
it('should handle non-existent session gracefully', async () => {
|
||||
mockAgentProcessManager.get = vi.fn().mockReturnValue(undefined)
|
||||
|
||||
await expect(handler.cancel('non-existent')).resolves.not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('processing state', () => {
|
||||
it('should return processing session IDs', async () => {
|
||||
const sendPromise = handler.send('session-1', [{ type: 'text', text: 'Hello' }])
|
||||
|
||||
expect(handler.getProcessingSessionIds()).toContain('session-1')
|
||||
|
||||
await sendPromise
|
||||
})
|
||||
|
||||
it('should track multiple processing sessions', async () => {
|
||||
// Start two prompts
|
||||
const mockSessionAgent2 = {
|
||||
...mockSessionAgent,
|
||||
agentSessionId: 'acp-session-456'
|
||||
}
|
||||
mockEnsureAgent
|
||||
.mockResolvedValueOnce(mockSessionAgent)
|
||||
.mockResolvedValueOnce(mockSessionAgent2)
|
||||
|
||||
const promise1 = handler.send('session-1', [{ type: 'text', text: 'Hello 1' }])
|
||||
const promise2 = handler.send('session-2', [{ type: 'text', text: 'Hello 2' }])
|
||||
|
||||
expect(handler.getProcessingSessionIds()).toContain('session-1')
|
||||
expect(handler.getProcessingSessionIds()).toContain('session-2')
|
||||
|
||||
await Promise.all([promise1, promise2])
|
||||
})
|
||||
})
|
||||
|
||||
describe('history replay', () => {
|
||||
it('should prepend history for resumed sessions', async () => {
|
||||
const resumedSessionAgent = {
|
||||
...mockSessionAgent,
|
||||
needsHistoryReplay: true
|
||||
}
|
||||
mockEnsureAgent.mockResolvedValue(resumedSessionAgent)
|
||||
|
||||
mockSessionStore.get = vi.fn().mockResolvedValue({
|
||||
session: {},
|
||||
updates: [
|
||||
{
|
||||
timestamp: new Date().toISOString(),
|
||||
update: {
|
||||
update: {
|
||||
sessionUpdate: 'user_message',
|
||||
content: [{ type: 'text', text: 'Previous message' }]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
timestamp: new Date().toISOString(),
|
||||
update: {
|
||||
update: {
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text: 'Previous response' }
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
await handler.send('session-1', [{ type: 'text', text: 'New message' }])
|
||||
|
||||
const connection = mockSessionAgent.connection as any
|
||||
const callArgs = connection.prompt.mock.calls[0][0]
|
||||
|
||||
// First element should be the history
|
||||
expect(callArgs.prompt[0].type).toBe('text')
|
||||
expect(callArgs.prompt[0].text).toContain('[Session History')
|
||||
|
||||
// Should mark as replayed
|
||||
expect(resumedSessionAgent.needsHistoryReplay).toBe(false)
|
||||
})
|
||||
|
||||
it('should skip history replay if no meaningful history', async () => {
|
||||
const resumedSessionAgent = {
|
||||
...mockSessionAgent,
|
||||
needsHistoryReplay: true
|
||||
}
|
||||
mockEnsureAgent.mockResolvedValue(resumedSessionAgent)
|
||||
|
||||
mockSessionStore.get = vi.fn().mockResolvedValue({
|
||||
session: {},
|
||||
updates: [] // Empty history
|
||||
})
|
||||
|
||||
await handler.send('session-1', [{ type: 'text', text: 'New message' }])
|
||||
|
||||
const connection = mockSessionAgent.connection as any
|
||||
const callArgs = connection.prompt.mock.calls[0][0]
|
||||
|
||||
// Should just have the user message
|
||||
expect(callArgs.prompt).toHaveLength(1)
|
||||
expect(callArgs.prompt[0].text).toBe('New message')
|
||||
})
|
||||
|
||||
it('should continue without history when sessionStore.get throws', async () => {
|
||||
const resumedSessionAgent = {
|
||||
...mockSessionAgent,
|
||||
needsHistoryReplay: true
|
||||
}
|
||||
mockEnsureAgent.mockResolvedValue(resumedSessionAgent)
|
||||
|
||||
// Simulate sessionStore.get throwing an error
|
||||
mockSessionStore.get = vi.fn().mockRejectedValue(new Error('Database connection failed'))
|
||||
|
||||
const result = await handler.send('session-1', [{ type: 'text', text: 'New message' }])
|
||||
|
||||
// Should still send the prompt successfully
|
||||
expect(result).toBe('end_turn')
|
||||
|
||||
// Should mark as replayed even on error (to prevent repeated attempts)
|
||||
expect(resumedSessionAgent.needsHistoryReplay).toBe(false)
|
||||
|
||||
const connection = mockSessionAgent.connection as any
|
||||
const callArgs = connection.prompt.mock.calls[0][0]
|
||||
|
||||
// Should just have the user message (no history)
|
||||
expect(callArgs.prompt).toHaveLength(1)
|
||||
expect(callArgs.prompt[0].text).toBe('New message')
|
||||
})
|
||||
})
|
||||
|
||||
describe('error parsing', () => {
|
||||
it('should parse MCP missing environment variable error', async () => {
|
||||
const mockConnection = mockSessionAgent.connection as any
|
||||
mockConnection.prompt.mockRejectedValue(
|
||||
new Error('Missing environment variables: OPENAI_API_KEY')
|
||||
)
|
||||
|
||||
await handler.send('session-1', [{ type: 'text', text: 'Hello' }])
|
||||
|
||||
expect(mockEvents.onSessionUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
update: expect.objectContaining({
|
||||
content: expect.objectContaining({
|
||||
text: expect.stringContaining(
|
||||
'MCP server requires environment variable: OPENAI_API_KEY'
|
||||
)
|
||||
})
|
||||
})
|
||||
}),
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
it('should parse MaxFileReadTokenExceededError', async () => {
|
||||
const mockConnection = mockSessionAgent.connection as any
|
||||
mockConnection.prompt.mockRejectedValue(
|
||||
new Error('MaxFileReadTokenExceededError: file too large')
|
||||
)
|
||||
|
||||
await handler.send('session-1', [{ type: 'text', text: 'Hello' }])
|
||||
|
||||
expect(mockEvents.onSessionUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
update: expect.objectContaining({
|
||||
content: expect.objectContaining({
|
||||
text: expect.stringContaining('File is too large to read')
|
||||
})
|
||||
})
|
||||
}),
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
it('should parse mcp-config-invalid error', async () => {
|
||||
const mockConnection = mockSessionAgent.connection as any
|
||||
mockConnection.prompt.mockRejectedValue(new Error('mcp-config-invalid: server name missing'))
|
||||
|
||||
await handler.send('session-1', [{ type: 'text', text: 'Hello' }])
|
||||
|
||||
expect(mockEvents.onSessionUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
update: expect.objectContaining({
|
||||
content: expect.objectContaining({
|
||||
text: expect.stringContaining('MCP server configuration is invalid')
|
||||
})
|
||||
})
|
||||
}),
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
it('should use generic fallback for unknown errors', async () => {
|
||||
const mockConnection = mockSessionAgent.connection as any
|
||||
mockConnection.prompt.mockRejectedValue(new Error('Some completely unknown error'))
|
||||
|
||||
await handler.send('session-1', [{ type: 'text', text: 'Hello' }])
|
||||
|
||||
expect(mockEvents.onSessionUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
update: expect.objectContaining({
|
||||
content: expect.objectContaining({
|
||||
text: expect.stringContaining('Agent encountered an error')
|
||||
})
|
||||
})
|
||||
}),
|
||||
undefined
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
441
tests/unit/main/conductor/SessionLifecycle.test.ts
Normal file
441
tests/unit/main/conductor/SessionLifecycle.test.ts
Normal file
@@ -0,0 +1,441 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { SessionLifecycle } from '../../../../src/main/conductor/SessionLifecycle'
|
||||
import type {
|
||||
ISessionStore,
|
||||
IAgentProcessManager,
|
||||
ConductorEvents,
|
||||
SessionAgent
|
||||
} from '../../../../src/main/conductor/types'
|
||||
import type { AgentConfig, MulticaSession, SessionData } from '../../../../src/shared/types'
|
||||
|
||||
describe('SessionLifecycle', () => {
|
||||
let lifecycle: SessionLifecycle
|
||||
let mockSessionStore: ISessionStore
|
||||
let mockAgentProcessManager: IAgentProcessManager
|
||||
let mockEvents: ConductorEvents
|
||||
|
||||
const mockAgentConfig: AgentConfig = {
|
||||
id: 'opencode',
|
||||
name: 'OpenCode',
|
||||
command: 'opencode',
|
||||
args: ['--mcp'],
|
||||
enabled: true
|
||||
}
|
||||
|
||||
const mockSession: MulticaSession = {
|
||||
id: 'session-123',
|
||||
agentSessionId: 'acp-123',
|
||||
agentId: 'opencode',
|
||||
workingDirectory: '/test/project',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-01T00:00:00Z',
|
||||
status: 'active',
|
||||
messageCount: 0
|
||||
}
|
||||
|
||||
const mockSessionAgent: SessionAgent = {
|
||||
agentProcess: {} as any,
|
||||
connection: {} as any,
|
||||
agentConfig: mockAgentConfig,
|
||||
agentSessionId: 'acp-123',
|
||||
needsHistoryReplay: false
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockSessionStore = {
|
||||
initialize: vi.fn().mockResolvedValue(undefined),
|
||||
create: vi.fn().mockResolvedValue(mockSession),
|
||||
get: vi.fn().mockResolvedValue({ session: mockSession, updates: [] } as SessionData),
|
||||
list: vi.fn().mockResolvedValue([mockSession]),
|
||||
updateMeta: vi.fn().mockResolvedValue(mockSession),
|
||||
delete: vi.fn().mockResolvedValue(undefined),
|
||||
appendUpdate: vi.fn().mockResolvedValue({ timestamp: new Date().toISOString() }),
|
||||
getByAgentSessionId: vi.fn().mockReturnValue(mockSession)
|
||||
}
|
||||
|
||||
mockAgentProcessManager = {
|
||||
start: vi.fn().mockResolvedValue({ connection: {}, agentSessionId: 'acp-new-123' }),
|
||||
stop: vi.fn().mockResolvedValue(undefined),
|
||||
stopAll: vi.fn().mockResolvedValue(undefined),
|
||||
get: vi.fn().mockReturnValue(undefined), // Default: no running agent
|
||||
set: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
isRunning: vi.fn().mockReturnValue(false),
|
||||
getRunningSessionIds: vi.fn().mockReturnValue([]),
|
||||
getAgentConfig: vi.fn().mockReturnValue(mockAgentConfig)
|
||||
}
|
||||
|
||||
mockEvents = {
|
||||
onSessionUpdate: vi.fn(),
|
||||
onStatusChange: vi.fn(),
|
||||
onSessionMetaUpdated: vi.fn()
|
||||
}
|
||||
|
||||
lifecycle = new SessionLifecycle({
|
||||
sessionStore: mockSessionStore,
|
||||
agentProcessManager: mockAgentProcessManager,
|
||||
events: mockEvents
|
||||
})
|
||||
})
|
||||
|
||||
describe('initialize', () => {
|
||||
it('should initialize session store', async () => {
|
||||
await lifecycle.initialize()
|
||||
expect(mockSessionStore.initialize).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle missing session store (CLI mode)', async () => {
|
||||
const cliLifecycle = new SessionLifecycle({
|
||||
sessionStore: null,
|
||||
agentProcessManager: mockAgentProcessManager,
|
||||
events: mockEvents
|
||||
})
|
||||
|
||||
await expect(cliLifecycle.initialize()).resolves.not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('create', () => {
|
||||
it('should create session with persistence', async () => {
|
||||
const session = await lifecycle.create('/test/project', mockAgentConfig)
|
||||
|
||||
expect(mockSessionStore.create).toHaveBeenCalledWith({
|
||||
agentSessionId: '',
|
||||
agentId: 'opencode',
|
||||
workingDirectory: '/test/project'
|
||||
})
|
||||
expect(session).toEqual(mockSession)
|
||||
})
|
||||
|
||||
it('should create in-memory session in CLI mode', async () => {
|
||||
const cliLifecycle = new SessionLifecycle({
|
||||
sessionStore: null,
|
||||
agentProcessManager: mockAgentProcessManager,
|
||||
events: mockEvents
|
||||
})
|
||||
|
||||
const session = await cliLifecycle.create('/test/project', mockAgentConfig)
|
||||
|
||||
expect(session.agentId).toBe('opencode')
|
||||
expect(session.workingDirectory).toBe('/test/project')
|
||||
expect(session.status).toBe('active')
|
||||
expect(cliLifecycle.getInMemorySession()).toBe(session)
|
||||
})
|
||||
})
|
||||
|
||||
describe('load', () => {
|
||||
it('should load session without starting agent', async () => {
|
||||
const session = await lifecycle.load('session-123')
|
||||
|
||||
expect(session).toEqual(mockSession)
|
||||
expect(mockAgentProcessManager.start).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should throw for non-existent session', async () => {
|
||||
mockSessionStore.get = vi.fn().mockResolvedValue(null)
|
||||
|
||||
await expect(lifecycle.load('non-existent')).rejects.toThrow('Session not found')
|
||||
})
|
||||
|
||||
it('should throw in CLI mode', async () => {
|
||||
const cliLifecycle = new SessionLifecycle({
|
||||
sessionStore: null,
|
||||
agentProcessManager: mockAgentProcessManager,
|
||||
events: mockEvents
|
||||
})
|
||||
|
||||
await expect(cliLifecycle.load('session-123')).rejects.toThrow('not available in CLI mode')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resume', () => {
|
||||
it('should start agent for existing session', async () => {
|
||||
const session = await lifecycle.resume('session-123')
|
||||
|
||||
expect(mockAgentProcessManager.start).toHaveBeenCalledWith(
|
||||
'session-123',
|
||||
expect.objectContaining({ id: 'opencode' }),
|
||||
'/test/project',
|
||||
true // isResumed
|
||||
)
|
||||
expect(mockSessionStore.updateMeta).toHaveBeenCalledWith('session-123', {
|
||||
agentSessionId: 'acp-new-123',
|
||||
status: 'active'
|
||||
})
|
||||
})
|
||||
|
||||
it('should return existing session if agent already running', async () => {
|
||||
mockAgentProcessManager.get = vi.fn().mockReturnValue(mockSessionAgent)
|
||||
|
||||
const session = await lifecycle.resume('session-123')
|
||||
|
||||
expect(mockAgentProcessManager.start).not.toHaveBeenCalled()
|
||||
expect(session).toEqual(mockSession)
|
||||
})
|
||||
|
||||
it('should throw for unknown agent', async () => {
|
||||
mockSessionStore.get = vi.fn().mockResolvedValue({
|
||||
session: { ...mockSession, agentId: 'unknown-agent' },
|
||||
updates: []
|
||||
})
|
||||
|
||||
await expect(lifecycle.resume('session-123')).rejects.toThrow('Unknown agent')
|
||||
})
|
||||
})
|
||||
|
||||
describe('delete', () => {
|
||||
it('should stop agent and delete from store', async () => {
|
||||
await lifecycle.delete('session-123')
|
||||
|
||||
expect(mockAgentProcessManager.stop).toHaveBeenCalledWith('session-123')
|
||||
expect(mockSessionStore.delete).toHaveBeenCalledWith('session-123')
|
||||
})
|
||||
|
||||
it('should clear in-memory session in CLI mode', async () => {
|
||||
const cliLifecycle = new SessionLifecycle({
|
||||
sessionStore: null,
|
||||
agentProcessManager: mockAgentProcessManager,
|
||||
events: mockEvents
|
||||
})
|
||||
|
||||
await cliLifecycle.create('/test/project', mockAgentConfig)
|
||||
const inMemory = cliLifecycle.getInMemorySession()
|
||||
expect(inMemory).not.toBeNull()
|
||||
|
||||
await cliLifecycle.delete(inMemory!.id)
|
||||
expect(cliLifecycle.getInMemorySession()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateMeta', () => {
|
||||
it('should update session metadata', async () => {
|
||||
await lifecycle.updateMeta('session-123', { title: 'New Title' })
|
||||
|
||||
expect(mockSessionStore.updateMeta).toHaveBeenCalledWith('session-123', {
|
||||
title: 'New Title'
|
||||
})
|
||||
})
|
||||
|
||||
it('should throw in CLI mode', async () => {
|
||||
const cliLifecycle = new SessionLifecycle({
|
||||
sessionStore: null,
|
||||
agentProcessManager: mockAgentProcessManager,
|
||||
events: mockEvents
|
||||
})
|
||||
|
||||
await expect(cliLifecycle.updateMeta('session-123', {})).rejects.toThrow(
|
||||
'not available in CLI mode'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('switchAgent', () => {
|
||||
it('should stop current agent and start new one', async () => {
|
||||
await lifecycle.switchAgent('session-123', 'claude-code')
|
||||
|
||||
expect(mockAgentProcessManager.stop).toHaveBeenCalledWith('session-123')
|
||||
expect(mockSessionStore.updateMeta).toHaveBeenCalledWith('session-123', {
|
||||
agentId: 'claude-code'
|
||||
})
|
||||
expect(mockAgentProcessManager.start).toHaveBeenCalledWith(
|
||||
'session-123',
|
||||
expect.objectContaining({ id: 'claude-code' }),
|
||||
'/test/project',
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('should notify frontend of metadata change', async () => {
|
||||
await lifecycle.switchAgent('session-123', 'claude-code')
|
||||
|
||||
expect(mockEvents.onSessionMetaUpdated).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should throw for unknown new agent', async () => {
|
||||
await expect(lifecycle.switchAgent('session-123', 'unknown-agent')).rejects.toThrow(
|
||||
'Unknown agent'
|
||||
)
|
||||
})
|
||||
|
||||
it('should throw when session not found', async () => {
|
||||
mockSessionStore.get = vi.fn().mockResolvedValue(null)
|
||||
|
||||
await expect(lifecycle.switchAgent('non-existent', 'claude-code')).rejects.toThrow(
|
||||
'Session not found'
|
||||
)
|
||||
})
|
||||
|
||||
it('should throw in CLI mode', async () => {
|
||||
const cliLifecycle = new SessionLifecycle({
|
||||
sessionStore: null,
|
||||
agentProcessManager: mockAgentProcessManager,
|
||||
events: mockEvents
|
||||
})
|
||||
|
||||
await expect(cliLifecycle.switchAgent('session-123', 'claude-code')).rejects.toThrow(
|
||||
'not available in CLI mode'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('list', () => {
|
||||
it('should list sessions from store', async () => {
|
||||
const sessions = await lifecycle.list()
|
||||
|
||||
expect(sessions).toEqual([mockSession])
|
||||
expect(mockSessionStore.list).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return in-memory session in CLI mode', async () => {
|
||||
const cliLifecycle = new SessionLifecycle({
|
||||
sessionStore: null,
|
||||
agentProcessManager: mockAgentProcessManager,
|
||||
events: mockEvents
|
||||
})
|
||||
|
||||
await cliLifecycle.create('/test/project', mockAgentConfig)
|
||||
const sessions = await cliLifecycle.list()
|
||||
|
||||
expect(sessions).toHaveLength(1)
|
||||
expect(sessions[0].workingDirectory).toBe('/test/project')
|
||||
})
|
||||
|
||||
it('should return empty array in CLI mode with no session', async () => {
|
||||
const cliLifecycle = new SessionLifecycle({
|
||||
sessionStore: null,
|
||||
agentProcessManager: mockAgentProcessManager,
|
||||
events: mockEvents
|
||||
})
|
||||
|
||||
const sessions = await cliLifecycle.list()
|
||||
expect(sessions).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('getData', () => {
|
||||
it('should return complete session data', async () => {
|
||||
const data = await lifecycle.getData('session-123')
|
||||
|
||||
expect(data).toEqual({ session: mockSession, updates: [] })
|
||||
})
|
||||
|
||||
it('should return null in CLI mode', async () => {
|
||||
const cliLifecycle = new SessionLifecycle({
|
||||
sessionStore: null,
|
||||
agentProcessManager: mockAgentProcessManager,
|
||||
events: mockEvents
|
||||
})
|
||||
|
||||
const data = await cliLifecycle.getData('session-123')
|
||||
expect(data).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSessionIdByAgentSessionId', () => {
|
||||
it('should find session ID from running agents', () => {
|
||||
mockAgentProcessManager.getRunningSessionIds = vi.fn().mockReturnValue(['session-123'])
|
||||
mockAgentProcessManager.get = vi.fn().mockReturnValue(mockSessionAgent)
|
||||
|
||||
const sessionId = lifecycle.getSessionIdByAgentSessionId('acp-123')
|
||||
expect(sessionId).toBe('session-123')
|
||||
})
|
||||
|
||||
it('should fallback to session store', () => {
|
||||
mockAgentProcessManager.getRunningSessionIds = vi.fn().mockReturnValue([])
|
||||
|
||||
const sessionId = lifecycle.getSessionIdByAgentSessionId('acp-123')
|
||||
expect(sessionId).toBe('session-123')
|
||||
expect(mockSessionStore.getByAgentSessionId).toHaveBeenCalledWith('acp-123')
|
||||
})
|
||||
|
||||
it('should return null if not found', () => {
|
||||
mockAgentProcessManager.getRunningSessionIds = vi.fn().mockReturnValue([])
|
||||
mockSessionStore.getByAgentSessionId = vi.fn().mockReturnValue(null)
|
||||
|
||||
const sessionId = lifecycle.getSessionIdByAgentSessionId('unknown')
|
||||
expect(sessionId).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ensureAgentForSession', () => {
|
||||
it('should return existing agent if already running', async () => {
|
||||
mockAgentProcessManager.get = vi.fn().mockReturnValue(mockSessionAgent)
|
||||
|
||||
const result = await lifecycle.ensureAgentForSession('session-123')
|
||||
|
||||
expect(result).toBe(mockSessionAgent)
|
||||
expect(mockAgentProcessManager.start).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should start agent if not running', async () => {
|
||||
mockAgentProcessManager.get = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(undefined) // First call: not running
|
||||
.mockReturnValueOnce(mockSessionAgent) // Second call: after start
|
||||
|
||||
const result = await lifecycle.ensureAgentForSession('session-123')
|
||||
|
||||
expect(mockAgentProcessManager.start).toHaveBeenCalledWith(
|
||||
'session-123',
|
||||
expect.objectContaining({ id: 'opencode' }),
|
||||
'/test/project',
|
||||
true
|
||||
)
|
||||
expect(mockEvents.onSessionMetaUpdated).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should throw in CLI mode', async () => {
|
||||
const cliLifecycle = new SessionLifecycle({
|
||||
sessionStore: null,
|
||||
agentProcessManager: mockAgentProcessManager,
|
||||
events: mockEvents
|
||||
})
|
||||
|
||||
await expect(cliLifecycle.ensureAgentForSession('session-123')).rejects.toThrow(
|
||||
'Cannot auto-start agent in CLI mode'
|
||||
)
|
||||
})
|
||||
|
||||
it('should throw when session not found', async () => {
|
||||
mockAgentProcessManager.get = vi.fn().mockReturnValue(undefined)
|
||||
mockSessionStore.get = vi.fn().mockResolvedValue(null)
|
||||
|
||||
await expect(lifecycle.ensureAgentForSession('non-existent')).rejects.toThrow(
|
||||
'Session not found'
|
||||
)
|
||||
})
|
||||
|
||||
it('should throw when agent config is unknown', async () => {
|
||||
mockAgentProcessManager.get = vi.fn().mockReturnValue(undefined)
|
||||
mockSessionStore.get = vi.fn().mockResolvedValue({
|
||||
session: { ...mockSession, agentId: 'unknown-agent' },
|
||||
updates: []
|
||||
})
|
||||
|
||||
await expect(lifecycle.ensureAgentForSession('session-123')).rejects.toThrow('Unknown agent')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getInMemorySession', () => {
|
||||
it('should return null when no in-memory session exists', () => {
|
||||
expect(lifecycle.getInMemorySession()).toBeNull()
|
||||
})
|
||||
|
||||
it('should return in-memory session after creation in CLI mode', async () => {
|
||||
const cliLifecycle = new SessionLifecycle({
|
||||
sessionStore: null,
|
||||
agentProcessManager: mockAgentProcessManager,
|
||||
events: mockEvents
|
||||
})
|
||||
|
||||
expect(cliLifecycle.getInMemorySession()).toBeNull()
|
||||
|
||||
await cliLifecycle.create('/test/project', mockAgentConfig)
|
||||
|
||||
const inMemory = cliLifecycle.getInMemorySession()
|
||||
expect(inMemory).not.toBeNull()
|
||||
expect(inMemory?.workingDirectory).toBe('/test/project')
|
||||
expect(inMemory?.agentId).toBe('opencode')
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user