@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.
- package/dist/google-gemini-cli-core-0.53.0.tgz +0 -0
- package/dist/src/agent/event-translator.js +7 -1
- package/dist/src/agent/event-translator.js.map +1 -1
- package/dist/src/agent/event-translator.test.js +24 -1
- package/dist/src/agent/event-translator.test.js.map +1 -1
- package/dist/src/agent/legacy-agent-session.js +8 -5
- package/dist/src/agent/legacy-agent-session.js.map +1 -1
- package/dist/src/core/agentChatHistory.d.ts +5 -0
- package/dist/src/core/agentChatHistory.js +9 -0
- package/dist/src/core/agentChatHistory.js.map +1 -1
- package/dist/src/core/agentChatHistory.test.d.ts +6 -0
- package/dist/src/core/agentChatHistory.test.js +95 -0
- package/dist/src/core/agentChatHistory.test.js.map +1 -0
- package/dist/src/core/geminiChat.d.ts +4 -4
- package/dist/src/core/geminiChat.js +106 -32
- package/dist/src/core/geminiChat.js.map +1 -1
- package/dist/src/core/geminiChat.test.js +586 -10
- package/dist/src/core/geminiChat.test.js.map +1 -1
- package/dist/src/core/turn.d.ts +4 -0
- package/dist/src/core/turn.js +7 -1
- package/dist/src/core/turn.js.map +1 -1
- package/dist/src/core/turn.test.js +9 -1
- package/dist/src/core/turn.test.js.map +1 -1
- package/dist/src/generated/git-commit.d.ts +2 -2
- package/dist/src/generated/git-commit.js +2 -2
- package/dist/src/prompts/snippets.js +10 -0
- package/dist/src/prompts/snippets.js.map +1 -1
- package/dist/src/scheduler/scheduler.js +8 -0
- package/dist/src/scheduler/scheduler.js.map +1 -1
- package/dist/src/scheduler/scheduler.test.js +2 -2
- package/dist/src/scheduler/scheduler.test.js.map +1 -1
- package/dist/src/services/modelConfigService.d.ts +1 -0
- package/dist/src/services/modelConfigService.js.map +1 -1
- package/dist/src/telemetry/uiTelemetry.d.ts +2 -0
- package/dist/src/telemetry/uiTelemetry.js +21 -0
- package/dist/src/telemetry/uiTelemetry.js.map +1 -1
- package/dist/src/telemetry/uiTelemetry.test.js +39 -0
- package/dist/src/telemetry/uiTelemetry.test.js.map +1 -1
- package/dist/src/utils/constants.d.ts +7 -0
- package/dist/src/utils/constants.js +7 -0
- package/dist/src/utils/constants.js.map +1 -1
- package/dist/src/utils/messageInspectors.js +2 -1
- package/dist/src/utils/messageInspectors.js.map +1 -1
- package/dist/src/utils/messageInspectors.test.d.ts +6 -0
- package/dist/src/utils/messageInspectors.test.js +156 -0
- package/dist/src/utils/messageInspectors.test.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- 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
|
|
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
|
-
//
|
|
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(
|
|
744
|
-
expect(mockLogContentRetry).
|
|
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
|
|
1715
|
+
// History should be rolled back to exclude the un-responded user message.
|
|
1163
1716
|
const history = chat.getHistory();
|
|
1164
|
-
expect(history.length).toBe(
|
|
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', () => {
|