mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-10 00:47:16 -07:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fe45f09636 | |||
| 10944dcc4a | |||
| 2f3285871c | |||
| ae5f5edb58 | |||
| 76a97cac0e |
Generated
+9
-9
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.54.0",
|
||||
"version": "0.54.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.54.0",
|
||||
"version": "0.54.3",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
@@ -17782,7 +17782,7 @@
|
||||
},
|
||||
"packages/a2a-server": {
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.54.0",
|
||||
"version": "0.54.3",
|
||||
"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.3",
|
||||
"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.3",
|
||||
"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.3",
|
||||
"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.3",
|
||||
"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.3",
|
||||
"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.3",
|
||||
"license": "LICENSE",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "1.23.0",
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.54.0",
|
||||
"version": "0.54.3",
|
||||
"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.3"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "cross-env NODE_ENV=development node scripts/start.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.54.0",
|
||||
"version": "0.54.3",
|
||||
"description": "Gemini CLI A2A Server",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.54.0",
|
||||
"version": "0.54.3",
|
||||
"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.3"
|
||||
},
|
||||
"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[] = [
|
||||
{
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-core",
|
||||
"version": "0.54.0",
|
||||
"version": "0.54.3",
|
||||
"description": "Gemini CLI Core",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-devtools",
|
||||
"version": "0.54.0",
|
||||
"version": "0.54.3",
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"main": "dist/src/index.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-sdk",
|
||||
"version": "0.54.0",
|
||||
"version": "0.54.3",
|
||||
"description": "Gemini CLI SDK",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-test-utils",
|
||||
"version": "0.54.0",
|
||||
"version": "0.54.3",
|
||||
"private": true,
|
||||
"main": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
|
||||
@@ -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.3",
|
||||
"publisher": "google",
|
||||
"icon": "assets/icon.png",
|
||||
"repository": {
|
||||
|
||||
Reference in New Issue
Block a user