mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-06-16 14:27:24 -07:00
further changes
This commit is contained in:
@@ -20,7 +20,7 @@ describe('Context Management Fidelity E2E', () => {
|
||||
|
||||
afterEach(async () => await rig.cleanup());
|
||||
|
||||
it('should reproduce the exact context working buffer on resume', { timeout: 30000 }, async () => {
|
||||
it('should reproduce the exact context working buffer on resume', async () => {
|
||||
// Mock responses to trigger GC (summarization)
|
||||
const snapshotResponse: FakeResponse = {
|
||||
method: 'generateContent',
|
||||
@@ -205,35 +205,28 @@ describe('Context Management Fidelity E2E', () => {
|
||||
contextBeforeExit!.length,
|
||||
);
|
||||
|
||||
try {
|
||||
for (let i = 0; i < contextBeforeExit!.length; i++) {
|
||||
expect(contextAfterResume![i].id).toBe(contextBeforeExit![i].id);
|
||||
expect(contextAfterResume![i].content).toEqual(
|
||||
contextBeforeExit![i].content,
|
||||
);
|
||||
}
|
||||
|
||||
// Most importantly, synthetic IDs (like summaries) must be stable.
|
||||
const syntheticTurns = contextBeforeExit!.filter(
|
||||
(t: HistoryTurn) => t.id && t.id.length === 32,
|
||||
); // deriveStableId produces 32-char hex
|
||||
expect(syntheticTurns.length).toBeGreaterThan(0);
|
||||
|
||||
const syntheticTurnsAfter = contextAfterResume!.filter(
|
||||
(t: HistoryTurn) => t.id && t.id.length === 32,
|
||||
for (let i = 0; i < contextBeforeExit!.length; i++) {
|
||||
expect(contextAfterResume![i].id).toBe(contextBeforeExit![i].id);
|
||||
expect(contextAfterResume![i].content).toEqual(
|
||||
contextBeforeExit![i].content,
|
||||
);
|
||||
expect(syntheticTurnsAfter.length).toBeGreaterThanOrEqual(
|
||||
syntheticTurns.length,
|
||||
);
|
||||
|
||||
// Check if the first synthetic turn is identical
|
||||
expect(syntheticTurnsAfter[0].id).toBe(syntheticTurns[0].id);
|
||||
expect(syntheticTurnsAfter[0].content).toEqual(syntheticTurns[0].content);
|
||||
} catch (e) {
|
||||
console.error('--- DEBUG LOG ---');
|
||||
console.error(fs.readFileSync(commonEnv.GEMINI_DEBUG_LOG_FILE, 'utf-8'));
|
||||
console.error('-----------------');
|
||||
throw e;
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
// Most importantly, synthetic IDs (like summaries) must be stable.
|
||||
const syntheticTurns = contextBeforeExit!.filter(
|
||||
(t: HistoryTurn) => t.id && t.id.length === 32,
|
||||
); // deriveStableId produces 32-char hex
|
||||
expect(syntheticTurns.length).toBeGreaterThan(0);
|
||||
|
||||
const syntheticTurnsAfter = contextAfterResume!.filter(
|
||||
(t: HistoryTurn) => t.id && t.id.length === 32,
|
||||
);
|
||||
expect(syntheticTurnsAfter.length).toBeGreaterThanOrEqual(
|
||||
syntheticTurns.length,
|
||||
);
|
||||
|
||||
// Check if the first synthetic turn is identical
|
||||
expect(syntheticTurnsAfter[0].id).toBe(syntheticTurns[0].id);
|
||||
expect(syntheticTurnsAfter[0].content).toEqual(syntheticTurns[0].content);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,10 +20,7 @@ describe('Context Management Resume E2E', () => {
|
||||
|
||||
afterEach(async () => await rig.cleanup());
|
||||
|
||||
it(
|
||||
'should preserve and utilize GC snapshot boundaries when resuming a session',
|
||||
{ timeout: 30000 },
|
||||
async () => {
|
||||
it('should preserve and utilize GC snapshot boundaries when resuming a session', async () => {
|
||||
const snapshotResponse: FakeResponse = {
|
||||
method: 'generateContent',
|
||||
response: {
|
||||
@@ -148,5 +145,5 @@ describe('Context Management Resume E2E', () => {
|
||||
const traces = fs.readFileSync(traceLog, 'utf-8');
|
||||
expect(traces).toContain('Hitting Synchronous Pressure Barrier');
|
||||
expect(traces).toContain('GC Triggered.');
|
||||
}, 30000);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,7 +14,6 @@ import { type HistoryItem } from '../types.js';
|
||||
import { convertSessionToHistoryFormats } from '../hooks/useSessionBrowser.js';
|
||||
import { revertFileChanges } from '../utils/rewindFileOps.js';
|
||||
import { RewindOutcome } from '../components/RewindConfirmation.js';
|
||||
import type { Content } from '@google/genai';
|
||||
import {
|
||||
checkExhaustive,
|
||||
coreEvents,
|
||||
@@ -58,7 +57,7 @@ async function rewindConversation(
|
||||
const { uiHistory } = convertSessionToHistoryFormats(conversation.messages);
|
||||
const clientHistory = convertSessionToClientHistory(conversation.messages);
|
||||
|
||||
client.setHistory(clientHistory as Content[]);
|
||||
client.setHistory(clientHistory);
|
||||
|
||||
// Reset context manager as we are rewinding history
|
||||
await context.services.agentContext?.config
|
||||
|
||||
@@ -194,14 +194,16 @@ describe('convertSessionToHistoryFormats', () => {
|
||||
|
||||
const clientHistory = convertSessionToClientHistory(messages);
|
||||
expect(clientHistory).toHaveLength(2);
|
||||
expect(clientHistory[0]).toEqual({
|
||||
role: 'user',
|
||||
parts: [{ text: 'Hello' }],
|
||||
});
|
||||
expect(clientHistory[1]).toEqual({
|
||||
role: 'model',
|
||||
parts: [{ text: 'Hi there' }],
|
||||
});
|
||||
expect(clientHistory.map((h) => h.content)).toEqual([
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: 'Hello' }],
|
||||
},
|
||||
{
|
||||
role: 'model',
|
||||
parts: [{ text: 'Hi there' }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should convert thinking tokens (thoughts) to thinking history items', () => {
|
||||
@@ -254,10 +256,12 @@ describe('convertSessionToHistoryFormats', () => {
|
||||
|
||||
const clientHistory = convertSessionToClientHistory(messages);
|
||||
expect(clientHistory).toHaveLength(1);
|
||||
expect(clientHistory[0]).toEqual({
|
||||
role: 'user',
|
||||
parts: [{ text: 'Expanded content' }],
|
||||
});
|
||||
expect(clientHistory.map((h) => h.content)).toEqual([
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: 'Expanded content' }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should filter out slash commands from client history but keep in UI', () => {
|
||||
@@ -316,33 +320,35 @@ describe('convertSessionToHistoryFormats', () => {
|
||||
|
||||
const clientHistory = convertSessionToClientHistory(messages);
|
||||
expect(clientHistory).toHaveLength(3); // User, Model (call), User (response)
|
||||
expect(clientHistory[0]).toEqual({
|
||||
role: 'user',
|
||||
parts: [{ text: 'What time is it?' }],
|
||||
});
|
||||
expect(clientHistory[1]).toEqual({
|
||||
role: 'model',
|
||||
parts: [
|
||||
{
|
||||
functionCall: {
|
||||
name: 'get_time',
|
||||
args: {},
|
||||
id: 'call_1',
|
||||
expect(clientHistory.map((h) => h.content)).toEqual([
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: 'What time is it?' }],
|
||||
},
|
||||
{
|
||||
role: 'model',
|
||||
parts: [
|
||||
{
|
||||
functionCall: {
|
||||
name: 'get_time',
|
||||
args: {},
|
||||
id: 'call_1',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(clientHistory[2]).toEqual({
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: 'call_1',
|
||||
name: 'get_time',
|
||||
response: { output: '12:00' },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
name: 'get_time',
|
||||
response: { output: '12:00' },
|
||||
id: 'call_1',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,8 +12,11 @@ import {
|
||||
convertSessionToClientHistory,
|
||||
uiTelemetryService,
|
||||
loadConversationRecord,
|
||||
type Config,
|
||||
type ResumedSessionData,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type {
|
||||
HistoryTurn,
|
||||
Config,
|
||||
ResumedSessionData,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
convertSessionToHistoryFormats,
|
||||
@@ -22,11 +25,15 @@ import {
|
||||
|
||||
export { convertSessionToHistoryFormats };
|
||||
|
||||
import type { Part } from '@google/genai';
|
||||
|
||||
export const useSessionBrowser = (
|
||||
config: Config,
|
||||
onLoadHistory: (
|
||||
uiHistory: HistoryItemWithoutId[],
|
||||
clientHistory: any[],
|
||||
clientHistory: Array<
|
||||
{ role: 'user' | 'model'; parts: Part[] } | HistoryTurn
|
||||
>,
|
||||
resumedSessionData: ResumedSessionData,
|
||||
) => Promise<void>,
|
||||
) => {
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
ResumedSessionData,
|
||||
ConversationRecord,
|
||||
MessageRecord,
|
||||
HistoryTurn,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
|
||||
import type { HistoryItemWithoutId } from '../types.js';
|
||||
@@ -527,10 +528,12 @@ describe('useSessionResume', () => {
|
||||
|
||||
// Should only have the non-slash-command message
|
||||
expect(clientHistory).toHaveLength(1);
|
||||
expect(clientHistory[0]).toEqual({
|
||||
role: 'user',
|
||||
parts: [{ text: 'Regular message' }],
|
||||
});
|
||||
expect(clientHistory.map((h: HistoryTurn) => h.content)).toEqual([
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: 'Regular message' }],
|
||||
},
|
||||
]);
|
||||
|
||||
// But UI history should have both
|
||||
expect(mockHistoryManager.addItem).toHaveBeenCalledTimes(2);
|
||||
|
||||
@@ -7,13 +7,17 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
coreEvents,
|
||||
type Config,
|
||||
type ResumedSessionData,
|
||||
convertSessionToClientHistory,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type {
|
||||
HistoryTurn,
|
||||
Config,
|
||||
ResumedSessionData,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { HistoryItemWithoutId } from '../types.js';
|
||||
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
|
||||
import { convertSessionToHistoryFormats } from './useSessionBrowser.js';
|
||||
import type { Part } from '@google/genai';
|
||||
|
||||
interface UseSessionResumeParams {
|
||||
config: Config;
|
||||
@@ -53,7 +57,9 @@ export function useSessionResume({
|
||||
const loadHistoryForResume = useCallback(
|
||||
async (
|
||||
uiHistory: HistoryItemWithoutId[],
|
||||
clientHistory: any[],
|
||||
clientHistory: Array<
|
||||
{ role: 'user' | 'model'; parts: Part[] } | HistoryTurn
|
||||
>,
|
||||
resumedData: ResumedSessionData,
|
||||
) => {
|
||||
// Wait for the client.
|
||||
|
||||
@@ -196,7 +196,7 @@ describe('ChatCompressionService', () => {
|
||||
} as unknown as Config;
|
||||
|
||||
vi.mocked(getInitialChatHistory).mockImplementation(
|
||||
async (_config, extraHistory) => extraHistory || [],
|
||||
async (_config, extraHistory) => (extraHistory ? [...extraHistory] : []),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -442,7 +442,9 @@ export class ChatCompressionService {
|
||||
const fullNewHistory = await getInitialChatHistory(config, extraHistory);
|
||||
|
||||
const newTokenCount = await calculateRequestTokenCount(
|
||||
fullNewHistory.flatMap((c) => c.parts || []),
|
||||
fullNewHistory.flatMap(
|
||||
(c) => ('content' in c ? c.content.parts : c.parts) || [],
|
||||
),
|
||||
config.getContentGenerator(),
|
||||
model,
|
||||
);
|
||||
|
||||
@@ -401,7 +401,10 @@ export class ContextManager {
|
||||
const last = hardenedHistory[hardenedHistory.length - 1];
|
||||
if (last && last.content.parts) {
|
||||
const numPartsToRemove = pendingRequest.content.parts?.length || 0;
|
||||
if (numPartsToRemove > 0 && last.content.parts.length > numPartsToRemove) {
|
||||
if (
|
||||
numPartsToRemove > 0 &&
|
||||
last.content.parts.length > numPartsToRemove
|
||||
) {
|
||||
last.content.parts.splice(-numPartsToRemove);
|
||||
} else {
|
||||
hardenedHistory.pop();
|
||||
|
||||
@@ -27,7 +27,7 @@ export function fromGraph(
|
||||
let currentTurn: { id: string; content: Content } | null = null;
|
||||
|
||||
for (const node of nodes) {
|
||||
const turnId = node.turnId || 'orphan'; debugLogger.log("[ID-TRACK] fromGraph converting node:", node.id, "turnId:", turnId);
|
||||
const turnId = node.turnId || 'orphan';
|
||||
const durableId = turnId.startsWith('turn_') ? turnId.slice(5) : turnId;
|
||||
|
||||
// Register the payload in the identity service to ensure stability
|
||||
|
||||
@@ -71,6 +71,13 @@ exports[`System Lifecycle Golden Tests > Scenario 1: Organic Growth with Huge To
|
||||
},
|
||||
"thoughtSignature": "skip_thought_signature_validator",
|
||||
},
|
||||
{
|
||||
"functionCall": {
|
||||
"args": {},
|
||||
"id": "undefined",
|
||||
"name": "run_shell_command",
|
||||
},
|
||||
},
|
||||
],
|
||||
"role": "model",
|
||||
},
|
||||
@@ -89,6 +96,15 @@ exports[`System Lifecycle Golden Tests > Scenario 1: Organic Growth with Huge To
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"functionResponse": {
|
||||
"id": "undefined",
|
||||
"name": "run_shell_command",
|
||||
"response": {
|
||||
"error": "The tool execution result was lost due to context management truncation.",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
"role": "user",
|
||||
},
|
||||
|
||||
@@ -51,9 +51,6 @@ export interface SnapshotState {
|
||||
import { debugLogger } from '../../utils/debugLogger.js';
|
||||
|
||||
export function isSnapshotState(text: string): boolean {
|
||||
import('../../utils/debugLogger.js').then(({ debugLogger }) => {
|
||||
debugLogger.log('[isSnapshotState] CALLED WITH:', text.substring(0, 50));
|
||||
});
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) {
|
||||
return false;
|
||||
@@ -61,15 +58,19 @@ export function isSnapshotState(text: string): boolean {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(trimmed);
|
||||
if (!isRecord(parsed)) return false;
|
||||
const isSnap = Array.isArray(parsed['active_tasks']) &&
|
||||
const isSnap =
|
||||
Array.isArray(parsed['active_tasks']) &&
|
||||
Array.isArray(parsed['discovered_facts']) &&
|
||||
Array.isArray(parsed['constraints_and_preferences']) &&
|
||||
Array.isArray(parsed['recent_arc']);
|
||||
if (!isSnap) {
|
||||
debugLogger.log('[isSnapshotState] FAILED FOR JSON:', JSON.stringify(parsed));
|
||||
debugLogger.log(
|
||||
'[isSnapshotState] FAILED FOR JSON:',
|
||||
JSON.stringify(parsed),
|
||||
);
|
||||
}
|
||||
return isSnap;
|
||||
} catch (e) {
|
||||
} catch {
|
||||
debugLogger.log('[isSnapshotState] PARSE FAILED FOR:', trimmed);
|
||||
return false;
|
||||
}
|
||||
@@ -89,9 +90,20 @@ export interface BaselineSnapshotInfo {
|
||||
export function findLatestSnapshotBaseline(
|
||||
targets: readonly ConcreteNode[],
|
||||
): BaselineSnapshotInfo | undefined {
|
||||
import('../../utils/debugLogger.js').then(({ debugLogger }) => {
|
||||
debugLogger.log('[findLatestSnapshotBaseline] Targets:', targets.map(t => ({ id: t.id, type: t.type, text: (t.payload as any).text?.substring(0, 20) })));
|
||||
});
|
||||
debugLogger.log(
|
||||
'[findLatestSnapshotBaseline] Targets:',
|
||||
targets.map((t) => ({
|
||||
id: t.id,
|
||||
type: t.type,
|
||||
text:
|
||||
t.payload &&
|
||||
typeof t.payload === 'object' &&
|
||||
'text' in t.payload &&
|
||||
typeof t.payload.text === 'string'
|
||||
? t.payload.text.substring(0, 20)
|
||||
: '',
|
||||
})),
|
||||
);
|
||||
const lastSnapshotNode = [...targets]
|
||||
.reverse()
|
||||
.find((n) => n.type === NodeType.SNAPSHOT && n.payload.text);
|
||||
|
||||
@@ -337,7 +337,7 @@ export class GeminiClient {
|
||||
}
|
||||
|
||||
async resumeChat(
|
||||
history: any[],
|
||||
history: ReadonlyArray<Content | HistoryTurn>,
|
||||
resumedSessionData?: ResumedSessionData,
|
||||
): Promise<void> {
|
||||
this.chat = await this.startChat(history, resumedSessionData);
|
||||
@@ -378,7 +378,7 @@ export class GeminiClient {
|
||||
}
|
||||
|
||||
async startChat(
|
||||
extraHistory?: any[],
|
||||
extraHistory?: ReadonlyArray<Content | HistoryTurn>,
|
||||
resumedSessionData?: ResumedSessionData,
|
||||
): Promise<GeminiChat> {
|
||||
this.forceFullIdeContext = true;
|
||||
@@ -394,16 +394,13 @@ export class GeminiClient {
|
||||
: await getInitialChatHistory(this.config, extraHistory);
|
||||
|
||||
try {
|
||||
import('node:fs').then((fs) => {
|
||||
fs.appendFileSync('/tmp/gemini_startChat_history.log', JSON.stringify(history, null, 2) + '\n');
|
||||
});
|
||||
const systemMemory = this.config.getSystemInstructionMemory();
|
||||
const systemInstruction = getCoreSystemPrompt(this.config, systemMemory);
|
||||
const chat = new GeminiChat(
|
||||
this.config,
|
||||
systemInstruction,
|
||||
tools,
|
||||
history as any[],
|
||||
[...history],
|
||||
resumedSessionData,
|
||||
async (modelId: string) => {
|
||||
this.lastUsedModelId = modelId;
|
||||
@@ -424,7 +421,7 @@ export class GeminiClient {
|
||||
await reportError(
|
||||
error,
|
||||
'Error initializing Gemini chat session.',
|
||||
history as Content[],
|
||||
[...history],
|
||||
'startChat',
|
||||
);
|
||||
throw new Error(`Failed to initialize chat: ${getErrorMessage(error)}`);
|
||||
@@ -668,7 +665,6 @@ export class GeminiClient {
|
||||
currentBaseUnits = baseUnits;
|
||||
|
||||
if (didApplyManagement) {
|
||||
import('node:fs').then(fs => fs.appendFileSync('/tmp/gemini_sync.log', `[client.ts] setHistory called with ${newHistory.length} items!\n`));
|
||||
// If the manager pruned history, we update the chat before continuing.
|
||||
// Note: we don't include the pendingRequest in this setHistory,
|
||||
// because Turn.run will add it normally.
|
||||
|
||||
@@ -898,7 +898,6 @@ export class ChatRecordingService {
|
||||
|
||||
// 1. Sync content and IDs
|
||||
const newMessages: MessageRecord[] = history.map((turn) => {
|
||||
import('node:fs').then(fs => fs.appendFileSync('/tmp/gemini_sync.log', `[${new Date().toISOString()}] [updateMessages] Turn ID: ${turn.id}, text: ${turn.content.parts?.[0]?.text?.substring(0, 50)}\n`));
|
||||
const existing = this.cachedConversation?.messages.find(
|
||||
(m) => m.id === turn.id,
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import type { Part, Content } from '@google/genai';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { getFolderStructure } from './getFolderStructure.js';
|
||||
import type { HistoryTurn } from '../core/agentChatHistory.js';
|
||||
|
||||
export const INITIAL_HISTORY_LENGTH = 1;
|
||||
|
||||
@@ -81,8 +82,8 @@ ${environmentMemory}
|
||||
|
||||
export async function getInitialChatHistory(
|
||||
config: Config,
|
||||
extraHistory?: Content[],
|
||||
): Promise<Content[]> {
|
||||
extraHistory?: ReadonlyArray<Content | HistoryTurn>,
|
||||
): Promise<Array<Content | HistoryTurn>> {
|
||||
const envParts = await getEnvironmentContext(config);
|
||||
const envContextString = envParts.map((part) => part.text || '').join('\n\n');
|
||||
|
||||
|
||||
@@ -212,7 +212,7 @@ describe('hardenHistory', () => {
|
||||
).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('should drop orphaned functionResponses', () => {
|
||||
it('should synthesize a functionCall for a singleton orphaned functionResponse', () => {
|
||||
const history: HistoryTurn[] = [
|
||||
{ id: '1', content: { role: 'user', parts: [{ text: 'hello' }] } },
|
||||
{ id: '2', content: { role: 'model', parts: [{ text: 'hi' }] } },
|
||||
@@ -231,9 +231,86 @@ describe('hardenHistory', () => {
|
||||
];
|
||||
|
||||
const hardened = hardenHistory(history);
|
||||
// Turn 1: user, Turn 2: model (with synthetic call), Turn 3: user
|
||||
expect(hardened.length).toBe(3);
|
||||
expect(hardened[2].content.parts).toHaveLength(1);
|
||||
expect(hardened[2].content.parts![0]).toEqual({ text: 'text is kept' });
|
||||
|
||||
const modelTurn = hardened[1];
|
||||
expect(modelTurn.content.role).toBe('model');
|
||||
expect(modelTurn.content.parts).toHaveLength(2); // text + synthetic call
|
||||
expect(modelTurn.content.parts![1].functionCall).toBeDefined();
|
||||
expect(modelTurn.content.parts![1].functionCall?.id).toBe('orphan_1');
|
||||
expect(
|
||||
(modelTurn.content.parts![1] as unknown as { thoughtSignature: string })
|
||||
.thoughtSignature,
|
||||
).toBe(SYNTHETIC_THOUGHT_SIGNATURE);
|
||||
|
||||
const userTurn = hardened[2];
|
||||
expect(userTurn.content.parts).toHaveLength(2); // hoisted response + text
|
||||
expect(userTurn.content.parts![0].functionResponse?.id).toBe('orphan_1');
|
||||
expect(userTurn.content.parts![1]).toEqual({ text: 'text is kept' });
|
||||
});
|
||||
|
||||
it('should synthesize functionCalls for multiple orphaned functionResponses in parallel', () => {
|
||||
const history: HistoryTurn[] = [
|
||||
{
|
||||
id: '1',
|
||||
content: { role: 'user', parts: [{ text: 'Parallel action' }] },
|
||||
},
|
||||
// Previous model turn exists but has NO tool calls
|
||||
{
|
||||
id: '2',
|
||||
content: { role: 'model', parts: [{ text: 'I will do nothing' }] },
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
content: {
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
functionResponse: { id: 'orphan_A', name: 'toolA', response: {} },
|
||||
},
|
||||
{
|
||||
functionResponse: { id: 'orphan_B', name: 'toolB', response: {} },
|
||||
},
|
||||
{
|
||||
functionResponse: { id: 'orphan_C', name: 'toolC', response: {} },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const hardened = hardenHistory(history);
|
||||
expect(hardened.length).toBe(3);
|
||||
|
||||
const modelTurn = hardened[1];
|
||||
expect(modelTurn.content.role).toBe('model');
|
||||
expect(modelTurn.content.parts).toHaveLength(4); // original text + 3 synthetic calls
|
||||
|
||||
// Only the FIRST function call should get the synthetic signature
|
||||
const callA = modelTurn.content.parts![1];
|
||||
expect(callA.functionCall?.id).toBe('orphan_A');
|
||||
expect(
|
||||
(callA as unknown as { thoughtSignature?: string }).thoughtSignature,
|
||||
).toBe(SYNTHETIC_THOUGHT_SIGNATURE);
|
||||
|
||||
const callB = modelTurn.content.parts![2];
|
||||
expect(callB.functionCall?.id).toBe('orphan_B');
|
||||
expect(
|
||||
(callB as unknown as { thoughtSignature?: string }).thoughtSignature,
|
||||
).toBeUndefined();
|
||||
|
||||
const callC = modelTurn.content.parts![3];
|
||||
expect(callC.functionCall?.id).toBe('orphan_C');
|
||||
expect(
|
||||
(callC as unknown as { thoughtSignature?: string }).thoughtSignature,
|
||||
).toBeUndefined();
|
||||
|
||||
const userTurn = hardened[2];
|
||||
expect(userTurn.content.parts).toHaveLength(3);
|
||||
expect(userTurn.content.parts![0].functionResponse?.id).toBe('orphan_A');
|
||||
expect(userTurn.content.parts![1].functionResponse?.id).toBe('orphan_B');
|
||||
expect(userTurn.content.parts![2].functionResponse?.id).toBe('orphan_C');
|
||||
});
|
||||
|
||||
it('should hoist and re-order tool responses to match functionCall order', () => {
|
||||
|
||||
@@ -179,6 +179,7 @@ function pairToolsAndEnforceSignatures(
|
||||
const prevTurn = result[result.length - 1];
|
||||
const parts = turn.content.parts || [];
|
||||
const validParts: Part[] = [];
|
||||
const orphanedResponses: Part[] = [];
|
||||
|
||||
for (const p of parts) {
|
||||
if (p.functionResponse) {
|
||||
@@ -195,13 +196,47 @@ function pairToolsAndEnforceSignatures(
|
||||
validParts.push(p);
|
||||
} else {
|
||||
debugLogger.log(
|
||||
`[HistoryHardener] Dropping orphaned functionResponse id='${id}' (name='${name}')`,
|
||||
`[HistoryHardener] Orphaned functionResponse id='${id}' (name='${name}'). Injecting synthetic functionCall.`,
|
||||
);
|
||||
orphanedResponses.push(p);
|
||||
validParts.push(p);
|
||||
}
|
||||
} else {
|
||||
validParts.push(p);
|
||||
}
|
||||
}
|
||||
|
||||
if (orphanedResponses.length > 0) {
|
||||
let targetModelTurn: HistoryTurn;
|
||||
if (prevTurn?.content.role === 'model') {
|
||||
targetModelTurn = prevTurn;
|
||||
} else {
|
||||
targetModelTurn = {
|
||||
id: deriveStableId([turn.id, 'sentinel_call']),
|
||||
content: { role: 'model', parts: [] },
|
||||
};
|
||||
result.push(targetModelTurn);
|
||||
}
|
||||
|
||||
for (const orph of orphanedResponses) {
|
||||
targetModelTurn.content.parts = targetModelTurn.content.parts || [];
|
||||
const hasExistingCall = targetModelTurn.content.parts.some(
|
||||
(p) => !!p.functionCall,
|
||||
);
|
||||
const callPart: Part = {
|
||||
functionCall: {
|
||||
name: orph.functionResponse!.name,
|
||||
id: orph.functionResponse!.id,
|
||||
args: {},
|
||||
},
|
||||
};
|
||||
if (!hasExistingCall) {
|
||||
callPart.thoughtSignature = SYNTHETIC_THOUGHT_SIGNATURE;
|
||||
}
|
||||
targetModelTurn.content.parts.push(callPart);
|
||||
}
|
||||
}
|
||||
|
||||
turn.content.parts = validParts;
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ describe('convertSessionToClientHistory', () => {
|
||||
|
||||
const history = convertSessionToClientHistory(messages);
|
||||
|
||||
expect(history).toEqual([
|
||||
expect(history.map((h) => h.content)).toEqual([
|
||||
{ role: 'user', parts: [{ text: 'Hello' }] },
|
||||
{ role: 'model', parts: [{ text: 'Hi there' }] },
|
||||
]);
|
||||
@@ -58,7 +58,7 @@ describe('convertSessionToClientHistory', () => {
|
||||
|
||||
const history = convertSessionToClientHistory(messages);
|
||||
|
||||
expect(history).toEqual([
|
||||
expect(history.map((h) => h.content)).toEqual([
|
||||
{ role: 'user', parts: [{ text: 'Hello' }] },
|
||||
{
|
||||
role: 'model',
|
||||
@@ -100,7 +100,7 @@ describe('convertSessionToClientHistory', () => {
|
||||
|
||||
const history = convertSessionToClientHistory(messages);
|
||||
|
||||
expect(history).toEqual([
|
||||
expect(history.map((h) => h.content)).toEqual([
|
||||
{ role: 'user', parts: [{ text: 'Actual query' }] },
|
||||
]);
|
||||
});
|
||||
@@ -133,7 +133,7 @@ describe('convertSessionToClientHistory', () => {
|
||||
|
||||
const history = convertSessionToClientHistory(messages);
|
||||
|
||||
expect(history).toEqual([
|
||||
expect(history.map((h) => h.content)).toEqual([
|
||||
{ role: 'user', parts: [{ text: 'List files' }] },
|
||||
{
|
||||
role: 'model',
|
||||
@@ -172,7 +172,7 @@ describe('convertSessionToClientHistory', () => {
|
||||
|
||||
const history = convertSessionToClientHistory(messages);
|
||||
|
||||
expect(history).toEqual([
|
||||
expect(history.map((h) => h.content)).toEqual([
|
||||
{
|
||||
role: 'user',
|
||||
parts: [
|
||||
|
||||
@@ -52,7 +52,7 @@ export function convertSessionToClientHistory(
|
||||
content: {
|
||||
role: 'user',
|
||||
parts: ensurePartArray(msg.content),
|
||||
}
|
||||
},
|
||||
});
|
||||
} else if (msg.type === 'gemini') {
|
||||
const modelParts: Part[] = [];
|
||||
@@ -93,7 +93,7 @@ export function convertSessionToClientHistory(
|
||||
content: {
|
||||
role: 'model',
|
||||
parts: modelParts,
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const functionResponseParts: Part[] = [];
|
||||
@@ -128,7 +128,7 @@ export function convertSessionToClientHistory(
|
||||
content: {
|
||||
role: 'user',
|
||||
parts: functionResponseParts,
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
} else {
|
||||
@@ -150,4 +150,4 @@ export function convertSessionToClientHistory(
|
||||
}
|
||||
|
||||
return clientHistory;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user