Repository navigation
Fix/29365 duplicate tool responses #29400
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1374,7 +1374,7 @@ describe('ChatRecordingService', () => { | |
| }); | ||
| }); | ||
|
|
||
| it('should preserve multi-modal sibling parts during sync', async () => { | ||
| it('should sync only the matching function response during sync', async () => { | ||
| await chatRecordingService.initialize(); | ||
| const modelMsgId = chatRecordingService.recordMessage({ | ||
| type: 'gemini', | ||
|
|
@@ -1440,12 +1440,10 @@ describe('ChatRecordingService', () => { | |
| type: 'gemini'; | ||
| }; | ||
| const result = lastMsg.toolCalls![0].result as Part[]; | ||
| expect(result).toHaveLength(2); | ||
| expect(result).toHaveLength(1); | ||
| expect(result[0].functionResponse!.response).toEqual({ | ||
| output: maskedSnippet, | ||
| }); | ||
| expect(result[1].inlineData).toBeDefined(); | ||
| expect(result[1].inlineData!.mimeType).toBe('image/png'); | ||
| }); | ||
|
Comment on lines
+1443
to
1447
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Since we should preserve multi-modal sibling parts (like expect(result).toHaveLength(2);
expect(result[0].functionResponse!.response).toEqual({
output: maskedSnippet,
});
expect(result[1].inlineData).toBeDefined();
expect(result[1].inlineData!.mimeType).toBe('image/png');
}); |
||
|
|
||
| it('should handle parts appearing BEFORE the functionResponse in a content block', async () => { | ||
|
|
@@ -1503,9 +1501,78 @@ describe('ChatRecordingService', () => { | |
| type: 'gemini'; | ||
| }; | ||
| const result = lastMsg.toolCalls![0].result as Part[]; | ||
| expect(result).toHaveLength(2); | ||
| expect(result[0].text).toBe('Prefix metadata or text'); | ||
| expect(result[1].functionResponse!.id).toBe(callId); | ||
| expect(result).toEqual([ | ||
| { | ||
| functionResponse: { | ||
| name: 'read_file', | ||
| id: callId, | ||
| response: { output: 'file content' }, | ||
| }, | ||
| }, | ||
| ]); | ||
| }); | ||
|
|
||
| it('keeps parallel tool results isolated to their matching call', async () => { | ||
| const modelMsgId = chatRecordingService.recordMessage({ | ||
| type: 'gemini', | ||
| content: '', | ||
| model: 'gemini-pro', | ||
| }); | ||
| const calls = ['call-a', 'call-b'].map((id) => ({ | ||
| id, | ||
| name: 'read_file', | ||
| args: { path: `${id}.txt` }, | ||
| result: [], | ||
| status: CoreToolCallStatus.Success, | ||
| timestamp: new Date().toISOString(), | ||
| })); | ||
| chatRecordingService.recordToolCalls('gemini-pro', calls); | ||
|
|
||
| chatRecordingService.updateMessagesFromHistory([ | ||
| { id: modelMsgId, content: { role: 'model', parts: [] } }, | ||
| { | ||
| id: 'responses', | ||
| content: { | ||
| role: 'user', | ||
| parts: calls.map((call) => ({ | ||
| functionResponse: { | ||
| id: call.id, | ||
| name: call.name, | ||
| response: { output: call.id }, | ||
| }, | ||
| })), | ||
| }, | ||
| }, | ||
| ]); | ||
|
|
||
| const conversation = (await loadConversationRecord( | ||
| chatRecordingService.getConversationFilePath()!, | ||
| )) as ConversationRecord; | ||
| const toolCalls = ( | ||
| conversation.messages[0] as MessageRecord & { | ||
| type: 'gemini'; | ||
| } | ||
| ).toolCalls!; | ||
| expect(toolCalls.map((call) => call.result)).toEqual([ | ||
| [ | ||
| { | ||
| functionResponse: { | ||
| id: 'call-a', | ||
| name: 'read_file', | ||
| response: { output: 'call-a' }, | ||
| }, | ||
| }, | ||
| ], | ||
| [ | ||
| { | ||
| functionResponse: { | ||
| id: 'call-b', | ||
| name: 'read_file', | ||
| response: { output: 'call-b' }, | ||
| }, | ||
| }, | ||
| ], | ||
| ]); | ||
| }); | ||
|
|
||
| it('should not write to disk when no tool calls match', async () => { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1003,13 +1003,17 @@ export class ChatRecordingService { | |
| if (geminiMsg && geminiMsg.type === 'gemini') { | ||
| const tc = geminiMsg.toolCalls!.find((tc) => tc.id === callId); | ||
| if (tc) { | ||
| // If the history version is different (e.g. masked), sync it into the record | ||
| // We sync the entire parts array of the user turn to ensure sibling parts are preserved | ||
| // If the history version is different (e.g. masked), sync it into the record. | ||
| // Do not copy responses for sibling calls: doing so causes each | ||
| // ToolCallRecord to replay every response in a parallel turn. | ||
| const matchingPart = (turn.content.parts || []).find( | ||
| (candidate) => candidate.functionResponse?.id === callId, | ||
| ); | ||
| if ( | ||
| JSON.stringify(tc.result) !== | ||
| JSON.stringify(turn.content.parts) | ||
| matchingPart && | ||
| JSON.stringify(tc.result) !== JSON.stringify([matchingPart]) | ||
| ) { | ||
| tc.result = turn.content.parts || []; | ||
| tc.result = [matchingPart]; | ||
|
Comment on lines
+1009
to
+1016
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. By using Instead, we should filter the parts to keep both the matching const matchingParts = filterToolParts(turn.content.parts || [], callId);
if (
matchingParts.length > 0 &&
JSON.stringify(tc.result) !== JSON.stringify(matchingParts)
) {
tc.result = matchingParts;
updated = true;
}References
|
||
| updated = true; | ||
| } | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -111,6 +111,19 @@ export function convertSessionToClientHistory( | |
| messages: ConversationRecord['messages'], | ||
| ): HistoryTurn[] { | ||
| const clientHistory: HistoryTurn[] = []; | ||
| // Modern recordings persist a tool response both on the ToolCallRecord and as | ||
| // a durable user turn. Prefer the latter when it exists; regenerating the | ||
| // former would answer the same function call twice on resume. | ||
| const recordedResponseIds = new Set( | ||
| messages.flatMap((message) => | ||
| message.type === 'user' | ||
| ? ensurePartArray(message.content).flatMap((part) => | ||
| part.functionResponse?.id ? [part.functionResponse.id] : [], | ||
| ) | ||
| : [], | ||
| ), | ||
| ); | ||
| const emittedResponseIds = new Set<string>(); | ||
|
|
||
| for (const msg of messages) { | ||
| if (msg.type === 'info' || msg.type === 'error' || msg.type === 'warning') { | ||
|
|
@@ -124,13 +137,26 @@ export function convertSessionToClientHistory( | |
| continue; | ||
| } | ||
|
|
||
| clientHistory.push({ | ||
| id: msg.id, | ||
| content: { | ||
| role: 'user', | ||
| parts: ensurePartArray(msg.content), | ||
| }, | ||
| // Some already-resumed sessions contain synthetic response turns in | ||
| // addition to the original durable response turn. Retain the first | ||
| // response for a call ID so those sessions can be recovered as well. | ||
| const parts = ensurePartArray(msg.content).filter((part) => { | ||
| const responseId = part.functionResponse?.id; | ||
| if (!responseId) return true; | ||
| if (emittedResponseIds.has(responseId)) return false; | ||
| emittedResponseIds.add(responseId); | ||
| return true; | ||
| }); | ||
|
|
||
| if (parts.length > 0) { | ||
| clientHistory.push({ | ||
| id: msg.id, | ||
| content: { | ||
| role: 'user', | ||
| parts, | ||
| }, | ||
| }); | ||
| } | ||
| } else if (msg.type === 'gemini') { | ||
| const modelParts: Part[] = []; | ||
|
|
||
|
|
@@ -186,7 +212,7 @@ export function convertSessionToClientHistory( | |
| if (msg.toolCalls && msg.toolCalls.length > 0) { | ||
| const functionResponseParts: Part[] = []; | ||
| for (const toolCall of msg.toolCalls) { | ||
| if (toolCall.result) { | ||
| if (toolCall.result && !recordedResponseIds.has(toolCall.id)) { | ||
| let responseData: Part; | ||
|
|
||
| if (typeof toolCall.result === 'string') { | ||
|
|
@@ -200,7 +226,14 @@ export function convertSessionToClientHistory( | |
| }, | ||
| }; | ||
| } else if (Array.isArray(toolCall.result)) { | ||
| functionResponseParts.push(...ensurePartArray(toolCall.result)); | ||
| // A result belongs only to its matching call. In particular, | ||
| // do not replay sibling responses that may have been copied | ||
| // into this result by an older session checkpoint. | ||
| functionResponseParts.push( | ||
| ...ensurePartArray(toolCall.result).filter( | ||
| (part) => part.functionResponse?.id === toolCall.id, | ||
| ), | ||
| ); | ||
|
Comment on lines
+232
to
+236
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Filtering the We should update the filter to also preserve any non- functionResponseParts.push(
...filterToolParts(ensurePartArray(toolCall.result), toolCall.id),
);References
|
||
| continue; | ||
| } else { | ||
| responseData = toolCall.result; | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Rename the test back to reflect that it is verifying the preservation of multi-modal sibling parts.