@google/gemini-cli-core 0.56.0 → 0.57.0-preview.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/dist/docs/changelogs/latest.md +224 -3
  2. package/dist/docs/get-started/authentication.mdx +1 -1
  3. package/dist/docs/reference/commands.md +7 -5
  4. package/dist/google-gemini-cli-core-0.57.0-preview.0.tgz +0 -0
  5. package/dist/src/agent/legacy-agent-session.js +14 -0
  6. package/dist/src/agent/legacy-agent-session.js.map +1 -1
  7. package/dist/src/agent/legacy-agent-session.test.js +52 -0
  8. package/dist/src/agent/legacy-agent-session.test.js.map +1 -1
  9. package/dist/src/agents/agentLoader.d.ts +18 -18
  10. package/dist/src/agents/cli-help-agent.js +13 -1
  11. package/dist/src/agents/cli-help-agent.js.map +1 -1
  12. package/dist/src/agents/cli-help-agent.test.js +4 -4
  13. package/dist/src/agents/cli-help-agent.test.js.map +1 -1
  14. package/dist/src/agents/registry.js +4 -1
  15. package/dist/src/agents/registry.js.map +1 -1
  16. package/dist/src/agents/registry.test.js +8 -0
  17. package/dist/src/agents/registry.test.js.map +1 -1
  18. package/dist/src/availability/modelAvailabilityService.d.ts +2 -1
  19. package/dist/src/availability/modelAvailabilityService.js +20 -5
  20. package/dist/src/availability/modelAvailabilityService.js.map +1 -1
  21. package/dist/src/availability/modelAvailabilityService.test.js +50 -1
  22. package/dist/src/availability/modelAvailabilityService.test.js.map +1 -1
  23. package/dist/src/config/config.js +5 -2
  24. package/dist/src/config/config.js.map +1 -1
  25. package/dist/src/context/contextManager.js +5 -6
  26. package/dist/src/context/contextManager.js.map +1 -1
  27. package/dist/src/context/contextManager.test.js +17 -0
  28. package/dist/src/context/contextManager.test.js.map +1 -1
  29. package/dist/src/core/geminiChat.d.ts +16 -0
  30. package/dist/src/core/geminiChat.js +164 -32
  31. package/dist/src/core/geminiChat.js.map +1 -1
  32. package/dist/src/core/geminiChat.test.js +388 -26
  33. package/dist/src/core/geminiChat.test.js.map +1 -1
  34. package/dist/src/generated/git-commit.d.ts +2 -2
  35. package/dist/src/generated/git-commit.js +2 -2
  36. package/dist/src/generated/git-commit.js.map +1 -1
  37. package/dist/src/ide/ide-connection-utils.js +47 -10
  38. package/dist/src/ide/ide-connection-utils.js.map +1 -1
  39. package/dist/src/ide/ide-connection-utils.test.js +85 -0
  40. package/dist/src/ide/ide-connection-utils.test.js.map +1 -1
  41. package/dist/src/ide/process-utils.js +5 -2
  42. package/dist/src/ide/process-utils.js.map +1 -1
  43. package/dist/src/ide/process-utils.test.js +13 -1
  44. package/dist/src/ide/process-utils.test.js.map +1 -1
  45. package/dist/src/mcp/oauth-provider.js +2 -2
  46. package/dist/src/mcp/oauth-provider.js.map +1 -1
  47. package/dist/src/mcp/oauth-provider.test.js +70 -0
  48. package/dist/src/mcp/oauth-provider.test.js.map +1 -1
  49. package/dist/src/policy/policy-engine.d.ts +2 -0
  50. package/dist/src/policy/policy-engine.js +56 -0
  51. package/dist/src/policy/policy-engine.js.map +1 -1
  52. package/dist/src/policy/policy-engine.test.js +79 -0
  53. package/dist/src/policy/policy-engine.test.js.map +1 -1
  54. package/dist/src/policy/types.d.ts +4 -0
  55. package/dist/src/policy/types.js.map +1 -1
  56. package/dist/src/services/gitService.js +4 -3
  57. package/dist/src/services/gitService.js.map +1 -1
  58. package/dist/src/services/gitService.test.js +16 -9
  59. package/dist/src/services/gitService.test.js.map +1 -1
  60. package/dist/src/services/shellExecutionService.d.ts +1 -1
  61. package/dist/src/services/shellExecutionService.js +40 -27
  62. package/dist/src/services/shellExecutionService.js.map +1 -1
  63. package/dist/src/services/shellExecutionService.test.js +29 -9
  64. package/dist/src/services/shellExecutionService.test.js.map +1 -1
  65. package/dist/src/services/worktreeService.js +9 -0
  66. package/dist/src/services/worktreeService.js.map +1 -1
  67. package/dist/src/services/worktreeService.test.js +8 -6
  68. package/dist/src/services/worktreeService.test.js.map +1 -1
  69. package/dist/src/skills/builtin/antigravity-support/SKILL.md +55 -18
  70. package/dist/src/skills/builtin/skill-creator/SKILL.md +221 -84
  71. package/dist/src/tools/edit.js +19 -4
  72. package/dist/src/tools/edit.js.map +1 -1
  73. package/dist/src/tools/edit.test.js +21 -2
  74. package/dist/src/tools/edit.test.js.map +1 -1
  75. package/dist/src/tools/grep.js +2 -1
  76. package/dist/src/tools/grep.js.map +1 -1
  77. package/dist/src/utils/gitUtils.d.ts +1 -0
  78. package/dist/src/utils/gitUtils.js +34 -0
  79. package/dist/src/utils/gitUtils.js.map +1 -1
  80. package/dist/src/utils/oauth-flow.d.ts +6 -0
  81. package/dist/src/utils/oauth-flow.js +33 -2
  82. package/dist/src/utils/oauth-flow.js.map +1 -1
  83. package/dist/src/utils/oauth-flow.test.js +81 -0
  84. package/dist/src/utils/oauth-flow.test.js.map +1 -1
  85. package/dist/src/utils/retry.js +55 -14
  86. package/dist/src/utils/retry.js.map +1 -1
  87. package/dist/src/utils/retry.test.js +88 -0
  88. package/dist/src/utils/retry.test.js.map +1 -1
  89. package/dist/src/utils/trust.js +3 -0
  90. package/dist/src/utils/trust.js.map +1 -1
  91. package/dist/tsconfig.tsbuildinfo +1 -1
  92. package/package.json +1 -1
@@ -4,8 +4,8 @@
4
4
  * SPDX-License-Identifier: Apache-2.0
5
5
  */
6
6
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
7
- import { ApiError, ThinkingLevel, } from '@google/genai';
8
- import { GeminiChat, InvalidStreamError, StreamEventType, SYNTHETIC_THOUGHT_SIGNATURE, stripToolCallIdPrefixes, coalesceConsecutiveRoles, stripThoughts, } from './geminiChat.js';
7
+ import { ApiError, ThinkingLevel, Language, } from '@google/genai';
8
+ import { GeminiChat, InvalidStreamError, StreamEventType, SYNTHETIC_THOUGHT_SIGNATURE, stripToolCallIdPrefixes, coalesceConsecutiveRoles, stripThoughts, THINKING_ONLY_NUDGE_MESSAGE, NO_RESPONSE_TEXT_NUDGE_MESSAGE, applyRetryNudge, } from './geminiChat.js';
9
9
  import { CoreToolCallStatus, } from '../scheduler/types.js';
10
10
  import { MockTool } from '../test-utils/mock-tool.js';
11
11
  import { setSimulate429 } from '../utils/testUtils.js';
@@ -603,7 +603,7 @@ describe('GeminiChat', () => {
603
603
  expect(modelTurn?.parts?.length).toBe(1);
604
604
  expect(modelTurn?.parts[0].text).toBe('This is the visible text that should not be lost.');
605
605
  });
606
- it('should throw an error when a tool call is followed by an empty stream response', async () => {
606
+ it('should succeed when a tool call is followed by an empty stream response', async () => {
607
607
  // 1. Setup: A history where the model has just made a function call.
608
608
  const initialHistory = [
609
609
  {
@@ -648,12 +648,16 @@ describe('GeminiChat', () => {
648
648
  response: { name: 'Vesuvio' },
649
649
  },
650
650
  }, 'prompt-id-stream-1', new AbortController().signal, LlmRole.MAIN);
651
- // 4. Assert: The stream processing should throw an InvalidStreamError.
651
+ // 4. Assert: The stream processing should succeed.
652
652
  await expect((async () => {
653
653
  for await (const _ of stream) {
654
654
  // This loop consumes the stream to trigger the internal logic.
655
655
  }
656
- })()).rejects.toThrow(InvalidStreamError);
656
+ })()).resolves.not.toThrow();
657
+ // Verify history now ends with a successful model turn containing the empty parts array
658
+ const lastTurn = chat.agentHistory.get()[chat.agentHistory.length - 1];
659
+ expect(lastTurn.content.role).toBe('model');
660
+ expect(lastTurn.content.parts).toEqual([]);
657
661
  });
658
662
  it('should succeed when there is a tool call without finish reason', async () => {
659
663
  // Setup: Stream with tool call but no finish reason
@@ -715,7 +719,7 @@ describe('GeminiChat', () => {
715
719
  // Verify history has been rolled back to its initial state
716
720
  expect(chat.agentHistory.length).toBe(initialHistoryLength);
717
721
  });
718
- it('should preserve function responses during rollback when InvalidStreamError is thrown', async () => {
722
+ it('should preserve function responses and matching model turn when empty stream response is received', async () => {
719
723
  // 1. Setup history ending with a model turn containing functionCall
720
724
  chat.agentHistory.push({
721
725
  id: 'model-turn-1',
@@ -732,7 +736,7 @@ describe('GeminiChat', () => {
732
736
  },
733
737
  });
734
738
  const initialHistoryLength = chat.agentHistory.length;
735
- // Setup: Stream that will throw InvalidStreamError
739
+ // Setup: Stream with empty parts
736
740
  const streamWithNoResponseText = (async function* () {
737
741
  yield {
738
742
  candidates: [
@@ -758,11 +762,13 @@ describe('GeminiChat', () => {
758
762
  for await (const _ of stream) {
759
763
  // consume
760
764
  }
761
- })()).rejects.toThrow(InvalidStreamError);
762
- // Verify that history was NOT rolled back, i.e., function response is preserved!
763
- expect(chat.agentHistory.length).toBe(initialHistoryLength + 1);
764
- const lastTurn = chat.agentHistory.get()[chat.agentHistory.length - 1];
765
- expect(lastTurn.content.parts?.[0]?.functionResponse).toBeDefined();
765
+ })()).resolves.not.toThrow();
766
+ // Verify that history contains both the function response and the matched empty model response turn
767
+ expect(chat.agentHistory.length).toBe(initialHistoryLength + 2);
768
+ const turns = chat.agentHistory.get();
769
+ expect(turns[turns.length - 2].content.parts?.[0]?.functionResponse).toBeDefined();
770
+ expect(turns[turns.length - 1].content.role).toBe('model');
771
+ expect(turns[turns.length - 1].content.parts).toEqual([]);
766
772
  });
767
773
  it('should not fuse the next user message into a preserved tool-response turn', async () => {
768
774
  // Regression: when a stream fails mid tool-loop the tool response is
@@ -778,7 +784,7 @@ describe('GeminiChat', () => {
778
784
  parts: [{ functionCall: { name: 'test_tool', args: {} } }],
779
785
  },
780
786
  });
781
- // 1. Tool response goes back, model returns nothing -> InvalidStreamError.
787
+ // 1. Tool response goes back, model returns nothing -> succeeds under Option 2.
782
788
  vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue((async function* () {
783
789
  yield {
784
790
  candidates: [
@@ -786,7 +792,7 @@ describe('GeminiChat', () => {
786
792
  ],
787
793
  };
788
794
  })());
789
- const failingStream = await chat.sendMessageStream({ model: 'gemini-2.0-flash' }, [
795
+ const successfulStream = await chat.sendMessageStream({ model: 'gemini-2.0-flash' }, [
790
796
  {
791
797
  functionResponse: {
792
798
  name: 'test_tool',
@@ -795,10 +801,10 @@ describe('GeminiChat', () => {
795
801
  },
796
802
  ], 'prompt-id-fusion-setup', new AbortController().signal, LlmRole.MAIN);
797
803
  await expect((async () => {
798
- for await (const _ of failingStream) {
804
+ for await (const _ of successfulStream) {
799
805
  // consume
800
806
  }
801
- })()).rejects.toThrow(InvalidStreamError);
807
+ })()).resolves.not.toThrow();
802
808
  // 2. The user types a brand new instruction.
803
809
  let capturedContents = [];
804
810
  vi.mocked(mockContentGenerator.generateContentStream).mockImplementation(async (req) => {
@@ -937,7 +943,7 @@ describe('GeminiChat', () => {
937
943
  },
938
944
  });
939
945
  const initialHistoryLength = chat.agentHistory.length;
940
- // Setup: Stream that will throw InvalidStreamError
946
+ // Setup: Stream with empty parts
941
947
  const streamWithNoResponseText = (async function* () {
942
948
  yield {
943
949
  candidates: [
@@ -969,12 +975,14 @@ describe('GeminiChat', () => {
969
975
  for await (const _ of stream) {
970
976
  // consume
971
977
  }
972
- })()).rejects.toThrow(InvalidStreamError);
973
- // Verify that history was NOT rolled back, i.e., function response and sibling fileData are preserved!
974
- expect(chat.agentHistory.length).toBe(initialHistoryLength + 1);
975
- const lastTurn = chat.agentHistory.get()[chat.agentHistory.length - 1];
976
- expect(lastTurn.content.parts?.[0]?.functionResponse).toBeDefined();
977
- expect(lastTurn.content.parts?.[1]?.fileData).toBeDefined();
978
+ })()).resolves.not.toThrow();
979
+ // Verify that history contains both the function response and the matched empty model response turn
980
+ expect(chat.agentHistory.length).toBe(initialHistoryLength + 2);
981
+ const turns = chat.agentHistory.get();
982
+ expect(turns[turns.length - 2].content.parts?.[0]?.functionResponse).toBeDefined();
983
+ expect(turns[turns.length - 2].content.parts?.[1]?.fileData).toBeDefined();
984
+ expect(turns[turns.length - 1].content.role).toBe('model');
985
+ expect(turns[turns.length - 1].content.parts).toEqual([]);
978
986
  });
979
987
  it('should restore the lastPromptTokenCount baseline on history rollback when InvalidStreamError is thrown', async () => {
980
988
  // Establish an initial token count baseline
@@ -1201,6 +1209,101 @@ describe('GeminiChat', () => {
1201
1209
  // Verify history has been rolled back to its initial state
1202
1210
  expect(chat.agentHistory.length).toBe(initialHistoryLength);
1203
1211
  });
1212
+ it('should roll back the un-responded user turn from history when stream consumption is broken out of early', async () => {
1213
+ const initialHistoryLength = chat.agentHistory.length;
1214
+ // Setup: A multi-chunk stream that would succeed if fully consumed
1215
+ const activeStream = (async function* () {
1216
+ yield {
1217
+ candidates: [
1218
+ {
1219
+ content: {
1220
+ role: 'model',
1221
+ parts: [{ text: 'chunk 1' }],
1222
+ },
1223
+ },
1224
+ ],
1225
+ };
1226
+ yield {
1227
+ candidates: [
1228
+ {
1229
+ content: {
1230
+ role: 'model',
1231
+ parts: [{ text: 'chunk 2' }],
1232
+ },
1233
+ finishReason: 'STOP',
1234
+ },
1235
+ ],
1236
+ };
1237
+ })();
1238
+ vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue(activeStream);
1239
+ const stream = await chat.sendMessageStream({ model: 'gemini-2.0-flash' }, 'test early exit message', 'prompt-id-early-exit', new AbortController().signal, LlmRole.MAIN);
1240
+ // Verify the user turn WAS added during sendMessageStream
1241
+ expect(chat.agentHistory.length).toBe(initialHistoryLength + 1);
1242
+ // Consume only the first chunk and break out of the loop early!
1243
+ for await (const chunk of stream) {
1244
+ expect(chunk.type).toBe(StreamEventType.CHUNK);
1245
+ break; // Trigger early exit, calling generator.return() under the hood
1246
+ }
1247
+ // Verify history has been rolled back to its initial state because of the early exit
1248
+ expect(chat.agentHistory.length).toBe(initialHistoryLength);
1249
+ });
1250
+ it('should roll back the entire multi-turn request including function responses when a continuation stream is aborted/cancelled', async () => {
1251
+ const initialHistoryLength = chat.agentHistory.length;
1252
+ const abortController = new AbortController();
1253
+ // 1. Send the first message of the prompt. This will succeed and register prompt-id-multi-turn-abort.
1254
+ const streamFirst = (async function* () {
1255
+ yield {
1256
+ candidates: [
1257
+ {
1258
+ content: {
1259
+ role: 'model',
1260
+ parts: [{ text: 'model first response' }],
1261
+ },
1262
+ finishReason: 'STOP',
1263
+ },
1264
+ ],
1265
+ };
1266
+ })();
1267
+ vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue(streamFirst);
1268
+ const s1 = await chat.sendMessageStream({ model: 'gemini-2.0-flash' }, 'user original prompt', 'prompt-id-multi-turn-abort', new AbortController().signal, LlmRole.MAIN);
1269
+ for await (const _ of s1) {
1270
+ // consume the stream
1271
+ }
1272
+ // Expect history to contain: user, model
1273
+ expect(chat.agentHistory.length).toBe(initialHistoryLength + 2);
1274
+ // 2. Send a continuation (functionResponse), which is cancelled mid-stream.
1275
+ const streamSecond = (async function* () {
1276
+ yield {
1277
+ candidates: [
1278
+ {
1279
+ content: {
1280
+ role: 'model',
1281
+ parts: [{ text: 'partial model response before abort' }],
1282
+ },
1283
+ },
1284
+ ],
1285
+ };
1286
+ abortController.abort();
1287
+ throw new Error('User aborted a continuation stream.');
1288
+ })();
1289
+ vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue(streamSecond);
1290
+ const s2 = await chat.sendMessageStream({ model: 'gemini-2.0-flash' }, [
1291
+ {
1292
+ functionResponse: {
1293
+ id: 'call_id_1',
1294
+ name: 'my_tool',
1295
+ response: { result: 'success' },
1296
+ },
1297
+ },
1298
+ ], 'prompt-id-multi-turn-abort', abortController.signal, LlmRole.MAIN);
1299
+ await expect((async () => {
1300
+ for await (const _ of s2) {
1301
+ // consume the stream to trigger abort
1302
+ }
1303
+ })()).rejects.toThrow();
1304
+ // Verify history has been rolled back entirely to initialHistoryLength (before the original prompt started)!
1305
+ expect(chat.agentHistory.length).toBe(initialHistoryLength);
1306
+ });
1204
1307
  it('should roll back the un-responded user turn from history when an ApiError is thrown', async () => {
1205
1308
  const initialHistoryLength = chat.agentHistory.length;
1206
1309
  // Setup: Stream that throws a standard API error
@@ -1646,6 +1749,43 @@ describe('GeminiChat', () => {
1646
1749
  });
1647
1750
  expect(geminiWrite).toBeDefined();
1648
1751
  });
1752
+ it('should accept a completely empty response without throwing NO_RESPONSE_TEXT if the input is a tool response (isOriginalFunctionResponse is true)', async () => {
1753
+ // Mock an empty stream response with no text/thoughts
1754
+ const responseStream = (async function* () {
1755
+ yield {
1756
+ candidates: [
1757
+ {
1758
+ content: {
1759
+ role: 'model',
1760
+ parts: [],
1761
+ },
1762
+ finishReason: 'STOP',
1763
+ },
1764
+ ],
1765
+ };
1766
+ })();
1767
+ vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue(responseStream);
1768
+ // We send a functionResponse message so that isOriginalFunctionResponse is true
1769
+ const stream = await chat.sendMessageStream({ model: 'test-model' }, [
1770
+ {
1771
+ functionResponse: {
1772
+ name: 'edit_file',
1773
+ response: { success: true },
1774
+ },
1775
+ },
1776
+ ], 'prompt-id-empty-tool-response', new AbortController().signal, LlmRole.MAIN);
1777
+ let error = undefined;
1778
+ try {
1779
+ for await (const _ of stream) {
1780
+ // consume
1781
+ }
1782
+ }
1783
+ catch (err) {
1784
+ error = err;
1785
+ }
1786
+ // It should NOT throw InvalidStreamError NO_RESPONSE_TEXT
1787
+ expect(error).toBeUndefined();
1788
+ });
1649
1789
  });
1650
1790
  describe('addHistory', () => {
1651
1791
  it('should add a new content item to the history', () => {
@@ -1809,7 +1949,7 @@ describe('GeminiChat', () => {
1809
1949
  }),
1810
1950
  }), 'prompt-id-retry-temperature', LlmRole.MAIN);
1811
1951
  });
1812
- it('should append nudge message to systemInstruction on retry when InvalidStreamError occurs', async () => {
1952
+ it('should append nudge message on retry when InvalidStreamError occurs without altering systemInstruction', async () => {
1813
1953
  vi.mocked(mockContentGenerator.generateContentStream)
1814
1954
  .mockImplementationOnce(async () => (async function* () {
1815
1955
  yield {
@@ -1846,13 +1986,81 @@ describe('GeminiChat', () => {
1846
1986
  systemInstruction: 'Initial instruction',
1847
1987
  }),
1848
1988
  }), 'prompt-id-retry-nudge', LlmRole.MAIN);
1849
- // Second call (retry) should have nudge message appended to systemInstruction
1989
+ // Second call (retry) should preserve systemInstruction and append nudge to contents
1850
1990
  expect(mockContentGenerator.generateContentStream).toHaveBeenNthCalledWith(2, expect.objectContaining({
1851
1991
  config: expect.objectContaining({
1852
- systemInstruction: 'Initial instruction\n[System: You previously generated thoughts but failed to provide a final user-facing response. Please ensure you provide your final answer or call a tool now.]',
1992
+ systemInstruction: 'Initial instruction',
1853
1993
  }),
1994
+ contents: [
1995
+ expect.objectContaining({
1996
+ role: 'user',
1997
+ parts: [
1998
+ { text: 'test' },
1999
+ { text: '\n' + THINKING_ONLY_NUDGE_MESSAGE },
2000
+ ],
2001
+ }),
2002
+ ],
1854
2003
  }), 'prompt-id-retry-nudge', LlmRole.MAIN);
1855
2004
  });
2005
+ it('should re-apply nudge message on retry if a BeforeModel hook returns modifiedContents', async () => {
2006
+ vi.mocked(mockConfig.getEnableHooks).mockReturnValue(true);
2007
+ const modifiedHookContents = [
2008
+ {
2009
+ role: 'user',
2010
+ parts: [{ text: 'hook-modified-prompt' }],
2011
+ },
2012
+ ];
2013
+ const mockHookSystem = {
2014
+ fireBeforeModelEvent: vi.fn().mockResolvedValue({
2015
+ blocked: false,
2016
+ modifiedContents: modifiedHookContents,
2017
+ }),
2018
+ fireAfterModelEvent: vi.fn().mockResolvedValue({ response: {} }),
2019
+ fireBeforeToolSelectionEvent: vi.fn().mockResolvedValue({}),
2020
+ };
2021
+ mockConfig.getHookSystem = vi.fn().mockReturnValue(mockHookSystem);
2022
+ vi.mocked(mockContentGenerator.generateContentStream)
2023
+ .mockImplementationOnce(async () => (async function* () {
2024
+ yield {
2025
+ candidates: [
2026
+ {
2027
+ content: {
2028
+ role: 'model',
2029
+ parts: [{ thought: true, text: 'thinking...' }],
2030
+ },
2031
+ finishReason: 'STOP',
2032
+ },
2033
+ ],
2034
+ };
2035
+ })())
2036
+ .mockImplementationOnce(async () => (async function* () {
2037
+ yield {
2038
+ candidates: [
2039
+ {
2040
+ content: { parts: [{ text: 'valid response' }] },
2041
+ finishReason: 'STOP',
2042
+ },
2043
+ ],
2044
+ };
2045
+ })());
2046
+ const stream = await chat.sendMessageStream({ model: 'gemini-2.5-pro' }, 'original-test-prompt', 'prompt-id-retry-hook-modified', new AbortController().signal, LlmRole.MAIN);
2047
+ for await (const _ of stream) {
2048
+ // consume
2049
+ }
2050
+ expect(mockContentGenerator.generateContentStream).toHaveBeenCalledTimes(2);
2051
+ // The second call (retry) should have hook-modified contents WITH the nudge message appended!
2052
+ expect(mockContentGenerator.generateContentStream).toHaveBeenNthCalledWith(2, expect.objectContaining({
2053
+ contents: [
2054
+ expect.objectContaining({
2055
+ role: 'user',
2056
+ parts: [
2057
+ { text: 'hook-modified-prompt' },
2058
+ { text: '\n' + THINKING_ONLY_NUDGE_MESSAGE },
2059
+ ],
2060
+ }),
2061
+ ],
2062
+ }), 'prompt-id-retry-hook-modified', LlmRole.MAIN);
2063
+ });
1856
2064
  it('should fail after all retries on persistent invalid content and report metrics', async () => {
1857
2065
  vi.mocked(mockContentGenerator.generateContentStream).mockImplementation(async () => (async function* () {
1858
2066
  yield {
@@ -3346,5 +3554,159 @@ describe('GeminiChat', () => {
3346
3554
  expect(stripped[0]).toHaveProperty('metadata', { some: 'value' });
3347
3555
  });
3348
3556
  });
3557
+ describe('isValidContent via curated history', () => {
3558
+ it('should strip turns with truly empty text parts and no other keys', () => {
3559
+ const chat = new GeminiChat(mockConfig, '', [], []);
3560
+ chat.setHistory([
3561
+ { role: 'user', parts: [{ text: 'User prompt' }] },
3562
+ { role: 'model', parts: [{ text: '' }] },
3563
+ ]);
3564
+ // Curated history should strip the invalid model turn
3565
+ const history = chat.getHistory(true);
3566
+ expect(history).toHaveLength(1);
3567
+ expect(history[0].role).toBe('user');
3568
+ });
3569
+ it('should preserve turns with empty text parts that contain functionCall', () => {
3570
+ const chat = new GeminiChat(mockConfig, '', [], []);
3571
+ chat.setHistory([
3572
+ { role: 'user', parts: [{ text: 'User prompt' }] },
3573
+ {
3574
+ role: 'model',
3575
+ parts: [
3576
+ {
3577
+ text: '',
3578
+ functionCall: { name: 'test_tool', args: {} },
3579
+ },
3580
+ ],
3581
+ },
3582
+ ]);
3583
+ // Curated history should preserve the valid model turn because of functionCall
3584
+ const history = chat.getHistory(true);
3585
+ expect(history).toHaveLength(2);
3586
+ expect(history[1].role).toBe('model');
3587
+ });
3588
+ it('should preserve turns with empty text parts that contain functionResponse, inlineData, or fileData', () => {
3589
+ const chat = new GeminiChat(mockConfig, '', [], []);
3590
+ chat.setHistory([
3591
+ { role: 'user', parts: [{ text: 'User prompt' }] },
3592
+ {
3593
+ role: 'model',
3594
+ parts: [
3595
+ {
3596
+ text: '',
3597
+ functionResponse: { name: 'test_tool', response: {} },
3598
+ },
3599
+ ],
3600
+ },
3601
+ ]);
3602
+ const history = chat.getHistory(true);
3603
+ expect(history).toHaveLength(2);
3604
+ expect(history[1].role).toBe('model');
3605
+ });
3606
+ it('should preserve turns with empty text parts that contain executableCode or codeExecutionResult', () => {
3607
+ const chat = new GeminiChat(mockConfig, '', [], []);
3608
+ chat.setHistory([
3609
+ { role: 'user', parts: [{ text: 'User prompt' }] },
3610
+ {
3611
+ role: 'model',
3612
+ parts: [
3613
+ {
3614
+ text: '',
3615
+ executableCode: {
3616
+ language: Language.PYTHON,
3617
+ code: 'print("test")',
3618
+ },
3619
+ },
3620
+ ],
3621
+ },
3622
+ ]);
3623
+ const history = chat.getHistory(true);
3624
+ expect(history).toHaveLength(2);
3625
+ expect(history[1].role).toBe('model');
3626
+ });
3627
+ });
3628
+ describe('applyRetryNudge', () => {
3629
+ it('should return original contents if nudge message is empty', () => {
3630
+ const contents = [
3631
+ { role: 'user', parts: [{ text: 'Hello' }] },
3632
+ ];
3633
+ const result = applyRetryNudge(contents, '');
3634
+ expect(result).toEqual(contents);
3635
+ });
3636
+ it('should append THINKING_ONLY_NUDGE_MESSAGE to the final user turn', () => {
3637
+ const contents = [
3638
+ { role: 'user', parts: [{ text: 'Hello' }] },
3639
+ ];
3640
+ const result = applyRetryNudge(contents, THINKING_ONLY_NUDGE_MESSAGE);
3641
+ expect(result).toHaveLength(1);
3642
+ expect(result[0].parts).toHaveLength(2);
3643
+ expect(result[0].parts[0].text).toBe('Hello');
3644
+ expect(result[0].parts[1].text).toBe('\n' + THINKING_ONLY_NUDGE_MESSAGE);
3645
+ });
3646
+ it('should append NO_RESPONSE_TEXT_NUDGE_MESSAGE to the final user turn', () => {
3647
+ const contents = [
3648
+ { role: 'user', parts: [{ text: 'Hello' }] },
3649
+ ];
3650
+ const result = applyRetryNudge(contents, NO_RESPONSE_TEXT_NUDGE_MESSAGE);
3651
+ expect(result).toHaveLength(1);
3652
+ expect(result[0].parts).toHaveLength(2);
3653
+ expect(result[0].parts[0].text).toBe('Hello');
3654
+ expect(result[0].parts[1].text).toBe('\n' + NO_RESPONSE_TEXT_NUDGE_MESSAGE);
3655
+ });
3656
+ it('should create a new user turn if history is empty', () => {
3657
+ const result = applyRetryNudge([], THINKING_ONLY_NUDGE_MESSAGE);
3658
+ expect(result).toHaveLength(1);
3659
+ expect(result[0].role).toBe('user');
3660
+ expect(result[0].parts).toEqual([{ text: THINKING_ONLY_NUDGE_MESSAGE }]);
3661
+ });
3662
+ it('should create a new user turn if the last turn is from model', () => {
3663
+ const contents = [
3664
+ { role: 'model', parts: [{ text: 'AI response' }] },
3665
+ ];
3666
+ const result = applyRetryNudge(contents, NO_RESPONSE_TEXT_NUDGE_MESSAGE);
3667
+ expect(result).toHaveLength(2);
3668
+ expect(result[0]).toEqual(contents[0]);
3669
+ expect(result[1].role).toBe('user');
3670
+ expect(result[1].parts).toEqual([
3671
+ { text: NO_RESPONSE_TEXT_NUDGE_MESSAGE },
3672
+ ]);
3673
+ });
3674
+ it('should insert synthetic model turn and dedicated user turn if the last turn is user with functionResponse', () => {
3675
+ const contents = [
3676
+ {
3677
+ role: 'user',
3678
+ parts: [
3679
+ {
3680
+ functionResponse: {
3681
+ name: 'Edit',
3682
+ response: { result: 'success' },
3683
+ },
3684
+ },
3685
+ ],
3686
+ },
3687
+ ];
3688
+ const result = applyRetryNudge(contents, NO_RESPONSE_TEXT_NUDGE_MESSAGE);
3689
+ expect(result).toHaveLength(3);
3690
+ expect(result[0]).toEqual(contents[0]);
3691
+ expect(result[1].role).toBe('model');
3692
+ expect(result[1].parts).toEqual([
3693
+ { text: '[Tool execution completed.]' },
3694
+ ]);
3695
+ expect(result[2].role).toBe('user');
3696
+ expect(result[2].parts).toEqual([
3697
+ { text: NO_RESPONSE_TEXT_NUDGE_MESSAGE },
3698
+ ]);
3699
+ });
3700
+ it('should not duplicate the nudge message if it is already present in contents', () => {
3701
+ const contents = [
3702
+ {
3703
+ role: 'user',
3704
+ parts: [{ text: 'Hello\n' + THINKING_ONLY_NUDGE_MESSAGE }],
3705
+ },
3706
+ ];
3707
+ const result = applyRetryNudge(contents, THINKING_ONLY_NUDGE_MESSAGE);
3708
+ expect(result).toEqual(contents);
3709
+ });
3710
+ });
3349
3711
  });
3350
3712
  //# sourceMappingURL=geminiChat.test.js.map