feat: multi-turn conversations and Claude fallback for Gemini provider

Major improvements to Gemini provider:

**Shared Conversation History**
- Add ConversationMessage interface for provider-agnostic history
- Both Claude and Gemini agents read/write shared conversationHistory
- Context persists across provider switches via claudeSessionId linkage

**Multi-Turn Gemini API**
- Replace stateless single-query with full conversation context
- queryGeminiMultiTurn() sends entire history for coherent responses
- Maps 'assistant' role to 'model' for Gemini API compatibility

**Automatic Fallback to Claude**
- Detect rate limits (429), server errors (5xx), network failures
- Fall back to Claude SDK when Gemini API fails
- Reset 'processing' messages to 'pending' before fallback

**Mid-Session Provider Switching**
- Track currentProvider on ActiveSession
- Provider changes take effect after current generator finishes
- Avoids race conditions from aborting active generators

Files changed:
- worker-types.ts: Add ConversationMessage, currentProvider tracking
- GeminiAgent.ts: Multi-turn queries, fallback logic
- SDKAgent.ts: Capture messages to shared history
- SessionManager.ts: Initialize new session fields
- SessionRoutes.ts: Provider selection and switching logic
- worker-service.ts: Wire up fallback agent dependency

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
bigphoot
2025-12-23 15:21:03 -08:00
parent ec8dd08c32
commit f837a9eb77
17 changed files with 407 additions and 214 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
+1
View File
@@ -85,6 +85,7 @@ export class WorkerService {
this.sseBroadcaster = new SSEBroadcaster(); this.sseBroadcaster = new SSEBroadcaster();
this.sdkAgent = new SDKAgent(this.dbManager, this.sessionManager); this.sdkAgent = new SDKAgent(this.dbManager, this.sessionManager);
this.geminiAgent = new GeminiAgent(this.dbManager, this.sessionManager); this.geminiAgent = new GeminiAgent(this.dbManager, this.sessionManager);
this.geminiAgent.setFallbackAgent(this.sdkAgent); // Enable fallback to Claude on Gemini API failure
this.paginationHelper = new PaginationHelper(this.dbManager); this.paginationHelper = new PaginationHelper(this.dbManager);
this.settingsManager = new SettingsManager(this.dbManager); this.settingsManager = new SettingsManager(this.dbManager);
this.sessionEventBroadcaster = new SessionEventBroadcaster(this.sseBroadcaster, this); this.sessionEventBroadcaster = new SessionEventBroadcaster(this.sseBroadcaster, this);
+11
View File
@@ -8,6 +8,15 @@ import type { Response } from 'express';
// Active Session Types // Active Session Types
// ============================================================================ // ============================================================================
/**
* Provider-agnostic conversation message for shared history
* Used to maintain context across ClaudeGemini provider switches
*/
export interface ConversationMessage {
role: 'user' | 'assistant';
content: string;
}
export interface ActiveSession { export interface ActiveSession {
sessionDbId: number; sessionDbId: number;
claudeSessionId: string; claudeSessionId: string;
@@ -22,6 +31,8 @@ export interface ActiveSession {
cumulativeInputTokens: number; // Track input tokens for discovery cost cumulativeInputTokens: number; // Track input tokens for discovery cost
cumulativeOutputTokens: number; // Track output tokens for discovery cost cumulativeOutputTokens: number; // Track output tokens for discovery cost
pendingProcessingIds: Set<number>; // Track ALL message IDs yielded but not yet processed pendingProcessingIds: Set<number>; // Track ALL message IDs yielded but not yet processed
conversationHistory: ConversationMessage[]; // Shared conversation history for provider switching
currentProvider: 'claude' | 'gemini' | null; // Track which provider is currently running
} }
export interface PendingMessage { export interface PendingMessage {
+112 -20
View File
@@ -19,7 +19,7 @@ import { parseObservations, parseSummary } from '../../sdk/parser.js';
import { buildInitPrompt, buildObservationPrompt, buildSummaryPrompt, buildContinuationPrompt } from '../../sdk/prompts.js'; import { buildInitPrompt, buildObservationPrompt, buildSummaryPrompt, buildContinuationPrompt } from '../../sdk/prompts.js';
import { SettingsDefaultsManager } from '../../shared/SettingsDefaultsManager.js'; import { SettingsDefaultsManager } from '../../shared/SettingsDefaultsManager.js';
import { USER_SETTINGS_PATH } from '../../shared/paths.js'; import { USER_SETTINGS_PATH } from '../../shared/paths.js';
import type { ActiveSession, PendingMessage } from '../worker-types.js'; import type { ActiveSession, PendingMessage, ConversationMessage } from '../worker-types.js';
import { ModeManager } from '../domain/ModeManager.js'; import { ModeManager } from '../domain/ModeManager.js';
// Gemini API endpoint // Gemini API endpoint
@@ -43,17 +43,58 @@ interface GeminiResponse {
}; };
} }
/**
* Gemini content message format
* role: "user" or "model" (Gemini uses "model" not "assistant")
*/
interface GeminiContent {
role: 'user' | 'model';
parts: Array<{ text: string }>;
}
// Forward declaration for fallback agent type
type FallbackAgent = {
startSession(session: ActiveSession, worker?: any): Promise<void>;
};
export class GeminiAgent { export class GeminiAgent {
private dbManager: DatabaseManager; private dbManager: DatabaseManager;
private sessionManager: SessionManager; private sessionManager: SessionManager;
private fallbackAgent: FallbackAgent | null = null;
constructor(dbManager: DatabaseManager, sessionManager: SessionManager) { constructor(dbManager: DatabaseManager, sessionManager: SessionManager) {
this.dbManager = dbManager; this.dbManager = dbManager;
this.sessionManager = sessionManager; this.sessionManager = sessionManager;
} }
/**
* Set the fallback agent (Claude SDK) for when Gemini API fails
* Must be set after construction to avoid circular dependency
*/
setFallbackAgent(agent: FallbackAgent): void {
this.fallbackAgent = agent;
}
/**
* Check if an error should trigger fallback to Claude
*/
private shouldFallbackToClaude(error: any): boolean {
const message = error?.message || '';
// Fall back on rate limit (429), server errors (5xx), or network issues
return (
message.includes('429') ||
message.includes('500') ||
message.includes('502') ||
message.includes('503') ||
message.includes('ECONNREFUSED') ||
message.includes('ETIMEDOUT') ||
message.includes('fetch failed')
);
}
/** /**
* Start Gemini agent for a session * Start Gemini agent for a session
* Uses multi-turn conversation to maintain context across messages
*/ */
async startSession(session: ActiveSession, worker?: any): Promise<void> { async startSession(session: ActiveSession, worker?: any): Promise<void> {
try { try {
@@ -72,10 +113,14 @@ export class GeminiAgent {
? buildInitPrompt(session.project, session.claudeSessionId, session.userPrompt, mode) ? buildInitPrompt(session.project, session.claudeSessionId, session.userPrompt, mode)
: buildContinuationPrompt(session.userPrompt, session.lastPromptNumber, session.claudeSessionId, mode); : buildContinuationPrompt(session.userPrompt, session.lastPromptNumber, session.claudeSessionId, mode);
// Query Gemini with initial prompt // Add to conversation history and query Gemini with full context
const initResponse = await this.queryGemini(initPrompt, apiKey, model); session.conversationHistory.push({ role: 'user', content: initPrompt });
const initResponse = await this.queryGeminiMultiTurn(session.conversationHistory, apiKey, model);
if (initResponse.content) { if (initResponse.content) {
// Add response to conversation history
session.conversationHistory.push({ role: 'assistant', content: initResponse.content });
// Track token usage // Track token usage
const tokensUsed = initResponse.tokensUsed || 0; const tokensUsed = initResponse.tokensUsed || 0;
session.cumulativeInputTokens += Math.floor(tokensUsed * 0.7); // Rough estimate session.cumulativeInputTokens += Math.floor(tokensUsed * 0.7); // Rough estimate
@@ -103,10 +148,14 @@ export class GeminiAgent {
cwd: message.cwd cwd: message.cwd
}); });
// Query Gemini // Add to conversation history and query Gemini with full context
const obsResponse = await this.queryGemini(obsPrompt, apiKey, model); session.conversationHistory.push({ role: 'user', content: obsPrompt });
const obsResponse = await this.queryGeminiMultiTurn(session.conversationHistory, apiKey, model);
if (obsResponse.content) { if (obsResponse.content) {
// Add response to conversation history
session.conversationHistory.push({ role: 'assistant', content: obsResponse.content });
const tokensUsed = obsResponse.tokensUsed || 0; const tokensUsed = obsResponse.tokensUsed || 0;
session.cumulativeInputTokens += Math.floor(tokensUsed * 0.7); session.cumulativeInputTokens += Math.floor(tokensUsed * 0.7);
session.cumulativeOutputTokens += Math.floor(tokensUsed * 0.3); session.cumulativeOutputTokens += Math.floor(tokensUsed * 0.3);
@@ -124,10 +173,14 @@ export class GeminiAgent {
last_assistant_message: message.last_assistant_message || '' last_assistant_message: message.last_assistant_message || ''
}, mode); }, mode);
// Query Gemini // Add to conversation history and query Gemini with full context
const summaryResponse = await this.queryGemini(summaryPrompt, apiKey, model); session.conversationHistory.push({ role: 'user', content: summaryPrompt });
const summaryResponse = await this.queryGeminiMultiTurn(session.conversationHistory, apiKey, model);
if (summaryResponse.content) { if (summaryResponse.content) {
// Add response to conversation history
session.conversationHistory.push({ role: 'assistant', content: summaryResponse.content });
const tokensUsed = summaryResponse.tokensUsed || 0; const tokensUsed = summaryResponse.tokensUsed || 0;
session.cumulativeInputTokens += Math.floor(tokensUsed * 0.7); session.cumulativeInputTokens += Math.floor(tokensUsed * 0.7);
session.cumulativeOutputTokens += Math.floor(tokensUsed * 0.3); session.cumulativeOutputTokens += Math.floor(tokensUsed * 0.3);
@@ -140,7 +193,8 @@ export class GeminiAgent {
const sessionDuration = Date.now() - session.startTime; const sessionDuration = Date.now() - session.startTime;
logger.success('SDK', 'Gemini agent completed', { logger.success('SDK', 'Gemini agent completed', {
sessionId: session.sessionDbId, sessionId: session.sessionDbId,
duration: `${(sessionDuration / 1000).toFixed(1)}s` duration: `${(sessionDuration / 1000).toFixed(1)}s`,
historyLength: session.conversationHistory.length
}); });
this.dbManager.getSessionStore().markSessionCompleted(session.sessionDbId); this.dbManager.getSessionStore().markSessionCompleted(session.sessionDbId);
@@ -148,25 +202,65 @@ export class GeminiAgent {
} catch (error: any) { } catch (error: any) {
if (error.name === 'AbortError') { if (error.name === 'AbortError') {
logger.warn('SDK', 'Gemini agent aborted', { sessionId: session.sessionDbId }); logger.warn('SDK', 'Gemini agent aborted', { sessionId: session.sessionDbId });
} else { throw error;
logger.failure('SDK', 'Gemini agent error', { sessionDbId: session.sessionDbId }, error);
} }
// Check if we should fall back to Claude
if (this.shouldFallbackToClaude(error) && this.fallbackAgent) {
logger.warn('SDK', 'Gemini API failed, falling back to Claude SDK', {
sessionDbId: session.sessionDbId,
error: error.message,
historyLength: session.conversationHistory.length
});
// Reset any 'processing' messages back to 'pending' so Claude can retry them
// This handles the case where Gemini failed mid-processing a message
const pendingStore = this.sessionManager.getPendingMessageStore();
const resetCount = pendingStore.resetStuckMessages(0); // 0 = reset ALL processing messages
if (resetCount > 0) {
logger.info('SDK', 'Reset processing messages for fallback', {
sessionDbId: session.sessionDbId,
resetCount
});
}
// Fall back to Claude - it will use the same session with shared conversationHistory
// Note: Claude SDK will continue processing from current state
return this.fallbackAgent.startSession(session, worker);
}
logger.failure('SDK', 'Gemini agent error', { sessionDbId: session.sessionDbId }, error);
throw error; throw error;
} finally {
// Cleanup
this.sessionManager.deleteSession(session.sessionDbId).catch(() => {});
} }
} }
/** /**
* Query Gemini via REST API * Convert shared ConversationMessage array to Gemini's contents format
* Maps 'assistant' role to 'model' for Gemini API compatibility
*/ */
private async queryGemini( private conversationToGeminiContents(history: ConversationMessage[]): GeminiContent[] {
prompt: string, return history.map(msg => ({
role: msg.role === 'assistant' ? 'model' : 'user',
parts: [{ text: msg.content }]
}));
}
/**
* Query Gemini via REST API with full conversation history (multi-turn)
* Sends the entire conversation context for coherent responses
*/
private async queryGeminiMultiTurn(
history: ConversationMessage[],
apiKey: string, apiKey: string,
model: GeminiModel model: GeminiModel
): Promise<{ content: string; tokensUsed?: number }> { ): Promise<{ content: string; tokensUsed?: number }> {
logger.debug('SDK', `Querying Gemini (${model})`, { promptLength: prompt.length }); const contents = this.conversationToGeminiContents(history);
const totalChars = history.reduce((sum, m) => sum + m.content.length, 0);
logger.debug('SDK', `Querying Gemini multi-turn (${model})`, {
turns: history.length,
totalChars
});
const url = `${GEMINI_API_URL}/${model}:generateContent?key=${apiKey}`; const url = `${GEMINI_API_URL}/${model}:generateContent?key=${apiKey}`;
@@ -176,9 +270,7 @@ export class GeminiAgent {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ body: JSON.stringify({
contents: [{ contents,
parts: [{ text: prompt }]
}],
generationConfig: { generationConfig: {
temperature: 0.3, // Lower temperature for structured extraction temperature: 0.3, // Lower temperature for structured extraction
maxOutputTokens: 4096, maxOutputTokens: 4096,
+48 -19
View File
@@ -184,20 +184,31 @@ export class SDKAgent {
* - SessionManager.initializeSession already fetched this from database * - SessionManager.initializeSession already fetched this from database
* - Database row was created by new-hook's createSDKSession call * - Database row was created by new-hook's createSDKSession call
* - We just use the session_id we're given - simple and reliable * - We just use the session_id we're given - simple and reliable
*
* SHARED CONVERSATION HISTORY:
* - Each user message is added to session.conversationHistory
* - This allows provider switching (ClaudeGemini) with full context
* - SDK manages its own internal state, but we mirror it for interop
*/ */
private async *createMessageGenerator(session: ActiveSession): AsyncIterableIterator<SDKUserMessage> { private async *createMessageGenerator(session: ActiveSession): AsyncIterableIterator<SDKUserMessage> {
// Load active mode // Load active mode
const mode = ModeManager.getInstance().getActiveMode(); const mode = ModeManager.getInstance().getActiveMode();
// Build initial prompt
const initPrompt = session.lastPromptNumber === 1
? buildInitPrompt(session.project, session.claudeSessionId, session.userPrompt, mode)
: buildContinuationPrompt(session.userPrompt, session.lastPromptNumber, session.claudeSessionId, mode);
// Add to shared conversation history for provider interop
session.conversationHistory.push({ role: 'user', content: initPrompt });
// Yield initial user prompt with context (or continuation if prompt #2+) // Yield initial user prompt with context (or continuation if prompt #2+)
// CRITICAL: Both paths use session.claudeSessionId from the hook // CRITICAL: Both paths use session.claudeSessionId from the hook
yield { yield {
type: 'user', type: 'user',
message: { message: {
role: 'user', role: 'user',
content: session.lastPromptNumber === 1 content: initPrompt
? buildInitPrompt(session.project, session.claudeSessionId, session.userPrompt, mode)
: buildContinuationPrompt(session.userPrompt, session.lastPromptNumber, session.claudeSessionId, mode)
}, },
session_id: session.claudeSessionId, session_id: session.claudeSessionId,
parent_tool_use_id: null, parent_tool_use_id: null,
@@ -212,36 +223,46 @@ export class SDKAgent {
session.lastPromptNumber = message.prompt_number; session.lastPromptNumber = message.prompt_number;
} }
const obsPrompt = buildObservationPrompt({
id: 0, // Not used in prompt
tool_name: message.tool_name!,
tool_input: JSON.stringify(message.tool_input),
tool_output: JSON.stringify(message.tool_response),
created_at_epoch: Date.now(),
cwd: message.cwd
});
// Add to shared conversation history for provider interop
session.conversationHistory.push({ role: 'user', content: obsPrompt });
yield { yield {
type: 'user', type: 'user',
message: { message: {
role: 'user', role: 'user',
content: buildObservationPrompt({ content: obsPrompt
id: 0, // Not used in prompt
tool_name: message.tool_name!,
tool_input: JSON.stringify(message.tool_input),
tool_output: JSON.stringify(message.tool_response),
created_at_epoch: Date.now(),
cwd: message.cwd
})
}, },
session_id: session.claudeSessionId, session_id: session.claudeSessionId,
parent_tool_use_id: null, parent_tool_use_id: null,
isSynthetic: true isSynthetic: true
}; };
} else if (message.type === 'summarize') { } else if (message.type === 'summarize') {
const summaryPrompt = buildSummaryPrompt({
id: session.sessionDbId,
sdk_session_id: session.sdkSessionId,
project: session.project,
user_prompt: session.userPrompt,
last_user_message: message.last_user_message || '',
last_assistant_message: message.last_assistant_message || ''
}, mode);
// Add to shared conversation history for provider interop
session.conversationHistory.push({ role: 'user', content: summaryPrompt });
yield { yield {
type: 'user', type: 'user',
message: { message: {
role: 'user', role: 'user',
content: buildSummaryPrompt({ content: summaryPrompt
id: session.sessionDbId,
sdk_session_id: session.sdkSessionId,
project: session.project,
user_prompt: session.userPrompt,
last_user_message: message.last_user_message || '',
last_assistant_message: message.last_assistant_message || ''
}, mode)
}, },
session_id: session.claudeSessionId, session_id: session.claudeSessionId,
parent_tool_use_id: null, parent_tool_use_id: null,
@@ -254,8 +275,16 @@ export class SDKAgent {
/** /**
* Process SDK response text (parse XML, save to database, sync to Chroma) * Process SDK response text (parse XML, save to database, sync to Chroma)
* @param discoveryTokens - Token cost for discovering this response (delta, not cumulative) * @param discoveryTokens - Token cost for discovering this response (delta, not cumulative)
*
* Also captures assistant responses to shared conversation history for provider interop.
* This allows Gemini to see full context if provider is switched mid-session.
*/ */
private async processSDKResponse(session: ActiveSession, text: string, worker: any | undefined, discoveryTokens: number): Promise<void> { private async processSDKResponse(session: ActiveSession, text: string, worker: any | undefined, discoveryTokens: number): Promise<void> {
// Add assistant response to shared conversation history for provider interop
if (text) {
session.conversationHistory.push({ role: 'assistant', content: text });
}
// Parse observations // Parse observations
const observations = parseObservations(text, session.claudeSessionId); const observations = parseObservations(text, session.claudeSessionId);
+3 -1
View File
@@ -117,7 +117,9 @@ export class SessionManager {
startTime: Date.now(), startTime: Date.now(),
cumulativeInputTokens: 0, cumulativeInputTokens: 0,
cumulativeOutputTokens: 0, cumulativeOutputTokens: 0,
pendingProcessingIds: new Set() pendingProcessingIds: new Set(),
conversationHistory: [], // Initialize empty - will be populated by agents
currentProvider: null // Will be set when generator starts
}; };
this.sessions.set(sessionDbId, session); this.sessions.set(sessionDbId, session);
@@ -42,7 +42,10 @@ export class SessionRoutes extends BaseRouteHandler {
/** /**
* Get the appropriate agent based on settings * Get the appropriate agent based on settings
* Falls back to Claude SDK if Gemini is selected but not configured * Throws error if Gemini is selected but not configured (no silent fallback)
*
* Note: Session linking via claudeSessionId allows provider switching mid-session.
* The conversationHistory on ActiveSession maintains context across providers.
*/ */
private getActiveAgent(): SDKAgent | GeminiAgent { private getActiveAgent(): SDKAgent | GeminiAgent {
if (isGeminiSelected()) { if (isGeminiSelected()) {
@@ -50,36 +53,82 @@ export class SessionRoutes extends BaseRouteHandler {
logger.debug('SESSION', 'Using Gemini agent'); logger.debug('SESSION', 'Using Gemini agent');
return this.geminiAgent; return this.geminiAgent;
} else { } else {
logger.warn('SESSION', 'Gemini selected but no API key configured, falling back to Claude SDK'); throw new Error('Gemini provider selected but no API key configured. Set CLAUDE_MEM_GEMINI_API_KEY in settings or GEMINI_API_KEY environment variable.');
return this.sdkAgent;
} }
} }
return this.sdkAgent; return this.sdkAgent;
} }
/**
* Get the currently selected provider name
*/
private getSelectedProvider(): 'claude' | 'gemini' {
return (isGeminiSelected() && isGeminiAvailable()) ? 'gemini' : 'claude';
}
/** /**
* Ensures agent generator is running for a session * Ensures agent generator is running for a session
* Auto-starts if not already running to process pending queue * Auto-starts if not already running to process pending queue
* Uses either Claude SDK or Gemini based on settings * Uses either Claude SDK or Gemini based on settings
*
* Provider switching: If provider setting changed while generator is running,
* we let the current generator finish naturally (max 5s linger timeout).
* The next generator will use the new provider with shared conversationHistory.
*/ */
private ensureGeneratorRunning(sessionDbId: number, source: string): void { private ensureGeneratorRunning(sessionDbId: number, source: string): void {
const session = this.sessionManager.getSession(sessionDbId); const session = this.sessionManager.getSession(sessionDbId);
if (session && !session.generatorPromise) { if (!session) return;
const agent = this.getActiveAgent();
const agentName = (isGeminiSelected() && isGeminiAvailable()) ? 'Gemini' : 'Claude SDK';
logger.info('SESSION', `Generator auto-starting (${source}) using ${agentName}`, { const selectedProvider = this.getSelectedProvider();
sessionId: sessionDbId,
queueDepth: session.pendingMessages.length
});
session.generatorPromise = agent.startSession(session, this.workerService) // Start generator if not running
.finally(() => { if (!session.generatorPromise) {
logger.info('SESSION', `Generator finished`, { sessionId: sessionDbId }); this.startGeneratorWithProvider(session, selectedProvider, source);
session.generatorPromise = null; return;
this.workerService.broadcastProcessingStatus();
});
} }
// Generator is running - check if provider changed
if (session.currentProvider && session.currentProvider !== selectedProvider) {
logger.info('SESSION', `Provider changed, will switch after current generator finishes`, {
sessionId: sessionDbId,
currentProvider: session.currentProvider,
selectedProvider,
historyLength: session.conversationHistory.length
});
// Let current generator finish naturally, next one will use new provider
// The shared conversationHistory ensures context is preserved
}
}
/**
* Start a generator with the specified provider
*/
private startGeneratorWithProvider(
session: ReturnType<typeof this.sessionManager.getSession>,
provider: 'claude' | 'gemini',
source: string
): void {
if (!session) return;
const agent = provider === 'gemini' ? this.geminiAgent : this.sdkAgent;
const agentName = provider === 'gemini' ? 'Gemini' : 'Claude SDK';
logger.info('SESSION', `Generator auto-starting (${source}) using ${agentName}`, {
sessionId: session.sessionDbId,
queueDepth: session.pendingMessages.length,
historyLength: session.conversationHistory.length
});
// Track which provider is running
session.currentProvider = provider;
session.generatorPromise = agent.startSession(session, this.workerService)
.finally(() => {
logger.info('SESSION', `Generator finished`, { sessionId: session.sessionDbId });
session.generatorPromise = null;
session.currentProvider = null;
this.workerService.broadcastProcessingStatus();
});
} }
setupRoutes(app: express.Application): void { setupRoutes(app: express.Application): void {
@@ -150,24 +199,8 @@ export class SessionRoutes extends BaseRouteHandler {
}); });
} }
// Start agent in background (pass worker ref for spinner control) // Start agent in background using the helper method
const agent = this.getActiveAgent(); this.startGeneratorWithProvider(session, this.getSelectedProvider(), 'init');
const agentName = isGeminiSelected() ? 'Gemini' : 'Claude SDK';
logger.info('SESSION', `Generator starting using ${agentName}`, {
sessionId: sessionDbId,
project: session.project,
promptNum: session.lastPromptNumber
});
session.generatorPromise = agent.startSession(session, this.workerService)
.finally(() => {
// Clear generator reference when completed
logger.info('SESSION', `Generator finished`, { sessionId: sessionDbId });
session.generatorPromise = null;
// Broadcast status change (generator finished, may stop spinner)
this.workerService.broadcastProcessingStatus();
});
// Broadcast session started event // Broadcast session started event
this.eventBroadcaster.broadcastSessionStarted(sessionDbId, session.project); this.eventBroadcaster.broadcastSessionStarted(sessionDbId, session.project);