@google/gemini-cli-core 0.53.0 → 0.53.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 (48) hide show
  1. package/dist/google-gemini-cli-core-0.53.0.tgz +0 -0
  2. package/dist/src/agent/event-translator.js +7 -1
  3. package/dist/src/agent/event-translator.js.map +1 -1
  4. package/dist/src/agent/event-translator.test.js +24 -1
  5. package/dist/src/agent/event-translator.test.js.map +1 -1
  6. package/dist/src/agent/legacy-agent-session.js +8 -5
  7. package/dist/src/agent/legacy-agent-session.js.map +1 -1
  8. package/dist/src/core/agentChatHistory.d.ts +5 -0
  9. package/dist/src/core/agentChatHistory.js +9 -0
  10. package/dist/src/core/agentChatHistory.js.map +1 -1
  11. package/dist/src/core/agentChatHistory.test.d.ts +6 -0
  12. package/dist/src/core/agentChatHistory.test.js +95 -0
  13. package/dist/src/core/agentChatHistory.test.js.map +1 -0
  14. package/dist/src/core/geminiChat.d.ts +4 -4
  15. package/dist/src/core/geminiChat.js +106 -32
  16. package/dist/src/core/geminiChat.js.map +1 -1
  17. package/dist/src/core/geminiChat.test.js +586 -10
  18. package/dist/src/core/geminiChat.test.js.map +1 -1
  19. package/dist/src/core/turn.d.ts +4 -0
  20. package/dist/src/core/turn.js +7 -1
  21. package/dist/src/core/turn.js.map +1 -1
  22. package/dist/src/core/turn.test.js +9 -1
  23. package/dist/src/core/turn.test.js.map +1 -1
  24. package/dist/src/generated/git-commit.d.ts +2 -2
  25. package/dist/src/generated/git-commit.js +2 -2
  26. package/dist/src/prompts/snippets.js +10 -0
  27. package/dist/src/prompts/snippets.js.map +1 -1
  28. package/dist/src/scheduler/scheduler.js +8 -0
  29. package/dist/src/scheduler/scheduler.js.map +1 -1
  30. package/dist/src/scheduler/scheduler.test.js +2 -2
  31. package/dist/src/scheduler/scheduler.test.js.map +1 -1
  32. package/dist/src/services/modelConfigService.d.ts +1 -0
  33. package/dist/src/services/modelConfigService.js.map +1 -1
  34. package/dist/src/telemetry/uiTelemetry.d.ts +2 -0
  35. package/dist/src/telemetry/uiTelemetry.js +21 -0
  36. package/dist/src/telemetry/uiTelemetry.js.map +1 -1
  37. package/dist/src/telemetry/uiTelemetry.test.js +39 -0
  38. package/dist/src/telemetry/uiTelemetry.test.js.map +1 -1
  39. package/dist/src/utils/constants.d.ts +7 -0
  40. package/dist/src/utils/constants.js +7 -0
  41. package/dist/src/utils/constants.js.map +1 -1
  42. package/dist/src/utils/messageInspectors.js +2 -1
  43. package/dist/src/utils/messageInspectors.js.map +1 -1
  44. package/dist/src/utils/messageInspectors.test.d.ts +6 -0
  45. package/dist/src/utils/messageInspectors.test.js +156 -0
  46. package/dist/src/utils/messageInspectors.test.js.map +1 -0
  47. package/dist/tsconfig.tsbuildinfo +1 -1
  48. package/package.json +1 -1
@@ -678,6 +678,378 @@ describe('GeminiChat', () => {
678
678
  }
679
679
  })()).resolves.not.toThrow();
680
680
  });
681
+ it('should roll back the un-responded user turn from history when InvalidStreamError is thrown', async () => {
682
+ const initialHistoryLength = chat.agentHistory.length;
683
+ // Setup: Stream with text but no finish reason and no tool call (will trigger InvalidStreamError)
684
+ const streamWithoutFinishReason = (async function* () {
685
+ yield {
686
+ candidates: [
687
+ {
688
+ content: {
689
+ role: 'model',
690
+ parts: [{ text: 'some response' }],
691
+ },
692
+ // No finishReason
693
+ },
694
+ ],
695
+ };
696
+ })();
697
+ vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue(streamWithoutFinishReason);
698
+ const stream = await chat.sendMessageStream({ model: 'gemini-2.0-flash' }, 'test message to roll back', 'prompt-id-rollback', new AbortController().signal, LlmRole.MAIN);
699
+ // Verify the user turn WAS added during sendMessageStream
700
+ expect(chat.agentHistory.length).toBe(initialHistoryLength + 1);
701
+ expect(chat.getHistory()[initialHistoryLength].parts?.[0]?.text).toBe('test message to roll back');
702
+ await expect((async () => {
703
+ for await (const _ of stream) {
704
+ // consume stream to trigger validation error
705
+ }
706
+ })()).rejects.toThrow(InvalidStreamError);
707
+ // Verify history has been rolled back to its initial state
708
+ expect(chat.agentHistory.length).toBe(initialHistoryLength);
709
+ });
710
+ it('should preserve function responses during rollback when InvalidStreamError is thrown', async () => {
711
+ // 1. Setup history ending with a model turn containing functionCall
712
+ chat.agentHistory.push({
713
+ id: 'model-turn-1',
714
+ content: {
715
+ role: 'model',
716
+ parts: [
717
+ {
718
+ functionCall: {
719
+ name: 'test_tool',
720
+ args: {},
721
+ },
722
+ },
723
+ ],
724
+ },
725
+ });
726
+ const initialHistoryLength = chat.agentHistory.length;
727
+ // Setup: Stream that will throw InvalidStreamError
728
+ const streamWithNoResponseText = (async function* () {
729
+ yield {
730
+ candidates: [
731
+ {
732
+ content: { role: 'model', parts: [] },
733
+ finishReason: 'STOP',
734
+ },
735
+ ],
736
+ };
737
+ })();
738
+ vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue(streamWithNoResponseText);
739
+ const stream = await chat.sendMessageStream({ model: 'gemini-2.0-flash' }, [
740
+ {
741
+ functionResponse: {
742
+ name: 'test_tool',
743
+ response: { success: true },
744
+ },
745
+ },
746
+ ], 'prompt-id-function-response-rollback', new AbortController().signal, LlmRole.MAIN);
747
+ // Verify the function response was added
748
+ expect(chat.agentHistory.length).toBe(initialHistoryLength + 1);
749
+ await expect((async () => {
750
+ for await (const _ of stream) {
751
+ // consume
752
+ }
753
+ })()).rejects.toThrow(InvalidStreamError);
754
+ // Verify that history was NOT rolled back, i.e., function response is preserved!
755
+ expect(chat.agentHistory.length).toBe(initialHistoryLength + 1);
756
+ const lastTurn = chat.agentHistory.get()[chat.agentHistory.length - 1];
757
+ expect(lastTurn.content.parts?.[0]?.functionResponse).toBeDefined();
758
+ });
759
+ it('should preserve mixed multimodal function responses during rollback when InvalidStreamError is thrown (regression)', async () => {
760
+ // 1. Setup history ending with a model turn containing functionCall
761
+ chat.agentHistory.push({
762
+ id: 'model-turn-1',
763
+ content: {
764
+ role: 'model',
765
+ parts: [
766
+ {
767
+ functionCall: {
768
+ name: 'test_tool',
769
+ args: {},
770
+ },
771
+ },
772
+ ],
773
+ },
774
+ });
775
+ const initialHistoryLength = chat.agentHistory.length;
776
+ // Setup: Stream that will throw InvalidStreamError
777
+ const streamWithNoResponseText = (async function* () {
778
+ yield {
779
+ candidates: [
780
+ {
781
+ content: { role: 'model', parts: [] },
782
+ finishReason: 'STOP',
783
+ },
784
+ ],
785
+ };
786
+ })();
787
+ vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue(streamWithNoResponseText);
788
+ const stream = await chat.sendMessageStream({ model: 'gemini-2.0-flash' }, [
789
+ {
790
+ functionResponse: {
791
+ name: 'test_tool',
792
+ response: { success: true },
793
+ },
794
+ },
795
+ {
796
+ fileData: {
797
+ mimeType: 'image/png',
798
+ fileUri: 'https://example.com/image.png',
799
+ },
800
+ },
801
+ ], 'prompt-id-mixed-multimodal-rollback', new AbortController().signal, LlmRole.MAIN);
802
+ // Verify the function response was added
803
+ expect(chat.agentHistory.length).toBe(initialHistoryLength + 1);
804
+ await expect((async () => {
805
+ for await (const _ of stream) {
806
+ // consume
807
+ }
808
+ })()).rejects.toThrow(InvalidStreamError);
809
+ // Verify that history was NOT rolled back, i.e., function response and sibling fileData are preserved!
810
+ expect(chat.agentHistory.length).toBe(initialHistoryLength + 1);
811
+ const lastTurn = chat.agentHistory.get()[chat.agentHistory.length - 1];
812
+ expect(lastTurn.content.parts?.[0]?.functionResponse).toBeDefined();
813
+ expect(lastTurn.content.parts?.[1]?.fileData).toBeDefined();
814
+ });
815
+ it('should restore the lastPromptTokenCount baseline on history rollback when InvalidStreamError is thrown', async () => {
816
+ // Establish an initial token count baseline
817
+ const initialBaseline = chat.getLastPromptTokenCount();
818
+ // Setup: Stream that yields usageMetadata updating token count and then throws an InvalidStreamError
819
+ const streamWithUsageAndFailure = (async function* () {
820
+ yield {
821
+ candidates: [
822
+ {
823
+ content: {
824
+ role: 'model',
825
+ parts: [{ text: '' }],
826
+ },
827
+ finishReason: 'STOP',
828
+ },
829
+ ],
830
+ usageMetadata: {
831
+ promptTokenCount: initialBaseline + 500, // mock updated larger token count
832
+ candidatesTokenCount: 10,
833
+ totalTokenCount: initialBaseline + 510,
834
+ },
835
+ };
836
+ })();
837
+ vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue(streamWithUsageAndFailure);
838
+ const stream = await chat.sendMessageStream({ model: 'gemini-2.0-flash' }, 'test prompt for token baseline rollback', 'prompt-id-baseline-rollback', new AbortController().signal, LlmRole.MAIN);
839
+ await expect((async () => {
840
+ for await (const _ of stream) {
841
+ // consume stream to trigger validation error
842
+ }
843
+ })()).rejects.toThrow(InvalidStreamError);
844
+ // Verify that the prompt token count has been successfully restored to its initial baseline
845
+ expect(chat.getLastPromptTokenCount()).toBe(initialBaseline);
846
+ });
847
+ it('should not write failed retry attempts to the chat recording service', async () => {
848
+ const recordMessageSpy = vi.spyOn(chat.getChatRecordingService(), 'recordMessage');
849
+ const recordSyntheticMessageSpy = vi.spyOn(chat.getChatRecordingService(), 'recordSyntheticMessage');
850
+ // Attempt 1: returns invalid stream (triggering InvalidStreamError)
851
+ vi.mocked(mockContentGenerator.generateContentStream)
852
+ .mockImplementationOnce(async () => (async function* () {
853
+ yield {
854
+ candidates: [
855
+ {
856
+ content: { role: 'model', parts: [{ text: '' }] },
857
+ finishReason: 'STOP',
858
+ },
859
+ ],
860
+ };
861
+ })())
862
+ // Attempt 2: returns a valid response
863
+ .mockImplementationOnce(async () => (async function* () {
864
+ yield {
865
+ candidates: [
866
+ {
867
+ content: {
868
+ role: 'model',
869
+ parts: [{ text: 'successful retry response' }],
870
+ },
871
+ finishReason: 'STOP',
872
+ },
873
+ ],
874
+ };
875
+ })());
876
+ const stream = await chat.sendMessageStream({ model: 'gemini-2.0-flash' }, 'test prompt for recording deferral', 'prompt-id-recording-deferral', new AbortController().signal, LlmRole.MAIN);
877
+ for await (const _ of stream) {
878
+ // consume stream completely
879
+ }
880
+ // 1. The execution was successful, and final history turn has the correct text
881
+ const lastHistoryTurn = chat.agentHistory.get()[chat.agentHistory.length - 1];
882
+ expect(lastHistoryTurn.content.parts?.[0]?.text).toBe('successful retry response');
883
+ // 2. recordMessage was only called for the successful turn
884
+ // (The failed attempt was NEVER recorded!)
885
+ const successfulCalls = recordMessageSpy.mock.calls.filter((call) => {
886
+ const payload = call[0];
887
+ return (typeof payload === 'object' &&
888
+ payload !== null &&
889
+ payload.content === 'successful retry response');
890
+ });
891
+ const failedCalls = recordMessageSpy.mock.calls.filter((call) => {
892
+ const payload = call[0];
893
+ return (typeof payload === 'object' &&
894
+ payload !== null &&
895
+ payload.content === '');
896
+ });
897
+ expect(successfulCalls.length).toBe(1);
898
+ expect(failedCalls.length).toBe(0);
899
+ expect(recordSyntheticMessageSpy).not.toHaveBeenCalled();
900
+ recordMessageSpy.mockRestore();
901
+ recordSyntheticMessageSpy.mockRestore();
902
+ });
903
+ it('should not record thoughts or usage metadata to chatRecordingService from failed stream attempts', async () => {
904
+ const recordThoughtSpy = vi.spyOn(chat.getChatRecordingService(), 'recordThought');
905
+ const recordMessageTokensSpy = vi.spyOn(chat.getChatRecordingService(), 'recordMessageTokens');
906
+ // Attempt 1: returns invalid stream with thoughts and usage metadata (triggering InvalidStreamError)
907
+ vi.mocked(mockContentGenerator.generateContentStream)
908
+ .mockImplementationOnce(async () => (async function* () {
909
+ yield {
910
+ candidates: [
911
+ {
912
+ content: {
913
+ role: 'model',
914
+ parts: [
915
+ {
916
+ thought: true,
917
+ text: '**Stale subject** Stale description',
918
+ },
919
+ ],
920
+ },
921
+ finishReason: 'STOP',
922
+ },
923
+ ],
924
+ usageMetadata: {
925
+ promptTokenCount: 1000,
926
+ candidatesTokenCount: 50,
927
+ totalTokenCount: 1050,
928
+ },
929
+ };
930
+ })())
931
+ // Attempt 2: returns a valid response with separate thoughts and usage metadata
932
+ .mockImplementationOnce(async () => (async function* () {
933
+ yield {
934
+ candidates: [
935
+ {
936
+ content: {
937
+ role: 'model',
938
+ parts: [
939
+ {
940
+ thought: true,
941
+ text: '**Fresh subject** Fresh description',
942
+ },
943
+ { text: 'successful retry response' },
944
+ ],
945
+ },
946
+ finishReason: 'STOP',
947
+ },
948
+ ],
949
+ usageMetadata: {
950
+ promptTokenCount: 2000,
951
+ candidatesTokenCount: 100,
952
+ totalTokenCount: 2100,
953
+ },
954
+ };
955
+ })());
956
+ const stream = await chat.sendMessageStream({ model: 'gemini-2.0-flash' }, 'test prompt for metadata deferral', 'prompt-id-metadata-deferral', new AbortController().signal, LlmRole.MAIN);
957
+ for await (const _ of stream) {
958
+ // consume stream completely
959
+ }
960
+ // Verify that recordThought was NOT called with the first (failed) attempt's thoughts
961
+ expect(recordThoughtSpy).toHaveBeenCalledTimes(1);
962
+ expect(recordThoughtSpy).toHaveBeenCalledWith({
963
+ subject: 'Fresh subject',
964
+ description: 'Fresh description',
965
+ });
966
+ expect(recordThoughtSpy).not.toHaveBeenCalledWith({
967
+ subject: 'Stale subject',
968
+ description: 'Stale description',
969
+ });
970
+ // Verify that recordMessageTokens was only called with the second (successful) attempt's metadata
971
+ expect(recordMessageTokensSpy).toHaveBeenCalledTimes(1);
972
+ expect(recordMessageTokensSpy).toHaveBeenCalledWith({
973
+ promptTokenCount: 2000,
974
+ candidatesTokenCount: 100,
975
+ totalTokenCount: 2100,
976
+ });
977
+ // Verify that the prompt token count is correct
978
+ expect(chat.getLastPromptTokenCount()).toBe(2000);
979
+ recordThoughtSpy.mockRestore();
980
+ recordMessageTokensSpy.mockRestore();
981
+ });
982
+ it('should sync the chat recording service on history rollback when InvalidStreamError is thrown', async () => {
983
+ const initialHistoryLength = chat.agentHistory.length;
984
+ const updateSpy = vi.spyOn(chat.getChatRecordingService(), 'updateMessagesFromHistory');
985
+ // Setup: Stream with text but no finish reason and no tool call (will trigger InvalidStreamError)
986
+ const streamWithoutFinishReason = (async function* () {
987
+ yield {
988
+ candidates: [
989
+ {
990
+ content: {
991
+ role: 'model',
992
+ parts: [{ text: 'some response' }],
993
+ },
994
+ // No finishReason
995
+ },
996
+ ],
997
+ };
998
+ })();
999
+ vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue(streamWithoutFinishReason);
1000
+ const stream = await chat.sendMessageStream({ model: 'gemini-2.0-flash' }, 'test disk sync rollback', 'prompt-id-rollback-sync', new AbortController().signal, LlmRole.MAIN);
1001
+ await expect((async () => {
1002
+ for await (const _ of stream) {
1003
+ // consume stream to trigger validation error
1004
+ }
1005
+ })()).rejects.toThrow(InvalidStreamError);
1006
+ // Verify history has been rolled back to its initial state
1007
+ expect(chat.agentHistory.length).toBe(initialHistoryLength);
1008
+ // Verify chatRecordingService.updateMessagesFromHistory was called to sync the disk
1009
+ expect(updateSpy).toHaveBeenCalled();
1010
+ updateSpy.mockRestore();
1011
+ });
1012
+ it('should roll back the un-responded user turn from history when the stream is aborted/cancelled', async () => {
1013
+ const initialHistoryLength = chat.agentHistory.length;
1014
+ const abortController = new AbortController();
1015
+ // Setup: Stream that aborts/fails mid-generation
1016
+ const streamWithAbort = (async function* () {
1017
+ yield {
1018
+ candidates: [
1019
+ {
1020
+ content: {
1021
+ role: 'model',
1022
+ parts: [{ text: 'some text' }],
1023
+ },
1024
+ },
1025
+ ],
1026
+ };
1027
+ abortController.abort();
1028
+ throw new Error('User aborted a request.');
1029
+ })();
1030
+ vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue(streamWithAbort);
1031
+ const stream = await chat.sendMessageStream({ model: 'gemini-2.0-flash' }, 'test abort message', 'prompt-id-abort', abortController.signal, LlmRole.MAIN);
1032
+ await expect((async () => {
1033
+ for await (const _ of stream) {
1034
+ // consume stream to trigger abort error
1035
+ }
1036
+ })()).rejects.toThrow();
1037
+ // Verify history has been rolled back to its initial state
1038
+ expect(chat.agentHistory.length).toBe(initialHistoryLength);
1039
+ });
1040
+ it('should roll back the un-responded user turn from history when an ApiError is thrown', async () => {
1041
+ const initialHistoryLength = chat.agentHistory.length;
1042
+ // Setup: Stream that throws a standard API error
1043
+ vi.mocked(mockContentGenerator.generateContentStream).mockRejectedValue(new Error('API rate limit reached'));
1044
+ const stream = await chat.sendMessageStream({ model: 'gemini-2.0-flash' }, 'test api error message', 'prompt-id-api-error', new AbortController().signal, LlmRole.MAIN);
1045
+ await expect((async () => {
1046
+ for await (const _ of stream) {
1047
+ // consume stream
1048
+ }
1049
+ })()).rejects.toThrow('API rate limit reached');
1050
+ // Verify history has been rolled back to its initial state
1051
+ expect(chat.agentHistory.length).toBe(initialHistoryLength);
1052
+ });
681
1053
  it('should throw InvalidStreamError when no tool call and no finish reason', async () => {
682
1054
  // Setup: Stream with text but no finish reason and no tool call
683
1055
  const streamWithoutFinishReason = (async function* () {
@@ -701,7 +1073,7 @@ describe('GeminiChat', () => {
701
1073
  }
702
1074
  })()).rejects.toThrow(InvalidStreamError);
703
1075
  });
704
- it('should throw InvalidStreamError without retrying when no tool call and empty response text', async () => {
1076
+ it('should retry when no tool call and empty response text, and succeed if a retry succeeds', async () => {
705
1077
  vi.mocked(mockContentGenerator.generateContentStream)
706
1078
  .mockImplementationOnce(async () =>
707
1079
  // First attempt: finish reason is present, but the stream has no
@@ -720,7 +1092,7 @@ describe('GeminiChat', () => {
720
1092
  };
721
1093
  })())
722
1094
  .mockImplementationOnce(async () =>
723
- // This would succeed if NO_RESPONSE_TEXT were retried.
1095
+ // Second attempt: succeeds
724
1096
  (async function* () {
725
1097
  yield {
726
1098
  candidates: [
@@ -735,13 +1107,43 @@ describe('GeminiChat', () => {
735
1107
  };
736
1108
  })());
737
1109
  const stream = await chat.sendMessageStream({ model: 'gemini-2.0-flash' }, 'test message', 'prompt-id-1', new AbortController().signal, LlmRole.MAIN);
1110
+ const chunks = [];
1111
+ for await (const chunk of stream) {
1112
+ if (chunk.type === StreamEventType.CHUNK) {
1113
+ chunks.push(chunk.value);
1114
+ }
1115
+ }
1116
+ expect(mockContentGenerator.generateContentStream).toHaveBeenCalledTimes(2);
1117
+ expect(mockLogContentRetry).toHaveBeenCalledTimes(1);
1118
+ expect(mockLogContentRetryFailure).not.toHaveBeenCalled();
1119
+ expect(chunks.length).toBe(2);
1120
+ expect(chunks[0].candidates?.[0]?.content?.parts?.[0]?.thought).toBe(true);
1121
+ expect(chunks[1].candidates?.[0]?.content?.parts?.[0]?.text).toBe('valid response after retry');
1122
+ });
1123
+ it('should retry when no tool call and empty response text, and throw InvalidStreamError after exhausting retries', async () => {
1124
+ vi.mocked(mockContentGenerator.generateContentStream).mockImplementation(async () =>
1125
+ // All attempts return empty response text
1126
+ (async function* () {
1127
+ yield {
1128
+ candidates: [
1129
+ {
1130
+ content: {
1131
+ role: 'model',
1132
+ parts: [{ thought: true, text: 'thinking...' }],
1133
+ },
1134
+ finishReason: 'STOP',
1135
+ },
1136
+ ],
1137
+ };
1138
+ })());
1139
+ const stream = await chat.sendMessageStream({ model: 'gemini-2.0-flash' }, 'test message', 'prompt-id-1', new AbortController().signal, LlmRole.MAIN);
738
1140
  await expect((async () => {
739
1141
  for await (const _ of stream) {
740
1142
  // consume stream
741
1143
  }
742
1144
  })()).rejects.toThrow(InvalidStreamError);
743
- expect(mockContentGenerator.generateContentStream).toHaveBeenCalledTimes(1);
744
- expect(mockLogContentRetry).not.toHaveBeenCalled();
1145
+ expect(mockContentGenerator.generateContentStream).toHaveBeenCalledTimes(4);
1146
+ expect(mockLogContentRetry).toHaveBeenCalledTimes(3);
745
1147
  expect(mockLogContentRetryFailure).toHaveBeenCalledTimes(1);
746
1148
  });
747
1149
  it('should succeed when there is finish reason and response text', async () => {
@@ -833,6 +1235,113 @@ describe('GeminiChat', () => {
833
1235
  e.value.candidates?.[0]?.content?.parts?.[0]?.text ===
834
1236
  'Success after retry')).toBe(true);
835
1237
  });
1238
+ it('should throw InvalidStreamError with type MAX_TOKENS_EXCEEDED when finishReason is MAX_TOKENS and text is empty', async () => {
1239
+ vi.mocked(mockContentGenerator.generateContentStream).mockImplementation(async () => (async function* () {
1240
+ yield {
1241
+ candidates: [
1242
+ {
1243
+ content: { role: 'model', parts: [] },
1244
+ finishReason: 'MAX_TOKENS',
1245
+ },
1246
+ ],
1247
+ };
1248
+ })());
1249
+ const stream = await chat.sendMessageStream({ model: 'gemini-2.5-pro' }, 'test', 'prompt-id-max-tokens', new AbortController().signal, LlmRole.MAIN);
1250
+ let error;
1251
+ try {
1252
+ const chunks = [];
1253
+ for await (const chunk of stream) {
1254
+ chunks.push(chunk);
1255
+ }
1256
+ }
1257
+ catch (err) {
1258
+ error = err;
1259
+ }
1260
+ expect(error).toBeInstanceOf(InvalidStreamError);
1261
+ expect(error.type).toBe('MAX_TOKENS_EXCEEDED');
1262
+ });
1263
+ it('should throw InvalidStreamError with type THINKING_ONLY_RESPONSE when response contains thoughts but text is empty', async () => {
1264
+ vi.mocked(mockContentGenerator.generateContentStream).mockImplementation(async () => (async function* () {
1265
+ yield {
1266
+ candidates: [
1267
+ {
1268
+ content: {
1269
+ role: 'model',
1270
+ parts: [{ thought: true, text: 'thinking...' }],
1271
+ },
1272
+ finishReason: 'STOP',
1273
+ },
1274
+ ],
1275
+ };
1276
+ })());
1277
+ const stream = await chat.sendMessageStream({ model: 'gemini-2.5-pro' }, 'test', 'prompt-id-thoughts-only', new AbortController().signal, LlmRole.MAIN);
1278
+ let error;
1279
+ try {
1280
+ const chunks = [];
1281
+ for await (const chunk of stream) {
1282
+ chunks.push(chunk);
1283
+ }
1284
+ }
1285
+ catch (err) {
1286
+ error = err;
1287
+ }
1288
+ expect(error).toBeInstanceOf(InvalidStreamError);
1289
+ expect(error.type).toBe('THINKING_ONLY_RESPONSE');
1290
+ });
1291
+ it('should throw InvalidStreamError when response consists only of zero-width or invisible characters', async () => {
1292
+ vi.mocked(mockContentGenerator.generateContentStream).mockImplementation(async () => (async function* () {
1293
+ yield {
1294
+ candidates: [
1295
+ {
1296
+ content: {
1297
+ role: 'model',
1298
+ parts: [{ text: '\u200B\uFEFF\u200D' }],
1299
+ },
1300
+ finishReason: 'STOP',
1301
+ },
1302
+ ],
1303
+ };
1304
+ })());
1305
+ const stream = await chat.sendMessageStream({ model: 'gemini-2.5-pro' }, 'test', 'prompt-id-invisible-only', new AbortController().signal, LlmRole.MAIN);
1306
+ let error;
1307
+ try {
1308
+ for await (const _ of stream) {
1309
+ // consume
1310
+ }
1311
+ }
1312
+ catch (err) {
1313
+ error = err;
1314
+ }
1315
+ expect(error).toBeInstanceOf(InvalidStreamError);
1316
+ expect(error.type).toBe('NO_RESPONSE_TEXT');
1317
+ });
1318
+ it('should throw InvalidStreamError when response consists only of HTML or Markdown comment blocks', async () => {
1319
+ vi.mocked(mockContentGenerator.generateContentStream).mockImplementation(async () => (async function* () {
1320
+ yield {
1321
+ candidates: [
1322
+ {
1323
+ content: {
1324
+ role: 'model',
1325
+ parts: [{ text: '<!-- invisible comment -->' }],
1326
+ },
1327
+ finishReason: 'STOP',
1328
+ },
1329
+ ],
1330
+ };
1331
+ })());
1332
+ const stream = await chat.sendMessageStream({ model: 'gemini-2.5-pro' }, 'test', 'prompt-id-comments-only', new AbortController().signal, LlmRole.MAIN);
1333
+ let error;
1334
+ try {
1335
+ for await (const _ of stream) {
1336
+ // consume
1337
+ }
1338
+ }
1339
+ catch (err) {
1340
+ error = err;
1341
+ }
1342
+ expect(error).toBeInstanceOf(InvalidStreamError);
1343
+ expect(error.type).toBe('NO_RESPONSE_TEXT');
1344
+ });
836
1345
  it('should call generateContentStream with the correct parameters', async () => {
837
1346
  const response = (async function* () {
838
1347
  yield {
@@ -1136,6 +1645,50 @@ describe('GeminiChat', () => {
1136
1645
  }),
1137
1646
  }), 'prompt-id-retry-temperature', LlmRole.MAIN);
1138
1647
  });
1648
+ it('should append nudge message to systemInstruction on retry when InvalidStreamError occurs', async () => {
1649
+ vi.mocked(mockContentGenerator.generateContentStream)
1650
+ .mockImplementationOnce(async () => (async function* () {
1651
+ yield {
1652
+ candidates: [
1653
+ {
1654
+ content: {
1655
+ role: 'model',
1656
+ parts: [{ thought: true, text: 'thinking...' }],
1657
+ },
1658
+ finishReason: 'STOP',
1659
+ },
1660
+ ],
1661
+ };
1662
+ })())
1663
+ .mockImplementationOnce(async () => (async function* () {
1664
+ yield {
1665
+ candidates: [
1666
+ {
1667
+ content: { parts: [{ text: 'valid response after nudge' }] },
1668
+ finishReason: 'STOP',
1669
+ },
1670
+ ],
1671
+ };
1672
+ })());
1673
+ chat.setSystemInstruction('Initial instruction');
1674
+ const stream = await chat.sendMessageStream({ model: 'gemini-2.5-pro' }, 'test', 'prompt-id-retry-nudge', new AbortController().signal, LlmRole.MAIN);
1675
+ for await (const _ of stream) {
1676
+ // consume
1677
+ }
1678
+ expect(mockContentGenerator.generateContentStream).toHaveBeenCalledTimes(2);
1679
+ // First call should have original system instruction
1680
+ expect(mockContentGenerator.generateContentStream).toHaveBeenNthCalledWith(1, expect.objectContaining({
1681
+ config: expect.objectContaining({
1682
+ systemInstruction: 'Initial instruction',
1683
+ }),
1684
+ }), 'prompt-id-retry-nudge', LlmRole.MAIN);
1685
+ // Second call (retry) should have nudge message appended to systemInstruction
1686
+ expect(mockContentGenerator.generateContentStream).toHaveBeenNthCalledWith(2, expect.objectContaining({
1687
+ config: expect.objectContaining({
1688
+ 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.]',
1689
+ }),
1690
+ }), 'prompt-id-retry-nudge', LlmRole.MAIN);
1691
+ });
1139
1692
  it('should fail after all retries on persistent invalid content and report metrics', async () => {
1140
1693
  vi.mocked(mockContentGenerator.generateContentStream).mockImplementation(async () => (async function* () {
1141
1694
  yield {
@@ -1159,13 +1712,9 @@ describe('GeminiChat', () => {
1159
1712
  expect(mockContentGenerator.generateContentStream).toHaveBeenCalledTimes(4);
1160
1713
  expect(mockLogContentRetry).toHaveBeenCalledTimes(3);
1161
1714
  expect(mockLogContentRetryFailure).toHaveBeenCalledTimes(1);
1162
- // History should still contain the user message.
1715
+ // History should be rolled back to exclude the un-responded user message.
1163
1716
  const history = chat.getHistory();
1164
- expect(history.length).toBe(1);
1165
- expect(history[0]).toEqual({
1166
- role: 'user',
1167
- parts: [{ text: 'test' }],
1168
- });
1717
+ expect(history.length).toBe(0);
1169
1718
  });
1170
1719
  describe('API error retry behavior', () => {
1171
1720
  beforeEach(() => {
@@ -2122,6 +2671,33 @@ describe('GeminiChat', () => {
2122
2671
  expect(capturedContents[2].parts[0].inlineData.mimeType).toBe('audio/mpeg');
2123
2672
  expect(capturedContents[2].parts[1].inlineData.mimeType).toBe('video/mp4');
2124
2673
  });
2674
+ it('should preserve all synthetic binary injection turns when the stream fails', async () => {
2675
+ const initialHistoryLength = chat.agentHistory.length;
2676
+ const audioParts = [
2677
+ {
2678
+ functionResponse: {
2679
+ id: 'call-123',
2680
+ name: 'read_file',
2681
+ response: {
2682
+ output: 'Success',
2683
+ [BINARY_INJECTION_KEY]: [
2684
+ { inlineData: { mimeType: 'audio/mpeg', data: 'base64' } },
2685
+ ],
2686
+ },
2687
+ },
2688
+ },
2689
+ ];
2690
+ // Setup: Stream that throws an error
2691
+ vi.mocked(mockContentGenerator.generateContentStream).mockRejectedValue(new Error('API error during binary injection stream'));
2692
+ const stream = await chat.sendMessageStream({ model: 'gemini-pro' }, audioParts, 'test-id', new AbortController().signal, LlmRole.MAIN);
2693
+ await expect((async () => {
2694
+ for await (const _ of stream) {
2695
+ // consume stream
2696
+ }
2697
+ })()).rejects.toThrow('API error during binary injection stream');
2698
+ // Verify that history has been preserved, and all 3 synthetic binary injection turns are kept.
2699
+ expect(chat.agentHistory.length).toBe(initialHistoryLength + 3);
2700
+ });
2125
2701
  });
2126
2702
  describe('recordCompletedToolCalls', () => {
2127
2703
  it('should use originalRequestName and originalRequestArgs if present', () => {