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) {