diff --git a/packages/cli/src/ui/components/RewindViewer.test.tsx b/packages/cli/src/ui/components/RewindViewer.test.tsx
index 0dd7fa5c021..d6954ec46b2 100644
--- a/packages/cli/src/ui/components/RewindViewer.test.tsx
+++ b/packages/cli/src/ui/components/RewindViewer.test.tsx
@@ -426,6 +426,53 @@ describe('RewindViewer', () => {
expect(lastFrame2()).toMatchSnapshot('after-update');
unmount2();
});
+
+ it('excludes user messages that only carry tool responses from the rewind points', async () => {
+ const messages: MessageRecord[] = [
+ { type: 'user', content: 'Run the tool', id: '1', timestamp: '1' },
+ { type: 'gemini', content: 'Running it now.', id: '2', timestamp: '2' },
+ {
+ type: 'user',
+ content: [
+ {
+ functionResponse: {
+ name: 'testTool',
+ id: 'call-1',
+ response: { output: 'tool output' },
+ },
+ },
+ ],
+ id: '3',
+ timestamp: '3',
+ },
+ {
+ type: 'user',
+ content: 'Thanks, next question',
+ id: '4',
+ timestamp: '4',
+ },
+ ];
+ const conversation = createConversation(messages);
+ const onExit = vi.fn();
+ const onRewind = vi.fn();
+
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ );
+
+ const frame = lastFrame();
+ expect(frame).toContain('Run the tool');
+ expect(frame).toContain('Thanks, next question');
+ expect(frame).toContain('Stay at current position');
+ // Only the two authored prompts are listed as rewind points; the
+ // tool-response record between them is not offered as a third entry.
+ expect((frame?.match(/No files have been changed/g) ?? []).length).toBe(2);
+ unmount();
+ });
});
it('renders accessible screen reader view when screen reader is enabled', async () => {
const { useIsScreenReaderEnabled } = await import('ink');
diff --git a/packages/cli/src/ui/components/RewindViewer.tsx b/packages/cli/src/ui/components/RewindViewer.tsx
index e77b17db32f..935de9459b7 100644
--- a/packages/cli/src/ui/components/RewindViewer.tsx
+++ b/packages/cli/src/ui/components/RewindViewer.tsx
@@ -19,6 +19,7 @@ import { useKeypress } from '../hooks/useKeypress.js';
import { useRewind } from '../hooks/useRewind.js';
import { RewindConfirmation, RewindOutcome } from './RewindConfirmation.js';
import { stripReferenceContent } from '../utils/formatters.js';
+import { isToolResponseMessage } from '../utils/rewindFileOps.js';
import { Command } from '../key/keyMatchers.js';
import { CliSpinner } from './CliSpinner.js';
import { ExpandableText } from './shared/ExpandableText.js';
@@ -69,7 +70,10 @@ export const RewindViewer: React.FC = ({
);
const interactions = useMemo(
- () => conversation.messages.filter((msg) => msg.type === 'user'),
+ () =>
+ conversation.messages.filter(
+ (msg) => msg.type === 'user' && !isToolResponseMessage(msg),
+ ),
[conversation.messages],
);
diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx
index 9ef69296813..9ad079cbb94 100644
--- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx
+++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx
@@ -92,6 +92,19 @@ const MockedGeminiClientClass = vi.hoisted(() =>
this.setHistory = vi.fn().mockImplementation((newHistory: any[]) => {
mockHistory = [...newHistory];
});
+ this.discardTrailingUnansweredToolCallTurn = vi
+ .fn()
+ .mockImplementation(() => {
+ const last = mockHistory[mockHistory.length - 1];
+ if (
+ last?.role !== 'model' ||
+ !last.parts?.some((part: any) => !!part.functionCall)
+ ) {
+ return false;
+ }
+ mockHistory = mockHistory.slice(0, -1);
+ return true;
+ });
this.generateContent = vi.fn().mockResolvedValue({
candidates: [
{ content: { parts: [{ text: 'Got it. Focusing on tests only.' }] } },
@@ -1132,7 +1145,7 @@ describe('useGeminiStream', () => {
),
);
- // Call submitQuery to populate the user turn and set historyLengthAfterUserPromptRef
+ // Call submitQuery to populate the user turn
await act(async () => {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
result.current.submitQuery('User prompt');
@@ -1167,6 +1180,161 @@ describe('useGeminiStream', () => {
});
});
+ it('should keep the user prompt and completed tool rounds when a later tool batch is declined', async () => {
+ const cancelledToolCalls: TrackedToolCall[] = [
+ {
+ request: {
+ callId: '2',
+ name: 'testTool',
+ args: {},
+ isClientInitiated: false,
+ prompt_id: 'prompt-id-4',
+ },
+ status: CoreToolCallStatus.Cancelled,
+ response: {
+ callId: '2',
+ responseParts: [{ text: CoreToolCallStatus.Cancelled }],
+ errorType: undefined,
+ },
+ responseSubmittedToGemini: false,
+ tool: {
+ displayName: 'mock tool',
+ },
+ invocation: {
+ getDescription: () => `Mock description`,
+ },
+ } as any,
+ ];
+ const client = new MockedGeminiClientClass(mockConfig);
+ const priorTurn = [
+ { role: 'user', parts: [{ text: 'Earlier prompt' }] },
+ { role: 'model', parts: [{ text: 'Earlier answer' }] },
+ ];
+ client.setHistory(priorTurn);
+ // Model the real sendMessageStream contract: the user turn is recorded in
+ // the client history when the request is sent, not before submitQuery.
+ mockSendMessageStream.mockImplementation((query: PartListUnion) => {
+ const parts = (Array.isArray(query) ? query : [query]).map((part) =>
+ typeof part === 'string' ? { text: part } : part,
+ );
+ client.setHistory([...client.getHistory(), { role: 'user', parts }]);
+ return (async function* () {
+ yield { type: ServerGeminiEventType.Content, value: 'Working on it' };
+ })();
+ });
+
+ let capturedOnComplete:
+ | ((completedTools: TrackedToolCall[]) => Promise)
+ | null = null;
+
+ mockUseToolScheduler.mockImplementation((onComplete) => {
+ capturedOnComplete = onComplete;
+ return [
+ [],
+ mockScheduleToolCalls,
+ mockMarkToolsAsSubmitted,
+ vi.fn(),
+ mockCancelAllToolCalls,
+ 0,
+ ];
+ });
+
+ const { result } = await renderHookWithProviders(() =>
+ useGeminiStream(
+ client,
+ [],
+ mockAddItem,
+ mockConfig,
+ mockLoadedSettings,
+ mockOnDebugMessage,
+ mockHandleSlashCommand,
+ false,
+ () => 'vscode' as EditorType,
+ () => {},
+ () => Promise.resolve(),
+ false,
+ () => {},
+ () => {},
+ () => {},
+ 80,
+ 24,
+ ),
+ );
+
+ // Turn 1 starts: sendMessageStream records the user prompt.
+ await act(async () => {
+ await result.current.submitQuery('User prompt');
+ });
+
+ // The model answers with a first tool call, which completes and is sent
+ // back to the model as a continuation turn.
+ const firstRoundResponse: Part[] = [
+ {
+ functionResponse: {
+ name: 'testTool',
+ id: '1',
+ response: { output: 'first result' },
+ },
+ },
+ ];
+ client.setHistory([
+ ...client.getHistory(),
+ {
+ role: 'model',
+ parts: [{ functionCall: { name: 'testTool', id: '1', args: {} } }],
+ },
+ ]);
+ await act(async () => {
+ await result.current.submitQuery(firstRoundResponse, {
+ isContinuation: true,
+ });
+ });
+
+ // The model requests a second tool call within the same chain.
+ client.setHistory([
+ ...client.getHistory(),
+ {
+ role: 'model',
+ parts: [{ functionCall: { name: 'testTool', id: '2', args: {} } }],
+ },
+ ]);
+ vi.mocked(client.setHistory).mockClear();
+
+ // The second tool call is declined.
+ await act(async () => {
+ if (capturedOnComplete) {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ await capturedOnComplete(cancelledToolCalls);
+ }
+ });
+
+ try {
+ await waitFor(() => {
+ expect(mockMarkToolsAsSubmitted).toHaveBeenCalledWith(['2']);
+ expect(client.addHistory).not.toHaveBeenCalled();
+ // Only the trailing unanswered call is removed, without re-setting
+ // the whole history (which would re-record every turn).
+ expect(
+ client.discardTrailingUnansweredToolCallTurn,
+ ).toHaveBeenCalledTimes(1);
+ expect(client.setHistory).not.toHaveBeenCalled();
+ // The previous turn, the user prompt and the completed first round
+ // are preserved.
+ expect(client.getHistory()).toEqual([
+ ...priorTurn,
+ { role: 'user', parts: [{ text: 'User prompt' }] },
+ {
+ role: 'model',
+ parts: [{ functionCall: { name: 'testTool', id: '1', args: {} } }],
+ },
+ { role: 'user', parts: firstRoundResponse },
+ ]);
+ });
+ } finally {
+ mockSendMessageStream.mockImplementation(() => (async function* () {})());
+ }
+ });
+
it('should record tool responses in history when the model was switched due to a quota error', async () => {
// Regression test: returning early on a quota-triggered model switch
// without recording the responses leaves the already-recorded
@@ -1597,7 +1765,7 @@ describe('useGeminiStream', () => {
),
);
- // Call submitQuery to populate the user turn and set historyLengthAfterUserPromptRef
+ // Call submitQuery to populate the user turn
await act(async () => {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
result.current.submitQuery('User prompt');
diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts
index ee7c7fa2967..7201d614b6d 100644
--- a/packages/cli/src/ui/hooks/useGeminiStream.ts
+++ b/packages/cli/src/ui/hooks/useGeminiStream.ts
@@ -258,7 +258,6 @@ export const useGeminiStream = (
const abortControllerRef = useRef(null);
const turnCancelledRef = useRef(false);
const activeQueryIdRef = useRef(null);
- const historyLengthAfterUserPromptRef = useRef(undefined);
const previousApprovalModeRef = useRef(
config.getApprovalMode(),
);
@@ -1739,11 +1738,6 @@ export const useGeminiStream = (
return;
}
- if (geminiClient) {
- historyLengthAfterUserPromptRef.current =
- geminiClient.getHistory().length;
- }
-
if (!options?.isContinuation) {
if (typeof queryToSend === 'string') {
// logging the text prompts only for now
@@ -2127,17 +2121,10 @@ export const useGeminiStream = (
}
setIsResponding(false);
- if (
- geminiClient &&
- historyLengthAfterUserPromptRef.current !== undefined
- ) {
- const targetLength = historyLengthAfterUserPromptRef.current;
- if (geminiClient.getHistory().length > targetLength) {
- geminiClient.setHistory(
- geminiClient.getHistory().slice(0, targetLength),
- );
- }
- }
+ // Only roll back the unanswered model function call turn for this
+ // cancelled batch. The originating user prompt and any tool rounds
+ // that already completed (and may have changed files) stay in history.
+ geminiClient?.discardTrailingUnansweredToolCallTurn();
const callIdsToMarkAsSubmitted = geminiTools.map(
(toolCall) => toolCall.request.callId,
diff --git a/packages/cli/src/ui/utils/rewindFileOps.test.ts b/packages/cli/src/ui/utils/rewindFileOps.test.ts
index 4e693386aba..e6486e7a23e 100644
--- a/packages/cli/src/ui/utils/rewindFileOps.test.ts
+++ b/packages/cli/src/ui/utils/rewindFileOps.test.ts
@@ -9,6 +9,7 @@ import fs from 'node:fs/promises';
import {
calculateTurnStats,
calculateRewindImpact,
+ isToolResponseMessage,
revertFileChanges,
} from './rewindFileOps.js';
import {
@@ -120,6 +121,140 @@ describe('rewindFileOps', () => {
removedLines: 3,
});
});
+
+ it('aggregates stats across multiple tool rounds separated by tool responses', async () => {
+ const { getFileDiffFromResultDisplay, computeModelAddedAndRemovedLines } =
+ await import('@google/gemini-cli-core');
+ vi.mocked(getFileDiffFromResultDisplay).mockImplementation(
+ (resultDisplay) =>
+ ({
+ filePath: String(resultDisplay),
+ fileName: String(resultDisplay),
+ originalContent: 'old',
+ newContent: 'new',
+ isNewFile: false,
+ diffStat: {
+ model_added_lines: 0,
+ model_removed_lines: 0,
+ model_added_chars: 0,
+ model_removed_chars: 0,
+ user_added_lines: 0,
+ user_removed_lines: 0,
+ user_added_chars: 0,
+ user_removed_chars: 0,
+ },
+ fileDiff: 'diff',
+ }) as ReturnType,
+ );
+ vi.mocked(computeModelAddedAndRemovedLines).mockReturnValue({
+ addedLines: 2,
+ removedLines: 1,
+ });
+
+ const userMsg = {
+ type: 'user',
+ content: [{ text: 'Edit both files' }],
+ } as unknown as MessageRecord;
+ const toolResponse = (id: string) =>
+ ({
+ type: 'user',
+ content: [
+ { functionResponse: { id, name: 'replace', response: {} } },
+ ],
+ }) as unknown as MessageRecord;
+ const editRound = (file: string) =>
+ ({
+ type: 'gemini',
+ toolCalls: [{ name: 'replace', args: {}, resultDisplay: file }],
+ }) as unknown as MessageRecord;
+ const nextUserMsg = {
+ type: 'user',
+ content: [{ text: 'Next prompt' }],
+ } as unknown as MessageRecord;
+
+ const conversation = {
+ messages: [
+ userMsg,
+ editRound('a.ts'),
+ toolResponse('1'),
+ editRound('b.ts'),
+ toolResponse('2'),
+ nextUserMsg,
+ editRound('c.ts'),
+ ],
+ };
+
+ const result = calculateTurnStats(
+ conversation as unknown as ConversationRecord,
+ userMsg,
+ );
+ expect(result).toEqual({
+ fileCount: 2,
+ addedLines: 4,
+ removedLines: 2,
+ });
+ });
+ });
+
+ describe('isToolResponseMessage', () => {
+ const userMessage = (content: unknown) =>
+ ({ type: 'user', content }) as unknown as MessageRecord;
+ const functionResponsePart = {
+ functionResponse: { id: '1', name: 'read_file', response: {} },
+ };
+
+ it('returns true for a message with only functionResponse parts', () => {
+ expect(
+ isToolResponseMessage(
+ userMessage([functionResponsePart, functionResponsePart]),
+ ),
+ ).toBe(true);
+ });
+
+ it('returns true when functionResponse parts carry binary siblings', () => {
+ expect(
+ isToolResponseMessage(
+ userMessage([
+ functionResponsePart,
+ { inlineData: { mimeType: 'image/png', data: 'abc' } },
+ { fileData: { mimeType: 'video/mp4', fileUri: 'gs://x' } },
+ ]),
+ ),
+ ).toBe(true);
+ });
+
+ it('returns false when functionResponse parts are mixed with user text', () => {
+ expect(
+ isToolResponseMessage(
+ userMessage([functionResponsePart, { text: 'Do something else' }]),
+ ),
+ ).toBe(false);
+ });
+
+ it('returns false for plain text user messages', () => {
+ expect(isToolResponseMessage(userMessage('hello'))).toBe(false);
+ expect(isToolResponseMessage(userMessage([{ text: 'hello' }]))).toBe(
+ false,
+ );
+ });
+
+ it('returns false for empty or binary-only user messages', () => {
+ expect(isToolResponseMessage(userMessage([]))).toBe(false);
+ expect(
+ isToolResponseMessage(
+ userMessage([{ inlineData: { mimeType: 'image/png', data: 'a' } }]),
+ ),
+ ).toBe(false);
+ });
+
+ it('returns false for non-user messages', () => {
+ expect(
+ isToolResponseMessage({
+ type: 'gemini',
+ content: [functionResponsePart],
+ } as unknown as MessageRecord),
+ ).toBe(false);
+ });
});
describe('calculateRewindImpact', () => {
diff --git a/packages/cli/src/ui/utils/rewindFileOps.ts b/packages/cli/src/ui/utils/rewindFileOps.ts
index 7eaebe90ed9..29ba30a0b59 100644
--- a/packages/cli/src/ui/utils/rewindFileOps.ts
+++ b/packages/cli/src/ui/utils/rewindFileOps.ts
@@ -29,6 +29,38 @@ export interface FileChangeStats {
details?: FileChangeDetail[];
}
+function isPartWithKey(part: unknown, key: string): boolean {
+ return typeof part === 'object' && part !== null && key in part;
+}
+
+/**
+ * Determines whether a user message record is a synthetic tool response.
+ *
+ * A tool response holds at least one `functionResponse` part, and every part
+ * is either a `functionResponse` or binary data emitted alongside tool output
+ * (`inlineData` / `fileData`). Any user-authored text (e.g. a prompt or a
+ * steering hint sent with the responses) keeps the message a real user turn.
+ */
+export function isToolResponseMessage(msg: MessageRecord): boolean {
+ if (
+ msg.type !== 'user' ||
+ !Array.isArray(msg.content) ||
+ msg.content.length === 0
+ ) {
+ return false;
+ }
+ const parts: unknown[] = msg.content;
+ return (
+ parts.some((p) => isPartWithKey(p, 'functionResponse')) &&
+ parts.every(
+ (p) =>
+ isPartWithKey(p, 'functionResponse') ||
+ isPartWithKey(p, 'inlineData') ||
+ isPartWithKey(p, 'fileData'),
+ )
+ );
+}
+
/**
* Calculates file change statistics for a single turn.
* A turn is defined as the sequence of messages starting after the given user message
@@ -53,7 +85,9 @@ export function calculateTurnStats(
// Look ahead until the next user message (single turn)
for (let i = msgIndex + 1; i < conversation.messages.length; i++) {
const msg = conversation.messages[i];
- if (msg.type === 'user') break; // Stop at next user message
+ if (msg.type === 'user' && !isToolResponseMessage(msg)) {
+ break; // Stop at next user message
+ }
if (msg.type === 'gemini' && msg.toolCalls) {
for (const toolCall of msg.toolCalls) {
diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts
index 86272c02d17..39d6ee5c102 100644
--- a/packages/core/src/core/client.test.ts
+++ b/packages/core/src/core/client.test.ts
@@ -351,6 +351,51 @@ describe('Gemini Client (client.ts)', () => {
});
});
+ describe('discardTrailingUnansweredToolCallTurn', () => {
+ it('should return false without throwing when the chat is not initialized', () => {
+ const uninitializedClient = new GeminiClient(
+ mockConfig as unknown as AgentLoopContext,
+ );
+
+ expect(uninitializedClient.isInitialized()).toBe(false);
+ expect(() =>
+ uninitializedClient.discardTrailingUnansweredToolCallTurn(),
+ ).not.toThrow();
+ expect(uninitializedClient.discardTrailingUnansweredToolCallTurn()).toBe(
+ false,
+ );
+ });
+
+ it('should delegate to the chat and update telemetry when a turn is removed', () => {
+ const mockChat = {
+ discardTrailingUnansweredToolCallTurn: vi.fn().mockReturnValue(true),
+ getLastPromptTokenCount: vi.fn().mockReturnValue(0),
+ setTools: vi.fn(),
+ } as unknown as GeminiChat;
+ client['chat'] = mockChat;
+ vi.mocked(uiTelemetryService.setLastPromptTokenCount).mockClear();
+
+ expect(client.discardTrailingUnansweredToolCallTurn()).toBe(true);
+ expect(
+ mockChat.discardTrailingUnansweredToolCallTurn,
+ ).toHaveBeenCalledTimes(1);
+ expect(uiTelemetryService.setLastPromptTokenCount).toHaveBeenCalled();
+ });
+
+ it('should not update telemetry when no turn is removed', () => {
+ const mockChat = {
+ discardTrailingUnansweredToolCallTurn: vi.fn().mockReturnValue(false),
+ getLastPromptTokenCount: vi.fn().mockReturnValue(0),
+ setTools: vi.fn(),
+ } as unknown as GeminiChat;
+ client['chat'] = mockChat;
+ vi.mocked(uiTelemetryService.setLastPromptTokenCount).mockClear();
+
+ expect(client.discardTrailingUnansweredToolCallTurn()).toBe(false);
+ expect(uiTelemetryService.setLastPromptTokenCount).not.toHaveBeenCalled();
+ });
+ });
+
describe('resumeChat', () => {
it('should update telemetry token count when a chat is resumed', async () => {
const history: Content[] = [
diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts
index c39c9b815c3..01bcc1e1938 100644
--- a/packages/core/src/core/client.ts
+++ b/packages/core/src/core/client.ts
@@ -301,6 +301,22 @@ export class GeminiClient {
this.forceFullIdeContext = true;
}
+ /**
+ * Removes the trailing model turn when it only holds unanswered function
+ * calls. See {@link GeminiChat.discardTrailingUnansweredToolCallTurn}.
+ * Returns false without side effects when the chat is not initialized.
+ */
+ discardTrailingUnansweredToolCallTurn(): boolean {
+ if (!this.isInitialized()) {
+ return false;
+ }
+ const removed = this.getChat().discardTrailingUnansweredToolCallTurn();
+ if (removed) {
+ this.updateTelemetryTokenCount();
+ }
+ return removed;
+ }
+
private lastUsedModelId?: string;
async setTools(modelId?: string): Promise {
diff --git a/packages/core/src/core/geminiChat.test.ts b/packages/core/src/core/geminiChat.test.ts
index 5bda3dab871..6a333955b9d 100644
--- a/packages/core/src/core/geminiChat.test.ts
+++ b/packages/core/src/core/geminiChat.test.ts
@@ -23,10 +23,15 @@ import {
stripToolCallIdPrefixes,
type HistoryTurn,
coalesceConsecutiveRoles,
+ INTERRUPTED_RESPONSE_PLACEHOLDER,
stripThoughts,
THINKING_ONLY_NUDGE_MESSAGE,
NO_RESPONSE_TEXT_NUDGE_MESSAGE,
applyRetryNudge,
+ coalesceConsecutiveContents,
+ ensureTerminalUserTurn,
+ CONTINUE_PROMPT_TEXT,
+ INTERRUPTED_TOOL_RESPONSE_ERROR,
} from './geminiChat.js';
import {
type CompletedToolCall,
@@ -4760,6 +4765,47 @@ describe('GeminiChat', () => {
expect(stripped[0].parts![0].functionCall!.id).toBe('call_123');
expect(stripped[1].parts![0].functionResponse!.id).toBe('call_123');
});
+
+ it('should preserve functionResponse parts when stripping prefix', () => {
+ const contents: Content[] = [
+ {
+ role: 'user',
+ parts: [
+ {
+ functionResponse: {
+ id: 'my_tool__call_123',
+ name: 'my_tool',
+ response: { result: 'success' },
+ parts: [{ inlineData: { mimeType: 'image/png', data: 'abc' } }],
+ },
+ },
+ ],
+ },
+ ];
+
+ const stripped = stripToolCallIdPrefixes(contents);
+ expect(stripped[0].parts![0].functionResponse!.id).toBe('call_123');
+ expect(stripped[0].parts![0].functionResponse!.parts).toEqual([
+ { inlineData: { mimeType: 'image/png', data: 'abc' } },
+ ]);
+ });
+
+ it('should remove turns whose parts become empty after removing empty text parts', () => {
+ const contents: Content[] = [
+ {
+ role: 'user',
+ parts: [{ text: 'valid message' }],
+ },
+ {
+ role: 'user',
+ parts: [{ text: '' }],
+ },
+ ];
+
+ const stripped = stripToolCallIdPrefixes(contents);
+ expect(stripped).toHaveLength(1);
+ expect(stripped[0].parts).toEqual([{ text: 'valid message' }]);
+ });
});
describe('coalesceConsecutiveRoles', () => {
@@ -5076,4 +5122,795 @@ describe('GeminiChat', () => {
expect(result).toEqual(contents);
});
});
+
+ describe('Terminal user turn invariant enforcement and request contents integrity', () => {
+ it('should ensure request contents end with a valid user turn when history ends with a model turn after rewind and trailing turn thoughts are stripped', async () => {
+ chat.setHistory([
+ { role: 'user', parts: [{ text: 'Read package.json' }] },
+ {
+ role: 'model',
+ parts: [{ text: 'Here is the summary of package.json.' }],
+ },
+ ]);
+
+ let capturedContents: Content[] | undefined;
+ vi.mocked(mockContentGenerator.generateContentStream).mockImplementation(
+ async (params) => {
+ capturedContents = params.contents as Content[];
+ return (async function* (): AsyncGenerator {
+ yield {
+ candidates: [
+ {
+ content: {
+ role: 'model',
+ parts: [{ text: 'Response' }],
+ },
+ finishReason: 'STOP' as unknown as undefined,
+ },
+ ],
+ } as unknown as GenerateContentResponse;
+ })();
+ },
+ );
+
+ const stream = await chat.sendMessageStream(
+ { model: 'gemini-2.5-pro' },
+ [{ text: 'internal reasoning only', thought: true } as Part],
+ 'prompt-after-rewind',
+ new AbortController().signal,
+ LlmRole.MAIN,
+ );
+
+ for await (const _ of stream) {
+ // consume
+ }
+
+ expect(capturedContents).toBeDefined();
+ const lastContent = capturedContents![capturedContents!.length - 1];
+ expect(lastContent.role).toBe('user');
+ expect(lastContent.parts?.length).toBeGreaterThan(0);
+ });
+
+ it('should ensure request contents end with a valid user turn after interrupted tool turn closure', async () => {
+ chat.setHistory([
+ { role: 'user', parts: [{ text: 'Search for files' }] },
+ {
+ role: 'model',
+ parts: [
+ {
+ functionCall: {
+ id: 'call_1',
+ name: 'grep_search',
+ args: { query: 'files' },
+ },
+ thoughtSignature: 'skip_thought_signature_validator',
+ },
+ ],
+ },
+ {
+ role: 'user',
+ parts: [
+ {
+ functionResponse: {
+ id: 'call_1',
+ name: 'grep_search',
+ response: { output: 'file.txt' },
+ },
+ },
+ ],
+ },
+ ]);
+
+ let capturedContents: Content[] | undefined;
+ vi.mocked(mockContentGenerator.generateContentStream).mockImplementation(
+ async (params) => {
+ capturedContents = params.contents as Content[];
+ return (async function* (): AsyncGenerator {
+ yield {
+ candidates: [
+ {
+ content: {
+ role: 'model',
+ parts: [{ text: 'Response' }],
+ },
+ finishReason: 'STOP' as unknown as undefined,
+ },
+ ],
+ } as unknown as GenerateContentResponse;
+ })();
+ },
+ );
+
+ const stream = await chat.sendMessageStream(
+ { model: 'gemini-2.5-pro' },
+ [{ text: '' }],
+ 'prompt-after-interrupt',
+ new AbortController().signal,
+ LlmRole.MAIN,
+ );
+
+ for await (const _ of stream) {
+ // consume
+ }
+
+ expect(capturedContents).toBeDefined();
+ const lastContent = capturedContents![capturedContents!.length - 1];
+ expect(lastContent.role).toBe('user');
+ expect(lastContent.parts?.length).toBeGreaterThan(0);
+ expect(
+ capturedContents!.some((c) =>
+ c.parts?.some((p) => p.text === INTERRUPTED_RESPONSE_PLACEHOLDER),
+ ),
+ ).toBe(true);
+ });
+
+ it('should not skip recording user turn when context management is enabled and preceding turn is a model turn with matching text', async () => {
+ vi.mocked(mockConfig.isContextManagementEnabled).mockReturnValue(true);
+
+ const turns: HistoryTurn[] = [
+ { id: 'u1', content: { role: 'user', parts: [{ text: 'Say yes' }] } },
+ { id: 'm1', content: { role: 'model', parts: [{ text: 'yes' }] } },
+ ];
+ chat.setHistory(turns);
+
+ let capturedContents: Content[] | undefined;
+ vi.mocked(mockContentGenerator.generateContentStream).mockImplementation(
+ async (params) => {
+ capturedContents = params.contents as Content[];
+ return (async function* (): AsyncGenerator {
+ yield {
+ candidates: [
+ {
+ content: {
+ role: 'model',
+ parts: [{ text: 'Response' }],
+ },
+ finishReason: 'STOP' as unknown as undefined,
+ },
+ ],
+ } as unknown as GenerateContentResponse;
+ })();
+ },
+ );
+
+ const stream = await chat.sendMessageStream(
+ { model: 'gemini-2.5-pro' },
+ 'yes',
+ 'prompt-cm-dedup',
+ new AbortController().signal,
+ LlmRole.MAIN,
+ );
+
+ for await (const _ of stream) {
+ // consume
+ }
+
+ expect(capturedContents).toBeDefined();
+ const lastContent = capturedContents![capturedContents!.length - 1];
+ expect(lastContent.role).toBe('user');
+ expect(lastContent.parts).toEqual([{ text: 'yes' }]);
+ });
+
+ it('should ensure request contents end with a valid user turn when apiHistoryOverride has a thought-only trailing turn', async () => {
+ const apiHistoryOverride: Content[] = [
+ { role: 'user', parts: [{ text: 'Initial question' }] },
+ { role: 'model', parts: [{ text: 'Initial answer' }] },
+ {
+ role: 'user',
+ parts: [{ text: 'internal thought only', thought: true } as Part],
+ },
+ ];
+
+ let capturedContents: Content[] | undefined;
+ vi.mocked(mockContentGenerator.generateContentStream).mockImplementation(
+ async (params) => {
+ capturedContents = params.contents as Content[];
+ return (async function* (): AsyncGenerator {
+ yield {
+ candidates: [
+ {
+ content: {
+ role: 'model',
+ parts: [{ text: 'Response' }],
+ },
+ finishReason: 'STOP' as unknown as undefined,
+ },
+ ],
+ } as unknown as GenerateContentResponse;
+ })();
+ },
+ );
+
+ const stream = await chat.sendMessageStream(
+ { model: 'gemini-2.5-pro' },
+ 'Follow-up question',
+ 'prompt-override',
+ new AbortController().signal,
+ LlmRole.MAIN,
+ undefined,
+ apiHistoryOverride,
+ );
+
+ for await (const _ of stream) {
+ // consume
+ }
+
+ expect(capturedContents).toBeDefined();
+ const lastContent = capturedContents![capturedContents!.length - 1];
+ expect(lastContent.role).toBe('user');
+ expect(lastContent.parts?.length).toBeGreaterThan(0);
+ });
+
+ it('should synthesize functionResponse with generic_tool fallback when trailing functionCall has missing or whitespace name', async () => {
+ chat.setHistory([
+ { role: 'user', parts: [{ text: 'Run tool' }] },
+ {
+ role: 'model',
+ parts: [
+ {
+ functionCall: {
+ id: 'call_fallback',
+ name: ' ',
+ args: {},
+ },
+ },
+ ],
+ },
+ ]);
+
+ let capturedContents: Content[] | undefined;
+ vi.mocked(mockContentGenerator.generateContentStream).mockImplementation(
+ async (params) => {
+ capturedContents = params.contents as Content[];
+ return (async function* (): AsyncGenerator {
+ yield {
+ candidates: [
+ {
+ content: {
+ role: 'model',
+ parts: [{ text: 'Response' }],
+ },
+ finishReason: 'STOP' as unknown as undefined,
+ },
+ ],
+ } as unknown as GenerateContentResponse;
+ })();
+ },
+ );
+
+ const stream = await chat.sendMessageStream(
+ { model: 'gemini-2.5-pro' },
+ [{ text: '' }],
+ 'prompt-tool-fallback',
+ new AbortController().signal,
+ LlmRole.MAIN,
+ );
+
+ for await (const _ of stream) {
+ // consume
+ }
+
+ expect(capturedContents).toBeDefined();
+ expect(capturedContents![2]).toEqual({
+ role: 'user',
+ parts: [
+ {
+ functionResponse: {
+ name: 'generic_tool',
+ id: 'call_fallback',
+ response: {
+ error: INTERRUPTED_TOOL_RESPONSE_ERROR,
+ },
+ },
+ },
+ ],
+ });
+ const lastTurn = capturedContents![capturedContents!.length - 1];
+ expect(lastTurn).toEqual({
+ role: 'user',
+ parts: [{ text: CONTINUE_PROMPT_TEXT }],
+ });
+ });
+
+ it('should synthesize matching functionResponse before follow-up user text prompt when preceding turn has unclosed functionCall', async () => {
+ chat.setHistory([
+ { role: 'user', parts: [{ text: 'Please read the file' }] },
+ {
+ role: 'model',
+ parts: [
+ {
+ functionCall: {
+ id: 'call_read',
+ name: 'read_file',
+ args: { path: 'foo.ts' },
+ },
+ },
+ ],
+ },
+ ]);
+
+ let capturedContents: Content[] | undefined;
+ vi.mocked(mockContentGenerator.generateContentStream).mockImplementation(
+ async (params) => {
+ capturedContents = params.contents as Content[];
+ return (async function* (): AsyncGenerator {
+ yield {
+ candidates: [
+ {
+ content: {
+ role: 'model',
+ parts: [{ text: 'Understood, doing something else.' }],
+ },
+ finishReason: 'STOP' as unknown as undefined,
+ },
+ ],
+ } as unknown as GenerateContentResponse;
+ })();
+ },
+ );
+
+ const recordMessageSpy = vi.spyOn(
+ chat.getChatRecordingService(),
+ 'recordMessage',
+ );
+ const recordSyntheticMessageSpy = vi.spyOn(
+ chat.getChatRecordingService(),
+ 'recordSyntheticMessage',
+ );
+
+ const stream = await chat.sendMessageStream(
+ { model: 'gemini-2.5-pro' },
+ [{ text: 'Nevermind, do something else.' }],
+ 'prompt-followup',
+ new AbortController().signal,
+ LlmRole.MAIN,
+ );
+
+ for await (const _ of stream) {
+ // consume
+ }
+
+ expect(capturedContents).toBeDefined();
+ expect(capturedContents).toEqual([
+ { role: 'user', parts: [{ text: 'Please read the file' }] },
+ {
+ role: 'model',
+ parts: [
+ {
+ functionCall: {
+ id: 'call_read',
+ name: 'read_file',
+ args: { path: 'foo.ts' },
+ },
+ },
+ ],
+ },
+ {
+ role: 'user',
+ parts: [
+ {
+ functionResponse: {
+ name: 'read_file',
+ id: 'call_read',
+ response: { error: INTERRUPTED_TOOL_RESPONSE_ERROR },
+ },
+ },
+ ],
+ },
+ {
+ role: 'model',
+ parts: [{ text: INTERRUPTED_RESPONSE_PLACEHOLDER }],
+ },
+ {
+ role: 'user',
+ parts: [{ text: 'Nevermind, do something else.' }],
+ },
+ ]);
+
+ // The synthetic response and the user's prompt are recorded as separate
+ // messages; the prompt is not mixed with functionResponse parts.
+ expect(recordSyntheticMessageSpy).toHaveBeenCalledWith('user', [
+ {
+ functionResponse: {
+ name: 'read_file',
+ id: 'call_read',
+ response: { error: INTERRUPTED_TOOL_RESPONSE_ERROR },
+ },
+ },
+ ]);
+ expect(recordMessageSpy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: 'user',
+ content: [{ text: 'Nevermind, do something else.' }],
+ }),
+ );
+ });
+
+ it('should re-coalesce adjacent turns of same role when interior empty user turn is stripped', () => {
+ const input: Content[] = [
+ {
+ role: 'model',
+ parts: [{ text: 'Step 1 output' }],
+ },
+ {
+ role: 'user',
+ parts: [{ text: '' }],
+ },
+ {
+ role: 'model',
+ parts: [{ text: 'Step 2 output' }],
+ },
+ ];
+
+ const stripped = stripToolCallIdPrefixes(input);
+ expect(stripped.length).toBe(1);
+ expect(stripped[0].role).toBe('model');
+ expect(stripped[0].parts).toEqual([
+ { text: 'Step 1 output' },
+ { text: 'Step 2 output' },
+ ]);
+ });
+
+ it('should not add synthetic turns to history or the session log across retries', async () => {
+ const recordSyntheticMessageSpy = vi.spyOn(
+ chat.getChatRecordingService(),
+ 'recordSyntheticMessage',
+ );
+ const apiHistoryOverride: Content[] = [
+ { role: 'user', parts: [{ text: 'Initial question' }] },
+ { role: 'model', parts: [{ text: 'Initial answer' }] },
+ ];
+
+ const capturedContents: Content[][] = [];
+ vi.mocked(mockContentGenerator.generateContentStream)
+ // Attempt 1: thought-only response triggers a mid-stream retry.
+ .mockImplementationOnce(async (params) => {
+ capturedContents.push(params.contents as Content[]);
+ return (async function* () {
+ yield {
+ candidates: [
+ {
+ content: {
+ role: 'model',
+ parts: [{ thought: true, text: 'thinking' }],
+ },
+ finishReason: 'STOP',
+ },
+ ],
+ } as unknown as GenerateContentResponse;
+ })();
+ })
+ // Attempt 2: valid response.
+ .mockImplementationOnce(async (params) => {
+ capturedContents.push(params.contents as Content[]);
+ return (async function* () {
+ yield {
+ candidates: [
+ {
+ content: { role: 'model', parts: [{ text: 'Answer' }] },
+ finishReason: 'STOP',
+ },
+ ],
+ } as unknown as GenerateContentResponse;
+ })();
+ });
+
+ const stream = await chat.sendMessageStream(
+ { model: 'gemini-2.0-flash' },
+ 'Follow-up question',
+ 'prompt-retry-normalization',
+ new AbortController().signal,
+ LlmRole.MAIN,
+ undefined,
+ apiHistoryOverride,
+ );
+ for await (const _ of stream) {
+ // consume
+ }
+
+ expect(capturedContents).toHaveLength(2);
+ expect(capturedContents[0].at(-1)).toEqual({
+ role: 'user',
+ parts: [{ text: CONTINUE_PROMPT_TEXT }],
+ });
+ expect(recordSyntheticMessageSpy).not.toHaveBeenCalled();
+ expect(chat.getHistory()).toEqual([
+ { role: 'user', parts: [{ text: 'Follow-up question' }] },
+ { role: 'model', parts: [{ text: 'Answer' }] },
+ ]);
+ expect(apiHistoryOverride).toHaveLength(2);
+ });
+
+ it('should populate a trailing empty model turn after a tool response in place', async () => {
+ chat.setHistory([
+ { id: 'u1', content: { role: 'user', parts: [{ text: 'Search' }] } },
+ {
+ id: 'm1',
+ content: {
+ role: 'model',
+ parts: [{ functionCall: { id: 'c1', name: 'grep', args: {} } }],
+ },
+ },
+ {
+ id: 'u2',
+ content: {
+ role: 'user',
+ parts: [
+ {
+ functionResponse: {
+ id: 'c1',
+ name: 'grep',
+ response: { output: 'ok' },
+ },
+ },
+ ],
+ },
+ },
+ { id: 'm2', content: { role: 'model', parts: [] } },
+ ]);
+ const updateSpy = vi.spyOn(
+ chat.getChatRecordingService(),
+ 'updateMessagesFromHistory',
+ );
+ vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue(
+ (async function* () {
+ yield {
+ candidates: [
+ {
+ content: { role: 'model', parts: [{ text: 'Done' }] },
+ finishReason: 'STOP',
+ },
+ ],
+ } as unknown as GenerateContentResponse;
+ })(),
+ );
+
+ const stream = await chat.sendMessageStream(
+ { model: 'gemini-2.0-flash' },
+ 'Next prompt',
+ 'prompt-empty-model-turn',
+ new AbortController().signal,
+ LlmRole.MAIN,
+ );
+ for await (const _ of stream) {
+ // consume
+ }
+
+ const turns = chat.getHistoryTurns();
+ expect(turns.map((t) => t.id).slice(0, 4)).toEqual([
+ 'u1',
+ 'm1',
+ 'u2',
+ 'm2',
+ ]);
+ expect(turns[3].content.parts).toEqual([
+ { text: INTERRUPTED_RESPONSE_PLACEHOLDER },
+ ]);
+ expect(turns[4].content).toEqual({
+ role: 'user',
+ parts: [{ text: 'Next prompt' }],
+ });
+ expect(updateSpy).toHaveBeenCalledWith(
+ expect.arrayContaining([
+ expect.objectContaining({
+ id: 'm2',
+ content: {
+ role: 'model',
+ parts: [{ text: INTERRUPTED_RESPONSE_PLACEHOLDER }],
+ },
+ }),
+ ]),
+ );
+ });
+
+ it('should replace an empty trailing user turn once and keep history in sync', async () => {
+ chat.setHistory([
+ { role: 'user', parts: [{ text: 'Hi' }] },
+ { role: 'model', parts: [{ text: 'Hello' }] },
+ ]);
+ vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue(
+ (async function* () {
+ yield {
+ candidates: [
+ {
+ content: { role: 'model', parts: [{ text: 'Continuing' }] },
+ finishReason: 'STOP',
+ },
+ ],
+ } as unknown as GenerateContentResponse;
+ })(),
+ );
+
+ const stream = await chat.sendMessageStream(
+ { model: 'gemini-2.0-flash' },
+ [{ text: '' }],
+ 'prompt-empty-user-turn',
+ new AbortController().signal,
+ LlmRole.MAIN,
+ );
+ for await (const _ of stream) {
+ // consume
+ }
+
+ expect(chat.getHistory()).toEqual([
+ { role: 'user', parts: [{ text: 'Hi' }] },
+ { role: 'model', parts: [{ text: 'Hello' }] },
+ { role: 'user', parts: [{ text: CONTINUE_PROMPT_TEXT }] },
+ { role: 'model', parts: [{ text: 'Continuing' }] },
+ ]);
+ });
+ });
+
+ describe('ensureTerminalUserTurn', () => {
+ it('returns the same array when the last turn is a user turn with content', () => {
+ const contents: Content[] = [
+ { role: 'model', parts: [{ text: 'a' }] },
+ { role: 'user', parts: [{ text: 'b' }] },
+ ];
+ expect(ensureTerminalUserTurn(contents)).toBe(contents);
+ });
+
+ it('replaces the parts of a trailing user turn without content', () => {
+ const contents: Content[] = [
+ { role: 'model', parts: [{ text: 'a' }] },
+ { role: 'user', parts: [{ text: '' }] },
+ ];
+ const snapshot = structuredClone(contents);
+ expect(ensureTerminalUserTurn(contents)).toEqual([
+ { role: 'model', parts: [{ text: 'a' }] },
+ { role: 'user', parts: [{ text: CONTINUE_PROMPT_TEXT }] },
+ ]);
+ expect(contents).toEqual(snapshot);
+ });
+
+ it('appends matching responses after a trailing model function call turn', () => {
+ const contents: Content[] = [
+ { role: 'user', parts: [{ text: 'go' }] },
+ {
+ role: 'model',
+ parts: [
+ { text: 'Running tools' },
+ { functionCall: { id: 'read_file__1', name: 'read_file' } },
+ { functionCall: { id: '2', name: ' ' } },
+ ],
+ },
+ ];
+ expect(ensureTerminalUserTurn(contents).at(-1)).toEqual({
+ role: 'user',
+ parts: [
+ {
+ functionResponse: {
+ name: 'read_file',
+ id: 'read_file__1',
+ response: { error: INTERRUPTED_TOOL_RESPONSE_ERROR },
+ },
+ },
+ {
+ functionResponse: {
+ name: 'generic_tool',
+ id: '2',
+ response: { error: INTERRUPTED_TOOL_RESPONSE_ERROR },
+ },
+ },
+ ],
+ });
+ expect(contents).toHaveLength(2);
+ });
+
+ it('appends a continuation prompt after a trailing model text turn or for empty contents', () => {
+ expect(
+ ensureTerminalUserTurn([{ role: 'model', parts: [{ text: 'a' }] }]),
+ ).toEqual([
+ { role: 'model', parts: [{ text: 'a' }] },
+ { role: 'user', parts: [{ text: CONTINUE_PROMPT_TEXT }] },
+ ]);
+ expect(ensureTerminalUserTurn([])).toEqual([
+ { role: 'user', parts: [{ text: CONTINUE_PROMPT_TEXT }] },
+ ]);
+ });
+ });
+
+ describe('discardTrailingUnansweredToolCallTurn', () => {
+ it('removes only the trailing model function call turn and keeps turn ids', () => {
+ chat.setHistory([
+ { id: 'u1', content: { role: 'user', parts: [{ text: 'go' }] } },
+ {
+ id: 'm1',
+ content: {
+ role: 'model',
+ parts: [{ functionCall: { id: 'c1', name: 'a', args: {} } }],
+ },
+ },
+ {
+ id: 'u2',
+ content: {
+ role: 'user',
+ parts: [
+ { functionResponse: { id: 'c1', name: 'a', response: {} } },
+ ],
+ },
+ },
+ {
+ id: 'm2',
+ content: {
+ role: 'model',
+ parts: [{ functionCall: { id: 'c2', name: 'b', args: {} } }],
+ },
+ },
+ ]);
+
+ expect(chat.discardTrailingUnansweredToolCallTurn()).toBe(true);
+ expect(chat.getHistoryTurns().map((t) => t.id)).toEqual([
+ 'u1',
+ 'm1',
+ 'u2',
+ ]);
+ });
+
+ it('does nothing when the last turn is not a model function call turn', () => {
+ chat.setHistory([
+ { id: 'u1', content: { role: 'user', parts: [{ text: 'go' }] } },
+ { id: 'm1', content: { role: 'model', parts: [{ text: 'done' }] } },
+ ]);
+
+ expect(chat.discardTrailingUnansweredToolCallTurn()).toBe(false);
+ expect(chat.getHistoryTurns().map((t) => t.id)).toEqual(['u1', 'm1']);
+ });
+ });
+
+ describe('stripToolCallIdPrefixes field preservation', () => {
+ it('keeps every functionCall and functionResponse field when stripping prefixes', () => {
+ const result = stripToolCallIdPrefixes([
+ {
+ role: 'model',
+ parts: [
+ {
+ functionCall: {
+ id: 'tool__1',
+ name: 'tool',
+ args: { a: 1 },
+ willContinue: true,
+ } as Part['functionCall'],
+ },
+ ],
+ },
+ {
+ role: 'user',
+ parts: [
+ {
+ functionResponse: {
+ id: 'tool__1',
+ name: 'tool',
+ response: { ok: true },
+ willContinue: true,
+ },
+ },
+ ],
+ },
+ ]);
+
+ expect(result[0].parts![0].functionCall).toEqual({
+ id: '1',
+ name: 'tool',
+ args: { a: 1 },
+ willContinue: true,
+ });
+ expect(result[1].parts![0].functionResponse).toEqual({
+ id: '1',
+ name: 'tool',
+ response: { ok: true },
+ willContinue: true,
+ });
+ });
+ });
+
+ describe('coalesceConsecutiveContents', () => {
+ it('does not merge adjacent contents without a role', () => {
+ const contents: Content[] = [
+ { parts: [{ text: 'a' }] },
+ { parts: [{ text: 'b' }] },
+ ];
+ expect(coalesceConsecutiveContents(contents)).toEqual(contents);
+ });
+ });
});
diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts
index 1fe4a305275..095a5292c48 100644
--- a/packages/core/src/core/geminiChat.ts
+++ b/packages/core/src/core/geminiChat.ts
@@ -70,6 +70,7 @@ import {
} from '../availability/policyHelpers.js';
import { coreEvents } from '../utils/events.js';
import type { AgentLoopContext } from '../config/agent-loop-context.js';
+import { debugLogger } from '../utils/debugLogger.js';
export enum StreamEventType {
/** A regular content chunk from the API. */
@@ -116,6 +117,19 @@ export const SYNTHETIC_THOUGHT_SIGNATURE = 'skip_thought_signature_validator';
export const INTERRUPTED_RESPONSE_PLACEHOLDER =
'[The previous response was interrupted before it completed.]';
+/**
+ * Error payload used for synthesized function responses that close a model
+ * function call whose real response never arrived.
+ */
+export const INTERRUPTED_TOOL_RESPONSE_ERROR =
+ 'Response was lost or interrupted.';
+
+/**
+ * Text used for a synthesized user turn when the request would otherwise not
+ * end with a user turn that carries content.
+ */
+export const CONTINUE_PROMPT_TEXT = 'Please continue.';
+
/**
* Internal interface for parts that carry the magic 'callIndex' property
* used during model response consolidation.
@@ -511,12 +525,13 @@ export class GeminiChat {
let userContent = createUserContent(message);
const isOriginalFunctionResponse = isFunctionResponse(userContent);
- // A turn can end leaving history on an unanswered tool response: a stream
- // error after the response was committed, or a cancelled tool call. Close
- // it before recording a genuinely new user message, otherwise the two user
- // turns are coalesced into one and the model continues the trailing text
- // instead of answering it.
+ // If history ended on an unanswered model tool call or an unanswered tool response
+ // (e.g. cancelled tool call, interrupted stream, or user follow-up prompt), close it
+ // with dedicated synthetic turns before recording a genuinely new user message, so
+ // tool call pairing and role alternation are maintained and the user's prompt stays
+ // a turn of its own.
if (!isOriginalFunctionResponse) {
+ this.closeUnansweredToolCallsTurn();
this.closeUnansweredToolResponseTurn();
}
@@ -559,6 +574,7 @@ export class GeminiChat {
const lastTurn = history[history.length - 1];
if (
!lastTurn ||
+ lastTurn.content.role !== 'user' ||
partListUnionToString(lastTurn.content.parts || []) !==
userMessageContent
) {
@@ -630,6 +646,7 @@ export class GeminiChat {
const lastTurn = history[history.length - 1];
if (
!lastTurn ||
+ lastTurn.content.role !== 'user' ||
partListUnionToString(lastTurn.content.parts || []) !==
partListUnionToString(userContent.parts || [])
) {
@@ -638,6 +655,10 @@ export class GeminiChat {
}
}
+ // Durable history repair runs once per send, outside the retry loop, so
+ // retried attempts never append duplicate synthetic turns.
+ this.replaceEmptyTrailingUserTurn();
+
const requestHistory = this.getHistoryTurns(true);
const streamWithRetries = async function* (
@@ -824,26 +845,92 @@ export class GeminiChat {
return streamWithRetries.call(this);
}
+ /**
+ * Appends a dedicated synthetic user turn with matching functionResponse
+ * parts when history ends with an unclosed model functionCall. The
+ * subsequent user prompt is then recorded as a clean turn of its own (after
+ * `closeUnansweredToolResponseTurn` adds the closing model turn).
+ */
+ private closeUnansweredToolCallsTurn(): void {
+ const turns = this.agentHistory.get();
+ const last = turns[turns.length - 1];
+ if (last?.content.role !== 'model') {
+ return;
+ }
+ const missingResponses = buildInterruptedToolResponseParts(
+ last.content.parts ?? [],
+ );
+ if (missingResponses.length === 0) {
+ return;
+ }
+ const id = this.chatRecordingService.recordSyntheticMessage(
+ 'user',
+ missingResponses,
+ );
+ this.agentHistory.push({
+ id,
+ content: { role: 'user', parts: missingResponses },
+ });
+ }
+
+ /**
+ * Replaces the parts of a trailing user turn that carries no content (e.g.
+ * only empty text parts) with a continuation prompt, keeping the in-memory
+ * history and the session log in sync. Runs once per send so that the
+ * persisted history never keeps an empty user turn between model turns.
+ */
+ private replaceEmptyTrailingUserTurn(): void {
+ const turns = this.agentHistory.get();
+ const last = turns[turns.length - 1];
+ if (last?.content.role !== 'user' || hasNonEmptyParts(last.content)) {
+ return;
+ }
+ this.agentHistory.set([
+ ...turns.slice(0, -1),
+ {
+ id: last.id,
+ content: { ...last.content, parts: [{ text: CONTINUE_PROMPT_TEXT }] },
+ },
+ ]);
+ this.chatRecordingService.updateMessagesFromHistory(
+ this.agentHistory.get(),
+ );
+ }
+
/**
* Appends a closing model turn when history ends with an unanswered tool
* response, so the next user message stays a turn of its own.
*/
private closeUnansweredToolResponseTurn(): void {
const turns = this.agentHistory.get();
- const last = turns[turns.length - 1];
+ let targetTurn = turns[turns.length - 1];
+ let hadEmptyTrailingModelTurn = false;
+ if (
+ targetTurn?.content.role === 'model' &&
+ (!targetTurn.content.parts || targetTurn.content.parts.length === 0)
+ ) {
+ hadEmptyTrailingModelTurn = true;
+ targetTurn = turns[turns.length - 2];
+ }
if (
- last?.content.role !== 'user' ||
- !last.content.parts?.some((part) => !!part.functionResponse)
+ targetTurn?.content.role !== 'user' ||
+ !targetTurn.content.parts?.some((part) => !!part.functionResponse)
) {
return;
}
- this.agentHistory.push({
- id: randomUUID(),
- content: {
- role: 'model',
- parts: [{ text: INTERRUPTED_RESPONSE_PLACEHOLDER }],
- },
- });
+ if (hadEmptyTrailingModelTurn) {
+ const lastTurn = turns[turns.length - 1];
+ lastTurn.content.parts = [{ text: INTERRUPTED_RESPONSE_PLACEHOLDER }];
+ this.chatRecordingService.updateMessagesFromHistory(turns);
+ } else {
+ this.agentHistory.push({
+ id: randomUUID(),
+ content: {
+ role: 'model',
+ parts: [{ text: INTERRUPTED_RESPONSE_PLACEHOLDER }],
+ },
+ });
+ }
}
private extractBinaryInjections(
@@ -1072,12 +1159,23 @@ export class GeminiChat {
this.tools = await this.onModelChanged(modelToUse);
}
+ // Normalize the outbound payload so it always ends with a user turn that
+ // carries content. This is a pure transformation: retries, per-call
+ // history overrides and hook-modified contents never leak into the
+ // persistent history or the session log.
+ const normalizedContents = ensureTerminalUserTurn(contentsToUse);
+ if (normalizedContents !== contentsToUse) {
+ debugLogger.warn(
+ 'Final contents do not end with a valid user turn. Normalizing contents to satisfy Gemini API invariant.',
+ );
+ }
+
// Track final request parameters for AfterModel hooks
lastModelToUse = modelToUse;
lastConfig = config;
- lastContentsToUse = contentsToUse;
+ lastContentsToUse = normalizedContents;
- const finalContents = stripToolCallIdPrefixes(contentsToUse);
+ const finalContents = stripToolCallIdPrefixes(normalizedContents);
return this.context.config.getContentGenerator().generateContentStream(
{
@@ -1217,6 +1315,33 @@ export class GeminiChat {
ensureStableToolIds(this.agentHistory.get() as HistoryTurn[]);
}
+ /**
+ * Removes the trailing model turn when it holds function calls that were
+ * never answered (e.g. every call in the batch was declined). Earlier
+ * completed tool rounds and the originating user prompt are kept, and the
+ * durable IDs of the remaining turns are preserved.
+ *
+ * @returns true when a turn was removed.
+ */
+ discardTrailingUnansweredToolCallTurn(): boolean {
+ const turns = this.agentHistory.get();
+ const last = turns[turns.length - 1];
+ if (
+ last?.content.role !== 'model' ||
+ !last.content.parts?.some((part) => !!part.functionCall)
+ ) {
+ return false;
+ }
+ this.agentHistory.rollback(turns.length - 1);
+ this.lastPromptTokenCount = estimateTokenCountSync(
+ this.agentHistory.flatMap((c) => c.content.parts || []),
+ );
+ this.chatRecordingService.updateMessagesFromHistory(
+ this.agentHistory.get(),
+ );
+ return true;
+ }
+
setHistory(history: ReadonlyArray): void {
const wrappedHistory: HistoryTurn[] = history.map((item) => {
if ('id' in item && 'content' in item) {
@@ -1753,61 +1878,148 @@ export function isInvalidArgumentError(errorMessage: string): boolean {
return errorMessage.includes('Request contains an invalid argument');
}
+/**
+ * Returns true for a part that only holds an empty `text` value (optionally
+ * alongside bookkeeping keys) and no other payload.
+ */
+function isEmptyTextPart(part: Part): boolean {
+ if (part.text !== '') {
+ return false;
+ }
+ return !Object.keys(part).some(
+ (key) => key !== 'text' && key !== 'thought' && key !== 'callIndex',
+ );
+}
+
+/**
+ * Returns true when the content has at least one part that survives
+ * `stripToolCallIdPrefixes` (i.e. it is not an empty text part).
+ */
+function hasNonEmptyParts(content: Content): boolean {
+ return (content.parts ?? []).some((part) => !isEmptyTextPart(part));
+}
+
+/**
+ * Builds functionResponse parts that close every functionCall in `parts`
+ * whose real response was lost or interrupted.
+ */
+export function buildInterruptedToolResponseParts(
+ parts: readonly Part[],
+): Part[] {
+ return parts
+ .filter((part) => !!part?.functionCall)
+ .map((part) => ({
+ functionResponse: {
+ name: part.functionCall!.name?.trim() || 'generic_tool',
+ id: part.functionCall!.id,
+ response: { error: INTERRUPTED_TOOL_RESPONSE_ERROR },
+ },
+ }));
+}
+
+/**
+ * Ensures the request contents end with a user turn that carries content, as
+ * required by the Gemini API. Pure: returns the same array when no change is
+ * needed, otherwise a new array; the input is never mutated.
+ *
+ * - A trailing user turn without content gets a continuation prompt.
+ * - A trailing model turn with function calls gets matching responses.
+ * - Any other trailing turn (or no turn at all) gets a continuation prompt.
+ */
+export function ensureTerminalUserTurn(contents: Content[]): Content[] {
+ const last = contents.at(-1);
+ if (last?.role === 'user' && hasNonEmptyParts(last)) {
+ return contents;
+ }
+ if (last?.role === 'user') {
+ return [
+ ...contents.slice(0, -1),
+ { ...last, parts: [{ text: CONTINUE_PROMPT_TEXT }] },
+ ];
+ }
+ const missingResponses = buildInterruptedToolResponseParts(last?.parts ?? []);
+ const parts =
+ missingResponses.length > 0
+ ? missingResponses
+ : [{ text: CONTINUE_PROMPT_TEXT }];
+ return [...contents, { role: 'user', parts }];
+}
+
export function stripToolCallIdPrefixes(contents: Content[]): Content[] {
- return contents.map((content) => {
- const parts = (content.parts || [])
- .map((part) => {
- const newPart = { ...part };
- if (newPart.functionCall) {
- const fc = newPart.functionCall;
- const name = fc.name?.trim() || 'generic_tool';
- if (fc.id && fc.id.startsWith(`${name}__`)) {
- newPart.functionCall = {
- name: fc.name,
- args: fc.args,
- id: fc.id.substring(name.length + 2),
- };
+ const stripped = contents
+ .map((content) => {
+ const parts = (content.parts || [])
+ .map((part) => {
+ const newPart = { ...part };
+ if (newPart.functionCall) {
+ const fc = newPart.functionCall;
+ const name = fc.name?.trim() || 'generic_tool';
+ if (fc.id && fc.id.startsWith(`${name}__`)) {
+ newPart.functionCall = {
+ ...fc,
+ id: fc.id.substring(name.length + 2),
+ };
+ }
}
- }
- if (newPart.functionResponse) {
- const fr = newPart.functionResponse;
- const name = fr.name?.trim() || 'generic_tool';
- if (fr.id && fr.id.startsWith(`${name}__`)) {
- newPart.functionResponse = {
- name: fr.name,
- response: fr.response,
- id: fr.id.substring(name.length + 2),
- };
+ if (newPart.functionResponse) {
+ const fr = newPart.functionResponse;
+ const name = fr.name?.trim() || 'generic_tool';
+ if (fr.id && fr.id.startsWith(`${name}__`)) {
+ newPart.functionResponse = {
+ // History parts are plain JSON objects, not class instances.
+ // eslint-disable-next-line @typescript-eslint/no-misused-spread
+ ...fr,
+ id: fr.id.substring(name.length + 2),
+ };
+ }
}
- }
- // If there's an empty text key alongside other active properties, remove the empty text key
- // so it doesn't trigger "contains empty parts" validation errors on the Gemini API.
- const hasOtherKeys = Object.keys(newPart).some(
- (key) => key !== 'text' && key !== 'thought' && key !== 'callIndex',
- );
- if (newPart.text !== undefined && newPart.text === '' && hasOtherKeys) {
- delete newPart.text;
- }
+ // If there's an empty text key alongside other active properties, remove the empty text key
+ // so it doesn't trigger "contains empty parts" validation errors on the Gemini API.
+ const hasOtherKeys = Object.keys(newPart).some(
+ (key) => key !== 'text' && key !== 'thought' && key !== 'callIndex',
+ );
+ if (
+ newPart.text !== undefined &&
+ newPart.text === '' &&
+ hasOtherKeys
+ ) {
+ delete newPart.text;
+ }
- return newPart;
- })
- .filter((part) => {
+ return newPart;
+ })
// Filter out truly empty parts that have only text: '' and no payload
- const hasOtherKeys = Object.keys(part).some(
- (key) => key !== 'text' && key !== 'thought' && key !== 'callIndex',
- );
- if (part.text !== undefined && part.text === '' && !hasOtherKeys) {
- return false;
- }
- return true;
- });
+ .filter((part) => !isEmptyTextPart(part));
- return {
- ...content,
- parts,
- };
- });
+ return {
+ ...content,
+ parts,
+ };
+ })
+ .filter((content) => content.parts.length > 0);
+
+ return coalesceConsecutiveContents(stripped);
+}
+
+export function coalesceConsecutiveContents(contents: Content[]): Content[] {
+ const result: Content[] = [];
+ for (const item of contents) {
+ const lastIdx = result.length - 1;
+ const last = result[lastIdx];
+ if (last && last.role && last.role === item.role) {
+ const hasParts = last.parts || item.parts;
+ result[lastIdx] = {
+ ...last,
+ parts: hasParts
+ ? [...(last.parts || []), ...(item.parts || [])]
+ : undefined,
+ };
+ } else {
+ result.push({ ...item });
+ }
+ }
+ return result;
}
export function coalesceConsecutiveRoles(
diff --git a/packages/core/src/utils/historyHardening.test.ts b/packages/core/src/utils/historyHardening.test.ts
index 90577e1e4b2..49bef68da05 100644
--- a/packages/core/src/utils/historyHardening.test.ts
+++ b/packages/core/src/utils/historyHardening.test.ts
@@ -578,4 +578,45 @@ describe('scrubHistory', () => {
{ text: 'World' },
]);
});
+
+ it('should preserve nested parts array within functionResponse', () => {
+ const history: HistoryTurn[] = [
+ {
+ id: '1',
+ content: {
+ role: 'user',
+ parts: [
+ {
+ functionResponse: {
+ name: 'readFile',
+ id: 'call-1',
+ response: { mimeType: 'image/png' },
+ parts: [
+ {
+ inlineData: {
+ mimeType: 'image/png',
+ data: 'base64data',
+ },
+ },
+ ],
+ } as unknown as Part['functionResponse'],
+ } as unknown as Part,
+ ],
+ },
+ },
+ ];
+
+ const scrubbed = scrubHistory(history);
+ expect(scrubbed.length).toBe(1);
+ const fr = scrubbed[0].content.parts![0].functionResponse;
+ expect(fr).toBeDefined();
+ expect((fr as unknown as Record)['parts']).toEqual([
+ {
+ inlineData: {
+ mimeType: 'image/png',
+ data: 'base64data',
+ },
+ },
+ ]);
+ });
});
diff --git a/packages/core/src/utils/historyHardening.ts b/packages/core/src/utils/historyHardening.ts
index 8a4e8a2f5cc..d58274161f6 100644
--- a/packages/core/src/utils/historyHardening.ts
+++ b/packages/core/src/utils/historyHardening.ts
@@ -485,6 +485,12 @@ export function scrubPart(part: Part): Part {
if (part.functionResponse.id) {
scrubbedResp['id'] = part.functionResponse.id;
}
+ if (
+ 'parts' in part.functionResponse &&
+ Array.isArray(part.functionResponse.parts)
+ ) {
+ scrubbedResp['parts'] = part.functionResponse.parts.map(scrubPart);
+ }
scrubbed['functionResponse'] = scrubbedResp;
}
if ('fileData' in part) {