Compare commits

...

3 Commits

Author SHA1 Message Date
David Pierce 3f5634951e Update package.json from 0.54.1 to 0.54.2 2026-08-06 13:14:13 -04:00
gemini-cli-robot ae5f5edb58 chore(release): v0.54.1 2026-08-06 16:13:54 +00:00
gemini-cli-robot 76a97cac0e fix(patch): cherry-pick 56f9688 to release/v0.54.0-pr-28700 to patch version v0.54.0 and create version 0.54.1 (#28710)
Co-authored-by: Adam Weidman <65992621+adamfweidman@users.noreply.github.com>
Co-authored-by: David Pierce <davidapierce@google.com>
Co-authored-by: Mpider-San <sarbojitrana47c@gmail.com>
2026-08-06 14:54:28 +00:00
15 changed files with 692 additions and 32 deletions
+9 -9
View File
@@ -1,12 +1,12 @@
{
"name": "@google/gemini-cli",
"version": "0.54.0",
"version": "0.54.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@google/gemini-cli",
"version": "0.54.0",
"version": "0.54.1",
"workspaces": [
"packages/*"
],
@@ -17782,7 +17782,7 @@
},
"packages/a2a-server": {
"name": "@google/gemini-cli-a2a-server",
"version": "0.54.0",
"version": "0.54.1",
"dependencies": {
"@a2a-js/sdk": "0.3.11",
"@google-cloud/storage": "7.19.0",
@@ -18242,7 +18242,7 @@
},
"packages/cli": {
"name": "@google/gemini-cli",
"version": "0.54.0",
"version": "0.54.1",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "0.16.1",
@@ -18458,7 +18458,7 @@
},
"packages/core": {
"name": "@google/gemini-cli-core",
"version": "0.54.0",
"version": "0.54.1",
"license": "Apache-2.0",
"dependencies": {
"@a2a-js/sdk": "0.3.11",
@@ -19131,7 +19131,7 @@
},
"packages/devtools": {
"name": "@google/gemini-cli-devtools",
"version": "0.54.0",
"version": "0.54.1",
"license": "Apache-2.0",
"dependencies": {
"ws": "8.16.0"
@@ -19167,7 +19167,7 @@
},
"packages/sdk": {
"name": "@google/gemini-cli-sdk",
"version": "0.54.0",
"version": "0.54.1",
"license": "Apache-2.0",
"dependencies": {
"@google/gemini-cli-core": "file:../core",
@@ -19506,7 +19506,7 @@
},
"packages/test-utils": {
"name": "@google/gemini-cli-test-utils",
"version": "0.54.0",
"version": "0.54.1",
"license": "Apache-2.0",
"dependencies": {
"@google/gemini-cli-core": "file:../core",
@@ -19524,7 +19524,7 @@
},
"packages/vscode-ide-companion": {
"name": "gemini-cli-vscode-ide-companion",
"version": "0.54.0",
"version": "0.54.1",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "1.23.0",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.54.0",
"version": "0.54.2",
"engines": {
"node": ">=20.0.0"
},
@@ -14,7 +14,7 @@
"url": "git+https://github.com/google-gemini/gemini-cli.git"
},
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.54.0"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.54.1"
},
"scripts": {
"start": "cross-env NODE_ENV=development node scripts/start.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-a2a-server",
"version": "0.54.0",
"version": "0.54.1",
"description": "Gemini CLI A2A Server",
"repository": {
"type": "git",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.54.0",
"version": "0.54.1",
"description": "Gemini CLI",
"license": "Apache-2.0",
"repository": {
@@ -27,7 +27,7 @@
"dist"
],
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.54.0"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.54.1"
},
"dependencies": {
"@agentclientprotocol/sdk": "0.16.1",
@@ -1046,6 +1046,107 @@ describe('useGeminiStream', () => {
});
});
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
// functionCall unpaired, which corrupts all subsequent requests.
const responseParts: Part[] = [
{
functionResponse: {
name: 'testTool',
id: 'call1',
response: { output: 'tool result' },
},
},
];
const completedToolCalls: TrackedToolCall[] = [
{
request: {
callId: 'call1',
name: 'testTool',
args: {},
isClientInitiated: false,
prompt_id: 'prompt-id-quota',
},
status: CoreToolCallStatus.Success,
responseSubmittedToGemini: false,
response: {
callId: 'call1',
responseParts,
errorType: undefined,
},
tool: { displayName: 'MockTool' },
invocation: {
getDescription: () => `Mock description`,
} as unknown as AnyToolInvocation,
} as TrackedCompletedToolCall,
];
const client = new MockedGeminiClientClass(mockConfig);
const mockConsumeUserHint = vi.fn(() => 'switch to the nprd database');
let capturedOnComplete:
| ((completedTools: TrackedToolCall[]) => Promise<void>)
| null = null;
mockUseToolScheduler.mockImplementation((onComplete) => {
capturedOnComplete = onComplete;
return [
[],
mockScheduleToolCalls,
mockMarkToolsAsSubmitted,
vi.fn(),
mockCancelAllToolCalls,
0,
];
});
await renderHookWithProviders(() =>
useGeminiStream(
client,
[],
mockAddItem,
mockConfig,
mockLoadedSettings,
mockOnDebugMessage,
mockHandleSlashCommand,
false,
() => 'vscode' as EditorType,
() => {},
() => Promise.resolve(),
true, // modelSwitchedFromQuotaError
() => {},
() => {},
() => {},
80,
24,
false,
mockConsumeUserHint,
),
);
await act(async () => {
if (capturedOnComplete) {
await new Promise((resolve) => setTimeout(resolve, 0));
await capturedOnComplete(completedToolCalls);
}
});
await waitFor(() => {
expect(mockMarkToolsAsSubmitted).toHaveBeenCalledWith(['call1']);
// The tool response must be paired with its functionCall in history,
// with no steering-hint text ahead of it...
expect(client.addHistory).toHaveBeenCalledWith({
role: 'user',
parts: responseParts,
});
// ...the turn must NOT auto-continue on the fallback model...
expect(mockSendMessageStream).not.toHaveBeenCalled();
// ...and the pending hint is left for the next real submit.
expect(mockConsumeUserHint).not.toHaveBeenCalled();
});
});
it('should NOT stop responding when only update_topic is called', async () => {
const topicToolCalls: TrackedToolCall[] = [
{
+21 -11
View File
@@ -2110,6 +2110,27 @@ export const useGeminiStream = (
(toolCall) => toolCall.response.responseParts,
);
const callIdsToMarkAsSubmitted = geminiTools.map(
(toolCall) => toolCall.request.callId,
);
markToolsAsSubmitted(callIdsToMarkAsSubmitted);
// Don't continue if model was switched due to quota error, but still
// record the responses: the matching functionCall is already in history,
// and leaving it unpaired corrupts every subsequent request. Any pending
// steering hint is deliberately left unconsumed so it rides along with
// the next query the user actually submits.
if (modelSwitchedFromQuotaError) {
if (geminiClient && responsesToSend.length > 0) {
await geminiClient.addHistory({
role: 'user',
parts: responsesToSend,
});
}
return;
}
if (consumeUserHint) {
const userHint = consumeUserHint();
if (userHint && userHint.trim().length > 0) {
@@ -2120,21 +2141,10 @@ export const useGeminiStream = (
}
}
const callIdsToMarkAsSubmitted = geminiTools.map(
(toolCall) => toolCall.request.callId,
);
const prompt_ids = geminiTools.map(
(toolCall) => toolCall.request.prompt_id,
);
markToolsAsSubmitted(callIdsToMarkAsSubmitted);
// Don't continue if model was switched due to quota error
if (modelSwitchedFromQuotaError) {
return;
}
// eslint-disable-next-line @typescript-eslint/no-floating-promises
submitQuery(
responsesToSend,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-core",
"version": "0.54.0",
"version": "0.54.1",
"description": "Gemini CLI Core",
"license": "Apache-2.0",
"repository": {
+292
View File
@@ -228,6 +228,16 @@ describe('GeminiChat', () => {
// Disable 429 simulation for tests
setSimulate429(false);
// The mid-stream retry loop sleeps on a real timer (1s + 2s + 4s) between
// attempts, which exceeds the default 5s test timeout and silently killed
// every InvalidStreamError test before it reached its assertions. Run those
// delays instantly.
vi.spyOn(globalThis, 'setTimeout').mockImplementation(((fn: () => void) => {
fn();
return 0;
}) as unknown as typeof globalThis.setTimeout);
// Reset history for each test by creating a new instance
chat = new GeminiChat(mockConfig);
mockConfig.getHookSystem = vi.fn().mockReturnValue(undefined);
@@ -999,6 +1009,219 @@ describe('GeminiChat', () => {
expect(lastTurn.content.parts?.[0]?.functionResponse).toBeDefined();
});
it('should not fuse the next user message into a preserved tool-response turn', async () => {
// Regression: when a stream fails mid tool-loop the tool response is
// deliberately preserved (see the test above), which leaves history
// ending on a user turn. The user's next message was then coalesced into
// that same turn as [functionResponse, text]. The model reads the
// trailing text as a continuation of the tool result and completes the
// sentence instead of answering it.
chat.agentHistory.push({
id: 'model-turn-1',
content: {
role: 'model',
parts: [{ functionCall: { name: 'test_tool', args: {} } }],
},
});
// 1. Tool response goes back, model returns nothing -> InvalidStreamError.
vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue(
(async function* () {
yield {
candidates: [
{ content: { role: 'model', parts: [] }, finishReason: 'STOP' },
],
} as unknown as GenerateContentResponse;
})(),
);
const failingStream = await chat.sendMessageStream(
{ model: 'gemini-2.0-flash' },
[
{
functionResponse: {
name: 'test_tool',
response: { success: true },
},
},
],
'prompt-id-fusion-setup',
new AbortController().signal,
LlmRole.MAIN,
);
await expect(
(async () => {
for await (const _ of failingStream) {
// consume
}
})(),
).rejects.toThrow(InvalidStreamError);
// 2. The user types a brand new instruction.
let capturedContents: Content[] = [];
vi.mocked(mockContentGenerator.generateContentStream).mockImplementation(
async (req) => {
capturedContents = req.contents as Content[];
return (async function* () {
yield {
candidates: [
{
content: { role: 'model', parts: [{ text: 'ok' }] },
finishReason: 'STOP',
},
],
} as unknown as GenerateContentResponse;
})();
},
);
const stream = await chat.sendMessageStream(
{ model: 'gemini-2.0-flash' },
'are you done?',
'prompt-id-fusion-check',
new AbortController().signal,
LlmRole.MAIN,
);
for await (const _ of stream) {
// consume
}
const fusedTurn = capturedContents.find(
(c) =>
c.role === 'user' &&
!!c.parts?.some((p) => !!p.functionResponse) &&
!!c.parts?.some((p) => p.text?.includes('are you done?')),
);
expect(fusedTurn).toBeUndefined();
});
it('should not fuse the next user message into a cancelled tool response', async () => {
// Same defect reached by a different trigger: cancelling a tool call
// records its response via addHistory then returns without submitting,
// leaving history on an unanswered user turn just like a stream failure.
chat.agentHistory.push({
id: 'model-turn-cancel',
content: {
role: 'model',
parts: [
{ functionCall: { id: 'c1', name: 'run_shell_command', args: {} } },
],
},
});
chat.addHistory({
role: 'user',
parts: [
{
functionResponse: {
id: 'c1',
name: 'run_shell_command',
response: { error: '[Operation Cancelled]' },
},
},
],
});
let capturedContents: Content[] = [];
vi.mocked(mockContentGenerator.generateContentStream).mockImplementation(
async (req) => {
capturedContents = req.contents as Content[];
return (async function* () {
yield {
candidates: [
{
content: { role: 'model', parts: [{ text: 'ok' }] },
finishReason: 'STOP',
},
],
} as unknown as GenerateContentResponse;
})();
},
);
const stream = await chat.sendMessageStream(
{ model: 'gemini-2.0-flash' },
"you're querying local database, I meant nprd",
'prompt-id-cancel-fusion',
new AbortController().signal,
LlmRole.MAIN,
);
for await (const _ of stream) {
// consume
}
const fusedCancelTurn = capturedContents.find(
(c) =>
c.role === 'user' &&
!!c.parts?.some((p) => !!p.functionResponse) &&
!!c.parts?.some((p) => p.text?.includes('I meant nprd')),
);
expect(fusedCancelTurn).toBeUndefined();
});
it('should close a dangling tool response restored from a resumed session', async () => {
// The guard runs when a new user message arrives rather than when the
// turn fails, so it does not depend on a placeholder having been
// persisted. A session resumed from disk that ends on an unanswered tool
// response is repaired on the next message just the same.
chat.setHistory([
{ role: 'user', parts: [{ text: 'run the tests' }] },
{
role: 'model',
parts: [
{ functionCall: { id: 'c1', name: 'run_shell_command', args: {} } },
],
},
{
role: 'user',
parts: [
{
functionResponse: {
id: 'c1',
name: 'run_shell_command',
response: { output: 'ok' },
},
},
],
},
]);
let capturedContents: Content[] = [];
vi.mocked(mockContentGenerator.generateContentStream).mockImplementation(
async (req) => {
capturedContents = req.contents as Content[];
return (async function* () {
yield {
candidates: [
{
content: { role: 'model', parts: [{ text: 'ok' }] },
finishReason: 'STOP',
},
],
} as unknown as GenerateContentResponse;
})();
},
);
const stream = await chat.sendMessageStream(
{ model: 'gemini-2.0-flash' },
'are you done?',
'prompt-id-resumed-fusion',
new AbortController().signal,
LlmRole.MAIN,
);
for await (const _ of stream) {
// consume
}
const fusedResumedTurn = capturedContents.find(
(c) =>
c.role === 'user' &&
!!c.parts?.some((p) => !!p.functionResponse) &&
!!c.parts?.some((p) => p.text?.includes('are you done?')),
);
expect(fusedResumedTurn).toBeUndefined();
});
it('should preserve mixed multimodal function responses during rollback when InvalidStreamError is thrown (regression)', async () => {
// 1. Setup history ending with a model turn containing functionCall
chat.agentHistory.push({
@@ -3210,6 +3433,75 @@ describe('GeminiChat', () => {
expect(turns[0].content.parts![0].text).toBe('Question 1');
expect(turns[0].content.parts![1].text).toBe('Question 2');
});
it('should inject a synthetic thoughtSignature onto a functionCall left signature-less after stripping a thought part that carried it (regression test for #28604)', () => {
vi.mocked(mockConfig.isContextManagementEnabled).mockReturnValue(false);
vi.mocked(mockConfig.getModel).mockReturnValue('gemini-2.5-pro');
chat.setHistory([
{ role: 'user', parts: [{ text: 'activate the skill' }] },
{
role: 'model',
parts: [
{
text: 'internal monologue',
thought: true,
thoughtSignature: 'real-sig-from-api',
} as unknown as Part,
{
functionCall: { name: 'activate_skill', args: {} },
},
],
},
{
role: 'user',
parts: [
{ functionResponse: { name: 'activate_skill', response: {} } },
],
},
]);
const turns = chat.getHistoryTurns(true);
const modelTurn = turns[1];
expect(modelTurn.content.parts).toHaveLength(1);
expect(modelTurn.content.parts![0].functionCall?.name).toBe(
'activate_skill',
);
expect(modelTurn.content.parts![0].thoughtSignature).toBe(
SYNTHETIC_THOUGHT_SIGNATURE,
);
});
it('should leave an existing thoughtSignature on a functionCall untouched when stripping thoughts', () => {
vi.mocked(mockConfig.isContextManagementEnabled).mockReturnValue(false);
vi.mocked(mockConfig.getModel).mockReturnValue('gemini-2.5-pro');
chat.setHistory([
{ role: 'user', parts: [{ text: 'activate the skill' }] },
{
role: 'model',
parts: [
{
text: 'internal monologue',
thought: true,
thoughtSignature: 'real-sig-from-api',
} as unknown as Part,
{
functionCall: { name: 'activate_skill', args: {} },
thoughtSignature: 'existing-sig-on-call',
},
],
},
]);
const turns = chat.getHistoryTurns(true);
const modelTurn = turns[1];
expect(modelTurn.content.parts![0].thoughtSignature).toBe(
'existing-sig-on-call',
);
});
});
describe('ensureActiveLoopHasThoughtSignatures', () => {
+63 -1
View File
@@ -108,6 +108,13 @@ const MID_STREAM_RETRY_OPTIONS: MidStreamRetryOptions = {
export const SYNTHETIC_THOUGHT_SIGNATURE = 'skip_thought_signature_validator';
/**
* Stands in for a model turn that never arrived because the stream failed
* after a tool response was already committed to history.
*/
export const INTERRUPTED_RESPONSE_PLACEHOLDER =
'[The previous response was interrupted before it completed.]';
/**
* Internal interface for parts that carry the magic 'callIndex' property
* used during model response consolidation.
@@ -408,6 +415,16 @@ 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 (!isOriginalFunctionResponse) {
this.closeUnansweredToolResponseTurn();
}
const { model } =
this.context.config.modelConfigService.getResolvedConfig(modelConfigKey);
@@ -683,6 +700,28 @@ export class GeminiChat {
return streamWithRetries.call(this);
}
/**
* 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];
if (
last?.content.role !== 'user' ||
!last.content.parts?.some((part) => !!part.functionResponse)
) {
return;
}
this.agentHistory.push({
id: randomUUID(),
content: {
role: 'model',
parts: [{ text: INTERRUPTED_RESPONSE_PLACEHOLDER }],
},
});
}
private extractBinaryInjections(
parts: Part[] | undefined,
): Part[] | undefined {
@@ -1648,11 +1687,34 @@ export function stripThoughts(history: HistoryTurn[]): HistoryTurn[] {
if (!hasThought) return turn;
const nonThoughtParts = turn.content.parts.filter((p) => p && !p.thought);
// The thoughtSignature the API requires on the first functionCall of a
// model turn is sometimes only carried by the thought part we just
// removed, not by the functionCall part itself. Without it, replaying
// this turn in a later request gets rejected with a 400 "missing
// thought_signature" error, so inject a synthetic one if needed.
let patchedFirstCall = false;
const finalParts =
turn.content.role === 'model'
? nonThoughtParts.map((p) => {
if (!patchedFirstCall && p.functionCall) {
patchedFirstCall = true;
if (!p.thoughtSignature) {
return {
...p,
thoughtSignature: SYNTHETIC_THOUGHT_SIGNATURE,
};
}
}
return p;
})
: nonThoughtParts;
return {
...turn,
content: {
...turn.content,
parts: nonThoughtParts,
parts: finalParts,
},
};
})
@@ -308,6 +308,125 @@ describe('ChatRecordingService', () => {
)) as ConversationRecord;
expect(conversation.sessionId).toBe('old-session-id');
});
it('should fall back to the in-memory conversation when the file cannot be reloaded', async () => {
// Regression test for the `/compress` "Failed to load resumed session
// data from file" bug: when resuming with a filePath that cannot be
// loaded from disk, initialize must NOT throw. It should adopt the
// in-memory conversation it was handed and rewrite a clean file.
const chatsDir = path.join(testTempDir, 'chats');
fs.mkdirSync(chatsDir, { recursive: true });
const missingFile = path.join(chatsDir, 'missing-session.jsonl');
expect(fs.existsSync(missingFile)).toBe(false);
const inMemoryConversation = {
sessionId: 'resumed-session-id',
projectHash: 'resumed-project-hash',
startTime: new Date().toISOString(),
lastUpdated: new Date().toISOString(),
messages: [
{
id: 'msg-1',
type: 'user',
timestamp: new Date().toISOString(),
content: 'hello from memory',
},
],
} as unknown as ConversationRecord;
await expect(
chatRecordingService.initialize({
filePath: missingFile,
conversation: inMemoryConversation,
}),
).resolves.not.toThrow();
// The in-memory conversation is adopted.
expect(chatRecordingService.getConversation()?.sessionId).toBe(
'resumed-session-id',
);
// A clean, loadable file is rewritten from the in-memory copy so future
// loads and appends succeed.
const reloaded = (await loadConversationRecord(
missingFile,
)) as ConversationRecord;
expect(reloaded).not.toBeNull();
expect(reloaded.sessionId).toBe('resumed-session-id');
expect(reloaded.projectHash).toBe('resumed-project-hash');
expect(reloaded.messages).toHaveLength(1);
});
it('should preserve an unreadable session file instead of destroying it', async () => {
// The reload may have failed only transiently, so the original bytes
// must survive the recovery rewrite.
const chatsDir = path.join(testTempDir, 'chats');
fs.mkdirSync(chatsDir, { recursive: true });
const sessionFile = path.join(chatsDir, 'unreadable.jsonl');
// No usable metadata line => loadConversationRecord() returns null.
const originalBytes = '{"not":"a valid metadata line"}\n';
fs.writeFileSync(sessionFile, originalBytes);
await chatRecordingService.initialize({
filePath: sessionFile,
conversation: {
sessionId: 'recovered-session-id',
projectHash: 'recovered-project-hash',
startTime: new Date().toISOString(),
lastUpdated: new Date().toISOString(),
messages: [],
} as unknown as ConversationRecord,
});
// The rewritten file is loadable again...
const reloaded = (await loadConversationRecord(
sessionFile,
)) as ConversationRecord;
expect(reloaded.sessionId).toBe('recovered-session-id');
// ...and the original bytes were kept alongside it.
const preserved = fs
.readdirSync(chatsDir)
.filter((f) => f.startsWith('unreadable.jsonl.unreadable-'));
expect(preserved).toHaveLength(1);
expect(fs.readFileSync(path.join(chatsDir, preserved[0]), 'utf-8')).toBe(
originalBytes,
);
});
it('should not leave a temp file behind when the rewrite fails', async () => {
const chatsDir = path.join(testTempDir, 'chats');
fs.mkdirSync(chatsDir, { recursive: true });
const sessionFile = path.join(chatsDir, 'rewrite-fails.jsonl');
// Fail the rename that publishes the temp file, leaving it orphaned.
const realRename = fs.renameSync;
vi.spyOn(fs, 'renameSync').mockImplementation((from, to) => {
if (String(from).includes('.tmp-')) {
throw new Error('simulated rename failure');
}
return realRename(from, to);
});
await expect(
chatRecordingService.initialize({
filePath: sessionFile,
conversation: {
sessionId: 'temp-cleanup-session',
projectHash: 'temp-cleanup-hash',
startTime: new Date().toISOString(),
lastUpdated: new Date().toISOString(),
messages: [],
} as unknown as ConversationRecord,
}),
).rejects.toThrow('simulated rename failure');
const leftovers = fs
.readdirSync(chatsDir)
.filter((f) => f.includes('.tmp-'));
expect(leftovers).toEqual([]);
});
});
describe('recordMessage', () => {
@@ -462,7 +462,16 @@ export class ChatRecordingService {
// Update the session ID in the existing file
this.updateMetadata({ sessionId: this.sessionId });
} else {
throw new Error('Failed to load resumed session data from file');
// The file could not be reloaded (missing, corrupt metadata, or an
// I/O error). Fall back to the in-memory conversation we were handed
// rather than failing the caller, and rewrite a clean file from it.
debugLogger.warn(
'Failed to reload resumed session data from file; falling back ' +
'to the in-memory conversation.',
);
this.cachedConversation = resumedSessionData.conversation;
this.projectHash = this.cachedConversation.projectHash;
this.rewriteConversationFile(this.cachedConversation);
}
} else {
// Create new session
@@ -563,6 +572,73 @@ export class ChatRecordingService {
}
}
/**
* Rewrites the session file from an in-memory record. Any existing
* (unreadable) file is preserved alongside rather than destroyed, and the
* new file is written atomically (temp file + rename).
*/
private rewriteConversationFile(conversation: ConversationRecord): void {
if (!this.conversationFile) return;
// Normalize legacy `.json` paths to the `.jsonl` format we write.
if (this.conversationFile.endsWith('.json')) {
this.conversationFile = this.conversationFile + 'l';
}
const { messages, memoryScratchpad, ...metadata } = conversation;
const lines: string[] = [JSON.stringify(metadata)];
for (const msg of messages) {
lines.push(JSON.stringify(msg));
}
if (memoryScratchpad) {
lines.push(JSON.stringify({ $set: { memoryScratchpad } }));
}
const content = lines.join('\n') + '\n';
try {
fs.mkdirSync(path.dirname(this.conversationFile), { recursive: true });
// The existing file was unreadable, but it may have been only
// transiently so (a lock or I/O blip) rather than truly corrupt. Keep
// its bytes rather than destroying them.
if (fs.existsSync(this.conversationFile)) {
const backup = `${this.conversationFile}.unreadable-${Date.now()}`;
try {
fs.renameSync(this.conversationFile, backup);
debugLogger.warn(
`Preserved the unreadable session file at ${backup}.`,
);
} catch (backupError) {
debugLogger.error(
'Failed to preserve the unreadable session file.',
backupError,
);
}
}
const tempFile = `${this.conversationFile}.tmp-${process.pid}`;
try {
fs.writeFileSync(tempFile, content);
fs.renameSync(tempFile, this.conversationFile);
} catch (error) {
// The rename did not complete, so the temp file would be left behind.
try {
fs.unlinkSync(tempFile);
} catch {
// Ignore cleanup errors so the original failure still surfaces.
}
throw error;
}
} catch (error) {
if (isNodeError(error) && error.code === 'ENOSPC') {
this.conversationFile = null;
debugLogger.warn(ENOSPC_WARNING_MESSAGE);
} else {
throw error;
}
}
}
private updateMetadata(updates: Partial<ConversationRecord>): void {
if (!this.cachedConversation) return;
Object.assign(this.cachedConversation, updates);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-devtools",
"version": "0.54.0",
"version": "0.54.1",
"license": "Apache-2.0",
"type": "module",
"main": "dist/src/index.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-sdk",
"version": "0.54.0",
"version": "0.54.1",
"description": "Gemini CLI SDK",
"license": "Apache-2.0",
"repository": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-test-utils",
"version": "0.54.0",
"version": "0.54.1",
"private": true,
"main": "src/index.ts",
"license": "Apache-2.0",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "gemini-cli-vscode-ide-companion",
"displayName": "Gemini CLI Companion",
"description": "Enable Gemini CLI with direct access to your IDE workspace.",
"version": "0.54.0",
"version": "0.54.1",
"publisher": "google",
"icon": "assets/icon.png",
"repository": {