Skip to content
This repository was archived by the owner on Feb 14, 2026. It is now read-only.

Commit 3960d02

Browse files
committed
refactor(gemini): prevent session swap race conditions, dedupe history, unify model resolution
1 parent 47d731d commit 3960d02

7 files changed

Lines changed: 166 additions & 58 deletions

File tree

src/agent/acp/AcpBackend.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -997,12 +997,24 @@ export class AcpBackend implements AgentBackend {
997997
}
998998
}
999999

1000+
/**
1001+
* Emit permission response event for UI/logging purposes.
1002+
*
1003+
* **IMPORTANT:** For ACP backends, this method does NOT send the actual permission
1004+
* response to the agent. The ACP protocol requires synchronous permission handling,
1005+
* which is done inside the `requestPermission` RPC handler via `this.options.permissionHandler`.
1006+
*
1007+
* This method only emits a `permission-response` event for:
1008+
* - UI updates (e.g., closing permission dialogs)
1009+
* - Logging and debugging
1010+
* - Other parts of the CLI that need to react to permission decisions
1011+
*
1012+
* @param requestId - The ID of the permission request
1013+
* @param approved - Whether the permission was granted
1014+
*/
10001015
async respondToPermission(requestId: string, approved: boolean): Promise<void> {
1001-
logger.debug(`[AcpBackend] Permission response: ${requestId} = ${approved}`);
1016+
logger.debug(`[AcpBackend] Permission response event (UI only): ${requestId} = ${approved}`);
10021017
this.emit({ type: 'permission-response', id: requestId, approved });
1003-
// IMPORTANT: The actual ACP permission response is handled synchronously
1004-
// within the `requestPermission` method via `this.options.permissionHandler`.
1005-
// This method only emits an internal event for other parts of the CLI to react to.
10061018
}
10071019

10081020
async dispose(): Promise<void> {

src/agent/core/AgentBackend.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,16 @@ export interface AgentBackend {
141141

142142
/**
143143
* Respond to a permission request.
144-
*
144+
*
145+
* **Implementation Note for ACP backends:**
146+
* For ACP-based agents (Gemini, Codex via ACP), permission handling is done
147+
* synchronously within the `requestPermission` RPC handler via `AcpPermissionHandler`.
148+
* This method only emits an internal `permission-response` event for UI/logging purposes.
149+
* The actual ACP response is already sent by the time this method is called.
150+
*
151+
* For non-ACP backends, this method should actually send the permission response
152+
* to the agent.
153+
*
145154
* @param requestId - The ID of the permission request
146155
* @param approved - Whether the permission was granted
147156
*/

src/agent/factories/gemini.ts

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -50,17 +50,28 @@ export interface GeminiBackendOptions extends AgentFactoryOptions {
5050
permissionHandler?: AcpPermissionHandler;
5151
}
5252

53+
/**
54+
* Result of creating a Gemini backend
55+
*/
56+
export interface GeminiBackendResult {
57+
/** The created AgentBackend instance */
58+
backend: AgentBackend;
59+
/** The resolved model that will be used (single source of truth) */
60+
model: string;
61+
/** Source of the model selection for logging */
62+
modelSource: 'explicit' | 'env-var' | 'local-config' | 'default';
63+
}
64+
5365
/**
5466
* Create a Gemini backend using ACP (official SDK).
55-
*
67+
*
5668
* The Gemini CLI must be installed and available in PATH.
5769
* Uses the --experimental-acp flag to enable ACP mode.
58-
*
70+
*
5971
* @param options - Configuration options
60-
* @returns AgentBackend instance for Gemini
72+
* @returns GeminiBackendResult with backend and resolved model (single source of truth)
6173
*/
62-
63-
export function createGeminiBackend(options: GeminiBackendOptions): AgentBackend {
74+
export function createGeminiBackend(options: GeminiBackendOptions): GeminiBackendResult {
6475

6576
// Resolve API key from multiple sources (in priority order):
6677
// 1. Happy cloud OAuth token (via 'happy connect gemini') - highest priority
@@ -145,7 +156,7 @@ export function createGeminiBackend(options: GeminiBackendOptions): AgentBackend
145156

146157
// Determine model source for logging
147158
const modelSource = getGeminiModelSource(options.model, localConfig);
148-
159+
149160
logger.debug('[Gemini] Creating ACP SDK backend with options:', {
150161
cwd: backendOptions.cwd,
151162
command: backendOptions.command,
@@ -156,7 +167,11 @@ export function createGeminiBackend(options: GeminiBackendOptions): AgentBackend
156167
mcpServerCount: options.mcpServers ? Object.keys(options.mcpServers).length : 0,
157168
});
158169

159-
return new AcpBackend(backendOptions);
170+
return {
171+
backend: new AcpBackend(backendOptions),
172+
model,
173+
modelSource,
174+
};
160175
}
161176

162177
/**
@@ -166,7 +181,7 @@ export function createGeminiBackend(options: GeminiBackendOptions): AgentBackend
166181
* to make the Gemini agent available for use.
167182
*/
168183
export function registerGeminiAgent(): void {
169-
agentRegistry.register('gemini', (opts) => createGeminiBackend(opts));
184+
agentRegistry.register('gemini', (opts) => createGeminiBackend(opts).backend);
170185
logger.debug('[Gemini] Registered with agent registry');
171186
}
172187

src/agent/factories/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export {
1212
createGeminiBackend,
1313
registerGeminiAgent,
1414
type GeminiBackendOptions,
15+
type GeminiBackendResult,
1516
} from './gemini';
1617

1718
// Future factories:

src/agent/transport/handlers/GeminiTransport.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import type {
2020
ToolNameContext,
2121
} from '../TransportHandler';
2222
import type { AgentMessage } from '../../core';
23+
import { logger } from '@/ui/logger';
2324

2425
/**
2526
* Gemini-specific timeout values (in milliseconds)
@@ -313,6 +314,16 @@ export class GeminiTransport implements TransportHandler {
313314
}
314315

315316
// Return original tool name if we couldn't determine it
317+
// Log unknown patterns so developers can add them to GEMINI_TOOL_PATTERNS
318+
if (toolName === 'other' || toolName === 'Unknown tool') {
319+
const inputKeys = input && typeof input === 'object' ? Object.keys(input) : [];
320+
logger.debug(
321+
`[GeminiTransport] Unknown tool pattern - toolCallId: "${toolCallId}", ` +
322+
`toolName: "${toolName}", inputKeys: [${inputKeys.join(', ')}]. ` +
323+
`Consider adding a new pattern to GEMINI_TOOL_PATTERNS if this tool appears frequently.`
324+
);
325+
}
326+
316327
return toolName;
317328
}
318329
}

src/gemini/runGemini.ts

Lines changed: 58 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,10 @@ import { GeminiDiffProcessor } from '@/gemini/utils/diffProcessor';
4040
import type { GeminiMode, CodexMessagePayload } from '@/gemini/types';
4141
import type { PermissionMode } from '@/api/types';
4242
import { GEMINI_MODEL_ENV, DEFAULT_GEMINI_MODEL, CHANGE_TITLE_INSTRUCTION } from '@/gemini/constants';
43-
import {
44-
readGeminiLocalConfig,
45-
determineGeminiModel,
43+
import {
44+
readGeminiLocalConfig,
4645
saveGeminiModelToConfig,
47-
getInitialGeminiModel
46+
getInitialGeminiModel
4847
} from '@/gemini/utils/config';
4948
import {
5049
parseOptionsFromText,
@@ -137,17 +136,45 @@ export async function runGemini(opts: {
137136
// Permission handler declared here so it can be updated in onSessionSwap callback
138137
// (assigned later after Happy server setup)
139138
let permissionHandler: GeminiPermissionHandler;
139+
140+
// Session swap synchronization to prevent race conditions during message processing
141+
// When a swap is requested during processing, it's queued and applied after the current cycle
142+
let isProcessingMessage = false;
143+
let pendingSessionSwap: ApiSessionClient | null = null;
144+
145+
/**
146+
* Apply a pending session swap. Called between message processing cycles.
147+
* This ensures session swaps happen at safe points, not during message processing.
148+
*/
149+
const applyPendingSessionSwap = () => {
150+
if (pendingSessionSwap) {
151+
logger.debug('[gemini] Applying pending session swap');
152+
session = pendingSessionSwap;
153+
if (permissionHandler) {
154+
permissionHandler.updateSession(pendingSessionSwap);
155+
}
156+
pendingSessionSwap = null;
157+
}
158+
};
159+
140160
const { session: initialSession, reconnectionHandle } = setupOfflineReconnection({
141161
api,
142162
sessionTag,
143163
metadata,
144164
state,
145165
response,
146166
onSessionSwap: (newSession) => {
147-
session = newSession;
148-
// Update permission handler with new session to avoid stale reference
149-
if (permissionHandler) {
150-
permissionHandler.updateSession(newSession);
167+
// If we're processing a message, queue the swap for later
168+
// This prevents race conditions where session changes mid-processing
169+
if (isProcessingMessage) {
170+
logger.debug('[gemini] Session swap requested during message processing - queueing');
171+
pendingSessionSwap = newSession;
172+
} else {
173+
// Safe to swap immediately
174+
session = newSession;
175+
if (permissionHandler) {
176+
permissionHandler.updateSession(newSession);
177+
}
151178
}
152179
}
153180
});
@@ -911,10 +938,10 @@ export async function runGemini(opts: {
911938
await geminiBackend.dispose();
912939
geminiBackend = null;
913940
}
914-
941+
915942
// Create new backend with new model
916943
const modelToUse = message.mode?.model === undefined ? undefined : (message.mode.model || null);
917-
geminiBackend = createGeminiBackend({
944+
const backendResult = createGeminiBackend({
918945
cwd: process.cwd(),
919946
mcpServers,
920947
permissionHandler,
@@ -924,16 +951,14 @@ export async function runGemini(opts: {
924951
// If explicitly null, will skip local config and use env/default
925952
model: modelToUse,
926953
});
927-
954+
geminiBackend = backendResult.backend;
955+
928956
// Set up message handler again
929957
setupGeminiMessageHandler(geminiBackend);
930-
931-
// Start new session
932-
// Determine actual model that will be used (from backend creation logic)
933-
// Replicate backend logic: message model > env var > local config > default
934-
const localConfigForModel = readGeminiLocalConfig();
935-
const actualModel = determineGeminiModel(modelToUse, localConfigForModel);
936-
logger.debug(`[gemini] Model change - modelToUse=${modelToUse}, actualModel=${actualModel}`);
958+
959+
// Use model from factory result (single source of truth - no duplicate resolution)
960+
const actualModel = backendResult.model;
961+
logger.debug(`[gemini] Model change - modelToUse=${modelToUse}, actualModel=${actualModel} (from ${backendResult.modelSource})`);
937962

938963
// Update conversation history with new model
939964
conversationHistory.setCurrentModel(actualModel);
@@ -961,12 +986,15 @@ export async function runGemini(opts: {
961986
const userMessageToShow = message.mode?.originalUserMessage || message.message;
962987
messageBuffer.addMessage(userMessageToShow, 'user');
963988

989+
// Mark that we're processing a message to synchronize session swaps
990+
isProcessingMessage = true;
991+
964992
try {
965993
if (first || !wasSessionCreated) {
966994
// First message or session not created yet - create backend and start session
967995
if (!geminiBackend) {
968996
const modelToUse = message.mode?.model === undefined ? undefined : (message.mode.model || null);
969-
geminiBackend = createGeminiBackend({
997+
const backendResult = createGeminiBackend({
970998
cwd: process.cwd(),
971999
mcpServers,
9721000
permissionHandler,
@@ -976,25 +1004,14 @@ export async function runGemini(opts: {
9761004
// If explicitly null, will skip local config and use env/default
9771005
model: modelToUse,
9781006
});
979-
1007+
geminiBackend = backendResult.backend;
1008+
9801009
// Set up message handler
9811010
setupGeminiMessageHandler(geminiBackend);
982-
983-
// Determine actual model that will be used
984-
// Backend will determine model from: message model > env var > local config > default
985-
// We need to replicate this logic here to show correct model in UI
986-
const localConfigForModel = readGeminiLocalConfig();
987-
const actualModel = determineGeminiModel(modelToUse, localConfigForModel);
988-
989-
const modelSource = modelToUse !== undefined
990-
? 'message'
991-
: process.env[GEMINI_MODEL_ENV]
992-
? 'env-var'
993-
: localConfigForModel.model
994-
? 'local-config'
995-
: 'default';
996-
997-
logger.debug(`[gemini] Backend created, model will be: ${actualModel} (from ${modelSource})`);
1011+
1012+
// Use model from factory result (single source of truth - no duplicate resolution)
1013+
const actualModel = backendResult.model;
1014+
logger.debug(`[gemini] Backend created, model will be: ${actualModel} (from ${backendResult.modelSource})`);
9981015
logger.debug(`[gemini] Calling updateDisplayedModel with: ${actualModel}`);
9991016
updateDisplayedModel(actualModel, false); // Don't save - this is backend initialization
10001017

@@ -1258,7 +1275,11 @@ export async function runGemini(opts: {
12581275

12591276
// Use same logic as Codex - emit ready if idle (no pending operations, no queue)
12601277
emitReadyIfIdle();
1261-
1278+
1279+
// Message processing complete - safe to apply any pending session swap
1280+
isProcessingMessage = false;
1281+
applyPendingSessionSwap();
1282+
12621283
logger.debug(`[gemini] Main loop: turn completed, continuing to next iteration (queue size: ${messageQueue.size()})`);
12631284
}
12641285
}

src/gemini/utils/conversationHistory.ts

Lines changed: 47 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -46,37 +46,76 @@ export class ConversationHistory {
4646
this.currentModel = model;
4747
}
4848

49+
/**
50+
* Check if content is a duplicate of the last message with the same role.
51+
* Deduplication prevents inflating history when the same message is sent multiple times.
52+
*/
53+
private isDuplicate(role: 'user' | 'assistant', content: string): boolean {
54+
if (this.messages.length === 0) return false;
55+
56+
// Find the last message with the same role
57+
for (let i = this.messages.length - 1; i >= 0; i--) {
58+
const msg = this.messages[i];
59+
if (msg.role === role) {
60+
// Check if content matches (normalize whitespace for comparison)
61+
const normalizedNew = content.trim().replace(/\s+/g, ' ');
62+
const normalizedExisting = msg.content.replace(/\s+/g, ' ');
63+
return normalizedNew === normalizedExisting;
64+
}
65+
}
66+
67+
return false;
68+
}
69+
4970
/**
5071
* Add a user message to history
72+
* Skips duplicate messages to prevent history inflation
5173
*/
5274
addUserMessage(content: string): void {
5375
if (!content.trim()) return;
54-
76+
77+
const trimmedContent = content.trim();
78+
79+
// Skip duplicate messages
80+
if (this.isDuplicate('user', trimmedContent)) {
81+
logger.debug(`[ConversationHistory] Skipping duplicate user message (${trimmedContent.length} chars)`);
82+
return;
83+
}
84+
5585
this.messages.push({
5686
role: 'user',
57-
content: content.trim(),
87+
content: trimmedContent,
5888
timestamp: Date.now(),
5989
});
60-
90+
6191
this.trimHistory();
62-
logger.debug(`[ConversationHistory] Added user message (${content.length} chars), total: ${this.messages.length}`);
92+
logger.debug(`[ConversationHistory] Added user message (${trimmedContent.length} chars), total: ${this.messages.length}`);
6393
}
6494

6595
/**
6696
* Add an assistant response to history
97+
* Skips duplicate messages to prevent history inflation
6798
*/
6899
addAssistantMessage(content: string): void {
69100
if (!content.trim()) return;
70-
101+
102+
const trimmedContent = content.trim();
103+
104+
// Skip duplicate messages
105+
if (this.isDuplicate('assistant', trimmedContent)) {
106+
logger.debug(`[ConversationHistory] Skipping duplicate assistant message (${trimmedContent.length} chars)`);
107+
return;
108+
}
109+
71110
this.messages.push({
72111
role: 'assistant',
73-
content: content.trim(),
112+
content: trimmedContent,
74113
timestamp: Date.now(),
75114
model: this.currentModel,
76115
});
77-
116+
78117
this.trimHistory();
79-
logger.debug(`[ConversationHistory] Added assistant message (${content.length} chars), total: ${this.messages.length}`);
118+
logger.debug(`[ConversationHistory] Added assistant message (${trimmedContent.length} chars), total: ${this.messages.length}`);
80119
}
81120

82121
/**

0 commit comments

Comments
 (0)