Compare commits

...

2 Commits

Author SHA1 Message Date
davidapierce ac818594b9 refactor(core): simplify history scrubbing by removing redundant calls 2026-08-04 00:05:52 +00:00
davidapierce 3cf18c822f fix(core,cli): resolve context corruption and quota error fallback issues 2026-08-03 22:21:04 +00:00
3 changed files with 118 additions and 9 deletions
@@ -4360,4 +4360,108 @@ describe('useGeminiStream', () => {
});
expect(spanMetadata.input).toBe('telemetry test query');
});
describe('Quota Error fallback', () => {
it('should add tool responses to geminiClient history if modelSwitchedFromQuotaError is true', async () => {
const completedToolCalls: TrackedCompletedToolCall[] = [
{
request: {
callId: 'call1',
name: 'tool1',
args: {},
isClientInitiated: false,
prompt_id: 'prompt-id-1',
},
status: CoreToolCallStatus.Success,
responseSubmittedToGemini: false,
response: {
callId: 'call1',
responseParts: [
{
functionResponse: {
name: 'tool1',
id: 'call1',
response: { success: true },
},
},
],
errorType: undefined,
},
tool: {
name: 'tool1',
displayName: 'tool1',
description: 'desc1',
build: vi.fn(),
isOutputMarkdown: false,
} as any,
invocation: {
getDescription: () => 'desc1',
} as any,
} as unknown as TrackedCompletedToolCall,
];
const client = new MockedGeminiClientClass(mockConfig);
let capturedOnComplete:
| ((completedTools: TrackedToolCall[]) => Promise<void>)
| null = null;
mockUseToolScheduler.mockImplementation((onComplete) => {
capturedOnComplete = onComplete;
return [
[],
mockScheduleToolCalls,
mockMarkToolsAsSubmitted,
vi.fn(),
mockCancelAllToolCalls,
0,
];
});
await renderHookWithProviders(() =>
useGeminiStream(
client,
[],
mockAddItem,
mockConfig,
mockLoadedSettings,
mockOnDebugMessage,
mockHandleSlashCommand,
false,
() => 'vscode' as EditorType,
() => {},
() => Promise.resolve(),
true, // modelSwitchedFromQuotaError is true
() => {},
() => {},
() => {},
80,
24,
),
);
// Trigger the onComplete callback with completed tools
await act(async () => {
if (capturedOnComplete) {
await new Promise((resolve) => setTimeout(resolve, 0));
await capturedOnComplete(completedToolCalls);
}
});
await waitFor(() => {
expect(mockMarkToolsAsSubmitted).toHaveBeenCalledWith(['call1']);
expect(client.addHistory).toHaveBeenCalledWith({
role: 'user',
parts: [
{
functionResponse: {
name: 'tool1',
id: 'call1',
response: { success: true },
},
},
],
});
});
});
});
});
@@ -2132,6 +2132,12 @@ export const useGeminiStream = (
// Don't continue if model was switched due to quota error
if (modelSwitchedFromQuotaError) {
if (geminiClient && responsesToSend.length > 0) {
await geminiClient.addHistory({
role: 'user',
parts: responsesToSend,
});
}
return;
}
+8 -9
View File
@@ -55,7 +55,11 @@ import {
} from '../telemetry/types.js';
import { handleFallback } from '../fallback/handler.js';
import { isFunctionResponse } from '../utils/messageInspectors.js';
import { scrubHistory, scrubContents } from '../utils/historyHardening.js';
import {
hardenHistory,
scrubHistory,
scrubContents,
} from '../utils/historyHardening.js';
import {
partListUnionToString,
ensureStableToolIds,
@@ -712,14 +716,9 @@ export class GeminiChat {
role: LlmRole,
apiHistoryOverride?: Content[],
): Promise<AsyncGenerator<GenerateContentResponse>> {
// Last mile scrubbing to remove internal tracking properties (e.g. callIndex)
// before sending to the Gemini API. This whitelists only standard Gemini fields.
let scrubbedHistory = this.context.config.isContextManagementEnabled()
? scrubHistory([...requestHistory])
: [...requestHistory];
// Always coalesce consecutive roles to prevent 400 Bad Request errors
scrubbedHistory = coalesceConsecutiveRoles(scrubbedHistory);
// Last mile hardening and scrubbing to ensure absolute compliance with Gemini API invariants
// and remove internal tracking properties (e.g. callIndex).
const scrubbedHistory = hardenHistory([...requestHistory]);
const scrubbedContents = scrubbedHistory.map((h) => h.content);