Merge pull request #63 from multica-ai/forrestchang/arch-review

refactor(conductor): split monolithic class into focused modules
This commit is contained in:
Jiayuan
2026-01-19 13:16:18 +08:00
committed by GitHub
11 changed files with 2672 additions and 649 deletions

View 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
}
}

View File

@@ -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)
}
}
}

View 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
)
}
}
}

View 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.'
}
}

View 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
}
}

View File

@@ -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
View 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
}

View 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()
})
})
})

View 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()
})
})
})

View 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
)
})
})
})

View 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')
})
})
})