further changes

This commit is contained in:
Your Name
2026-05-15 05:55:41 +00:00
parent efdaf0cb6a
commit b439c15907
20 changed files with 274 additions and 122 deletions
+23 -30
View File
@@ -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);
});
});
+2 -5
View File
@@ -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',
},
},
},
],
});
],
},
]);
});
});
+10 -3
View File
@@ -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,
);
+4 -1
View File
@@ -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();
+1 -1
View File
@@ -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);
+4 -8
View File
@@ -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', () => {
+36 -1
View File
@@ -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;
}
+5 -5
View File
@@ -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: [
+4 -4
View File
@@ -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;
}
}