@google/gemini-cli-core 0.64.0-preview.0 → 0.64.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 (50) hide show
  1. package/dist/docs/tools/mcp-server.md +41 -12
  2. package/dist/google-gemini-cli-core-0.64.0-preview.0.tgz +0 -0
  3. package/dist/src/core/client.d.ts +6 -0
  4. package/dist/src/core/client.js +15 -0
  5. package/dist/src/core/client.js.map +1 -1
  6. package/dist/src/core/client.test.js +31 -0
  7. package/dist/src/core/client.test.js.map +1 -1
  8. package/dist/src/core/geminiChat.d.ts +49 -0
  9. package/dist/src/core/geminiChat.js +209 -31
  10. package/dist/src/core/geminiChat.js.map +1 -1
  11. package/dist/src/core/geminiChat.test.js +677 -1
  12. package/dist/src/core/geminiChat.test.js.map +1 -1
  13. package/dist/src/generated/git-commit.d.ts +2 -2
  14. package/dist/src/generated/git-commit.js +2 -2
  15. package/dist/src/mcp/oauth-callback-iss-handling.test.d.ts +6 -0
  16. package/dist/src/mcp/oauth-callback-iss-handling.test.js +251 -0
  17. package/dist/src/mcp/oauth-callback-iss-handling.test.js.map +1 -0
  18. package/dist/src/mcp/oauth-provider.d.ts +1 -0
  19. package/dist/src/mcp/oauth-provider.js +10 -1
  20. package/dist/src/mcp/oauth-provider.js.map +1 -1
  21. package/dist/src/mcp/oauth-provider.test.js +112 -0
  22. package/dist/src/mcp/oauth-provider.test.js.map +1 -1
  23. package/dist/src/mcp/oauth-utils.d.ts +6 -0
  24. package/dist/src/mcp/oauth-utils.js +6 -0
  25. package/dist/src/mcp/oauth-utils.js.map +1 -1
  26. package/dist/src/mcp/oauth-utils.test.js +32 -0
  27. package/dist/src/mcp/oauth-utils.test.js.map +1 -1
  28. package/dist/src/policy/policy-engine.js +74 -4
  29. package/dist/src/policy/policy-engine.js.map +1 -1
  30. package/dist/src/policy/policy-engine.test.js +184 -0
  31. package/dist/src/policy/policy-engine.test.js.map +1 -1
  32. package/dist/src/tools/mcp-client.js +5 -0
  33. package/dist/src/tools/mcp-client.js.map +1 -1
  34. package/dist/src/tools/mcp-client.test.js +49 -0
  35. package/dist/src/tools/mcp-client.test.js.map +1 -1
  36. package/dist/src/utils/historyHardening.js +4 -0
  37. package/dist/src/utils/historyHardening.js.map +1 -1
  38. package/dist/src/utils/historyHardening.test.js +39 -0
  39. package/dist/src/utils/historyHardening.test.js.map +1 -1
  40. package/dist/src/utils/oauth-flow.d.ts +2 -1
  41. package/dist/src/utils/oauth-flow.js +11 -6
  42. package/dist/src/utils/oauth-flow.js.map +1 -1
  43. package/dist/src/utils/oauth-flow.test.js +34 -2
  44. package/dist/src/utils/oauth-flow.test.js.map +1 -1
  45. package/dist/src/utils/untrustedContextTracker.js +234 -70
  46. package/dist/src/utils/untrustedContextTracker.js.map +1 -1
  47. package/dist/src/utils/untrustedContextTracker.test.js +141 -0
  48. package/dist/src/utils/untrustedContextTracker.test.js.map +1 -1
  49. package/dist/tsconfig.tsbuildinfo +1 -1
  50. package/package.json +1 -1
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
7
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';
8
+ import { GeminiChat, InvalidStreamError, StreamEventType, SYNTHETIC_THOUGHT_SIGNATURE, stripToolCallIdPrefixes, coalesceConsecutiveRoles, INTERRUPTED_RESPONSE_PLACEHOLDER, stripThoughts, THINKING_ONLY_NUDGE_MESSAGE, NO_RESPONSE_TEXT_NUDGE_MESSAGE, applyRetryNudge, coalesceConsecutiveContents, ensureTerminalUserTurn, CONTINUE_PROMPT_TEXT, INTERRUPTED_TOOL_RESPONSE_ERROR, } 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';
@@ -3436,6 +3436,43 @@ describe('GeminiChat', () => {
3436
3436
  expect(stripped[0].parts[0].functionCall.id).toBe('call_123');
3437
3437
  expect(stripped[1].parts[0].functionResponse.id).toBe('call_123');
3438
3438
  });
3439
+ it('should preserve functionResponse parts when stripping prefix', () => {
3440
+ const contents = [
3441
+ {
3442
+ role: 'user',
3443
+ parts: [
3444
+ {
3445
+ functionResponse: {
3446
+ id: 'my_tool__call_123',
3447
+ name: 'my_tool',
3448
+ response: { result: 'success' },
3449
+ parts: [{ inlineData: { mimeType: 'image/png', data: 'abc' } }],
3450
+ },
3451
+ },
3452
+ ],
3453
+ },
3454
+ ];
3455
+ const stripped = stripToolCallIdPrefixes(contents);
3456
+ expect(stripped[0].parts[0].functionResponse.id).toBe('call_123');
3457
+ expect(stripped[0].parts[0].functionResponse.parts).toEqual([
3458
+ { inlineData: { mimeType: 'image/png', data: 'abc' } },
3459
+ ]);
3460
+ });
3461
+ it('should remove turns whose parts become empty after removing empty text parts', () => {
3462
+ const contents = [
3463
+ {
3464
+ role: 'user',
3465
+ parts: [{ text: 'valid message' }],
3466
+ },
3467
+ {
3468
+ role: 'user',
3469
+ parts: [{ text: '' }],
3470
+ },
3471
+ ];
3472
+ const stripped = stripToolCallIdPrefixes(contents);
3473
+ expect(stripped).toHaveLength(1);
3474
+ expect(stripped[0].parts).toEqual([{ text: 'valid message' }]);
3475
+ });
3439
3476
  });
3440
3477
  describe('coalesceConsecutiveRoles', () => {
3441
3478
  it('should return empty history if empty array is passed', () => {
@@ -3726,5 +3763,644 @@ describe('GeminiChat', () => {
3726
3763
  expect(result).toEqual(contents);
3727
3764
  });
3728
3765
  });
3766
+ describe('Terminal user turn invariant enforcement and request contents integrity', () => {
3767
+ it('should ensure request contents end with a valid user turn when history ends with a model turn after rewind and trailing turn thoughts are stripped', async () => {
3768
+ chat.setHistory([
3769
+ { role: 'user', parts: [{ text: 'Read package.json' }] },
3770
+ {
3771
+ role: 'model',
3772
+ parts: [{ text: 'Here is the summary of package.json.' }],
3773
+ },
3774
+ ]);
3775
+ let capturedContents;
3776
+ vi.mocked(mockContentGenerator.generateContentStream).mockImplementation(async (params) => {
3777
+ capturedContents = params.contents;
3778
+ return (async function* () {
3779
+ yield {
3780
+ candidates: [
3781
+ {
3782
+ content: {
3783
+ role: 'model',
3784
+ parts: [{ text: 'Response' }],
3785
+ },
3786
+ finishReason: 'STOP',
3787
+ },
3788
+ ],
3789
+ };
3790
+ })();
3791
+ });
3792
+ const stream = await chat.sendMessageStream({ model: 'gemini-2.5-pro' }, [{ text: 'internal reasoning only', thought: true }], 'prompt-after-rewind', new AbortController().signal, LlmRole.MAIN);
3793
+ for await (const _ of stream) {
3794
+ // consume
3795
+ }
3796
+ expect(capturedContents).toBeDefined();
3797
+ const lastContent = capturedContents[capturedContents.length - 1];
3798
+ expect(lastContent.role).toBe('user');
3799
+ expect(lastContent.parts?.length).toBeGreaterThan(0);
3800
+ });
3801
+ it('should ensure request contents end with a valid user turn after interrupted tool turn closure', async () => {
3802
+ chat.setHistory([
3803
+ { role: 'user', parts: [{ text: 'Search for files' }] },
3804
+ {
3805
+ role: 'model',
3806
+ parts: [
3807
+ {
3808
+ functionCall: {
3809
+ id: 'call_1',
3810
+ name: 'grep_search',
3811
+ args: { query: 'files' },
3812
+ },
3813
+ thoughtSignature: 'skip_thought_signature_validator',
3814
+ },
3815
+ ],
3816
+ },
3817
+ {
3818
+ role: 'user',
3819
+ parts: [
3820
+ {
3821
+ functionResponse: {
3822
+ id: 'call_1',
3823
+ name: 'grep_search',
3824
+ response: { output: 'file.txt' },
3825
+ },
3826
+ },
3827
+ ],
3828
+ },
3829
+ ]);
3830
+ let capturedContents;
3831
+ vi.mocked(mockContentGenerator.generateContentStream).mockImplementation(async (params) => {
3832
+ capturedContents = params.contents;
3833
+ return (async function* () {
3834
+ yield {
3835
+ candidates: [
3836
+ {
3837
+ content: {
3838
+ role: 'model',
3839
+ parts: [{ text: 'Response' }],
3840
+ },
3841
+ finishReason: 'STOP',
3842
+ },
3843
+ ],
3844
+ };
3845
+ })();
3846
+ });
3847
+ const stream = await chat.sendMessageStream({ model: 'gemini-2.5-pro' }, [{ text: '' }], 'prompt-after-interrupt', new AbortController().signal, LlmRole.MAIN);
3848
+ for await (const _ of stream) {
3849
+ // consume
3850
+ }
3851
+ expect(capturedContents).toBeDefined();
3852
+ const lastContent = capturedContents[capturedContents.length - 1];
3853
+ expect(lastContent.role).toBe('user');
3854
+ expect(lastContent.parts?.length).toBeGreaterThan(0);
3855
+ expect(capturedContents.some((c) => c.parts?.some((p) => p.text === INTERRUPTED_RESPONSE_PLACEHOLDER))).toBe(true);
3856
+ });
3857
+ it('should not skip recording user turn when context management is enabled and preceding turn is a model turn with matching text', async () => {
3858
+ vi.mocked(mockConfig.isContextManagementEnabled).mockReturnValue(true);
3859
+ const turns = [
3860
+ { id: 'u1', content: { role: 'user', parts: [{ text: 'Say yes' }] } },
3861
+ { id: 'm1', content: { role: 'model', parts: [{ text: 'yes' }] } },
3862
+ ];
3863
+ chat.setHistory(turns);
3864
+ let capturedContents;
3865
+ vi.mocked(mockContentGenerator.generateContentStream).mockImplementation(async (params) => {
3866
+ capturedContents = params.contents;
3867
+ return (async function* () {
3868
+ yield {
3869
+ candidates: [
3870
+ {
3871
+ content: {
3872
+ role: 'model',
3873
+ parts: [{ text: 'Response' }],
3874
+ },
3875
+ finishReason: 'STOP',
3876
+ },
3877
+ ],
3878
+ };
3879
+ })();
3880
+ });
3881
+ const stream = await chat.sendMessageStream({ model: 'gemini-2.5-pro' }, 'yes', 'prompt-cm-dedup', new AbortController().signal, LlmRole.MAIN);
3882
+ for await (const _ of stream) {
3883
+ // consume
3884
+ }
3885
+ expect(capturedContents).toBeDefined();
3886
+ const lastContent = capturedContents[capturedContents.length - 1];
3887
+ expect(lastContent.role).toBe('user');
3888
+ expect(lastContent.parts).toEqual([{ text: 'yes' }]);
3889
+ });
3890
+ it('should ensure request contents end with a valid user turn when apiHistoryOverride has a thought-only trailing turn', async () => {
3891
+ const apiHistoryOverride = [
3892
+ { role: 'user', parts: [{ text: 'Initial question' }] },
3893
+ { role: 'model', parts: [{ text: 'Initial answer' }] },
3894
+ {
3895
+ role: 'user',
3896
+ parts: [{ text: 'internal thought only', thought: true }],
3897
+ },
3898
+ ];
3899
+ let capturedContents;
3900
+ vi.mocked(mockContentGenerator.generateContentStream).mockImplementation(async (params) => {
3901
+ capturedContents = params.contents;
3902
+ return (async function* () {
3903
+ yield {
3904
+ candidates: [
3905
+ {
3906
+ content: {
3907
+ role: 'model',
3908
+ parts: [{ text: 'Response' }],
3909
+ },
3910
+ finishReason: 'STOP',
3911
+ },
3912
+ ],
3913
+ };
3914
+ })();
3915
+ });
3916
+ const stream = await chat.sendMessageStream({ model: 'gemini-2.5-pro' }, 'Follow-up question', 'prompt-override', new AbortController().signal, LlmRole.MAIN, undefined, apiHistoryOverride);
3917
+ for await (const _ of stream) {
3918
+ // consume
3919
+ }
3920
+ expect(capturedContents).toBeDefined();
3921
+ const lastContent = capturedContents[capturedContents.length - 1];
3922
+ expect(lastContent.role).toBe('user');
3923
+ expect(lastContent.parts?.length).toBeGreaterThan(0);
3924
+ });
3925
+ it('should synthesize functionResponse with generic_tool fallback when trailing functionCall has missing or whitespace name', async () => {
3926
+ chat.setHistory([
3927
+ { role: 'user', parts: [{ text: 'Run tool' }] },
3928
+ {
3929
+ role: 'model',
3930
+ parts: [
3931
+ {
3932
+ functionCall: {
3933
+ id: 'call_fallback',
3934
+ name: ' ',
3935
+ args: {},
3936
+ },
3937
+ },
3938
+ ],
3939
+ },
3940
+ ]);
3941
+ let capturedContents;
3942
+ vi.mocked(mockContentGenerator.generateContentStream).mockImplementation(async (params) => {
3943
+ capturedContents = params.contents;
3944
+ return (async function* () {
3945
+ yield {
3946
+ candidates: [
3947
+ {
3948
+ content: {
3949
+ role: 'model',
3950
+ parts: [{ text: 'Response' }],
3951
+ },
3952
+ finishReason: 'STOP',
3953
+ },
3954
+ ],
3955
+ };
3956
+ })();
3957
+ });
3958
+ const stream = await chat.sendMessageStream({ model: 'gemini-2.5-pro' }, [{ text: '' }], 'prompt-tool-fallback', new AbortController().signal, LlmRole.MAIN);
3959
+ for await (const _ of stream) {
3960
+ // consume
3961
+ }
3962
+ expect(capturedContents).toBeDefined();
3963
+ expect(capturedContents[2]).toEqual({
3964
+ role: 'user',
3965
+ parts: [
3966
+ {
3967
+ functionResponse: {
3968
+ name: 'generic_tool',
3969
+ id: 'call_fallback',
3970
+ response: {
3971
+ error: INTERRUPTED_TOOL_RESPONSE_ERROR,
3972
+ },
3973
+ },
3974
+ },
3975
+ ],
3976
+ });
3977
+ const lastTurn = capturedContents[capturedContents.length - 1];
3978
+ expect(lastTurn).toEqual({
3979
+ role: 'user',
3980
+ parts: [{ text: CONTINUE_PROMPT_TEXT }],
3981
+ });
3982
+ });
3983
+ it('should synthesize matching functionResponse before follow-up user text prompt when preceding turn has unclosed functionCall', async () => {
3984
+ chat.setHistory([
3985
+ { role: 'user', parts: [{ text: 'Please read the file' }] },
3986
+ {
3987
+ role: 'model',
3988
+ parts: [
3989
+ {
3990
+ functionCall: {
3991
+ id: 'call_read',
3992
+ name: 'read_file',
3993
+ args: { path: 'foo.ts' },
3994
+ },
3995
+ },
3996
+ ],
3997
+ },
3998
+ ]);
3999
+ let capturedContents;
4000
+ vi.mocked(mockContentGenerator.generateContentStream).mockImplementation(async (params) => {
4001
+ capturedContents = params.contents;
4002
+ return (async function* () {
4003
+ yield {
4004
+ candidates: [
4005
+ {
4006
+ content: {
4007
+ role: 'model',
4008
+ parts: [{ text: 'Understood, doing something else.' }],
4009
+ },
4010
+ finishReason: 'STOP',
4011
+ },
4012
+ ],
4013
+ };
4014
+ })();
4015
+ });
4016
+ const recordMessageSpy = vi.spyOn(chat.getChatRecordingService(), 'recordMessage');
4017
+ const recordSyntheticMessageSpy = vi.spyOn(chat.getChatRecordingService(), 'recordSyntheticMessage');
4018
+ const stream = await chat.sendMessageStream({ model: 'gemini-2.5-pro' }, [{ text: 'Nevermind, do something else.' }], 'prompt-followup', new AbortController().signal, LlmRole.MAIN);
4019
+ for await (const _ of stream) {
4020
+ // consume
4021
+ }
4022
+ expect(capturedContents).toBeDefined();
4023
+ expect(capturedContents).toEqual([
4024
+ { role: 'user', parts: [{ text: 'Please read the file' }] },
4025
+ {
4026
+ role: 'model',
4027
+ parts: [
4028
+ {
4029
+ functionCall: {
4030
+ id: 'call_read',
4031
+ name: 'read_file',
4032
+ args: { path: 'foo.ts' },
4033
+ },
4034
+ },
4035
+ ],
4036
+ },
4037
+ {
4038
+ role: 'user',
4039
+ parts: [
4040
+ {
4041
+ functionResponse: {
4042
+ name: 'read_file',
4043
+ id: 'call_read',
4044
+ response: { error: INTERRUPTED_TOOL_RESPONSE_ERROR },
4045
+ },
4046
+ },
4047
+ ],
4048
+ },
4049
+ {
4050
+ role: 'model',
4051
+ parts: [{ text: INTERRUPTED_RESPONSE_PLACEHOLDER }],
4052
+ },
4053
+ {
4054
+ role: 'user',
4055
+ parts: [{ text: 'Nevermind, do something else.' }],
4056
+ },
4057
+ ]);
4058
+ // The synthetic response and the user's prompt are recorded as separate
4059
+ // messages; the prompt is not mixed with functionResponse parts.
4060
+ expect(recordSyntheticMessageSpy).toHaveBeenCalledWith('user', [
4061
+ {
4062
+ functionResponse: {
4063
+ name: 'read_file',
4064
+ id: 'call_read',
4065
+ response: { error: INTERRUPTED_TOOL_RESPONSE_ERROR },
4066
+ },
4067
+ },
4068
+ ]);
4069
+ expect(recordMessageSpy).toHaveBeenCalledWith(expect.objectContaining({
4070
+ type: 'user',
4071
+ content: [{ text: 'Nevermind, do something else.' }],
4072
+ }));
4073
+ });
4074
+ it('should re-coalesce adjacent turns of same role when interior empty user turn is stripped', () => {
4075
+ const input = [
4076
+ {
4077
+ role: 'model',
4078
+ parts: [{ text: 'Step 1 output' }],
4079
+ },
4080
+ {
4081
+ role: 'user',
4082
+ parts: [{ text: '' }],
4083
+ },
4084
+ {
4085
+ role: 'model',
4086
+ parts: [{ text: 'Step 2 output' }],
4087
+ },
4088
+ ];
4089
+ const stripped = stripToolCallIdPrefixes(input);
4090
+ expect(stripped.length).toBe(1);
4091
+ expect(stripped[0].role).toBe('model');
4092
+ expect(stripped[0].parts).toEqual([
4093
+ { text: 'Step 1 output' },
4094
+ { text: 'Step 2 output' },
4095
+ ]);
4096
+ });
4097
+ it('should not add synthetic turns to history or the session log across retries', async () => {
4098
+ const recordSyntheticMessageSpy = vi.spyOn(chat.getChatRecordingService(), 'recordSyntheticMessage');
4099
+ const apiHistoryOverride = [
4100
+ { role: 'user', parts: [{ text: 'Initial question' }] },
4101
+ { role: 'model', parts: [{ text: 'Initial answer' }] },
4102
+ ];
4103
+ const capturedContents = [];
4104
+ vi.mocked(mockContentGenerator.generateContentStream)
4105
+ // Attempt 1: thought-only response triggers a mid-stream retry.
4106
+ .mockImplementationOnce(async (params) => {
4107
+ capturedContents.push(params.contents);
4108
+ return (async function* () {
4109
+ yield {
4110
+ candidates: [
4111
+ {
4112
+ content: {
4113
+ role: 'model',
4114
+ parts: [{ thought: true, text: 'thinking' }],
4115
+ },
4116
+ finishReason: 'STOP',
4117
+ },
4118
+ ],
4119
+ };
4120
+ })();
4121
+ })
4122
+ // Attempt 2: valid response.
4123
+ .mockImplementationOnce(async (params) => {
4124
+ capturedContents.push(params.contents);
4125
+ return (async function* () {
4126
+ yield {
4127
+ candidates: [
4128
+ {
4129
+ content: { role: 'model', parts: [{ text: 'Answer' }] },
4130
+ finishReason: 'STOP',
4131
+ },
4132
+ ],
4133
+ };
4134
+ })();
4135
+ });
4136
+ const stream = await chat.sendMessageStream({ model: 'gemini-2.0-flash' }, 'Follow-up question', 'prompt-retry-normalization', new AbortController().signal, LlmRole.MAIN, undefined, apiHistoryOverride);
4137
+ for await (const _ of stream) {
4138
+ // consume
4139
+ }
4140
+ expect(capturedContents).toHaveLength(2);
4141
+ expect(capturedContents[0].at(-1)).toEqual({
4142
+ role: 'user',
4143
+ parts: [{ text: CONTINUE_PROMPT_TEXT }],
4144
+ });
4145
+ expect(recordSyntheticMessageSpy).not.toHaveBeenCalled();
4146
+ expect(chat.getHistory()).toEqual([
4147
+ { role: 'user', parts: [{ text: 'Follow-up question' }] },
4148
+ { role: 'model', parts: [{ text: 'Answer' }] },
4149
+ ]);
4150
+ expect(apiHistoryOverride).toHaveLength(2);
4151
+ });
4152
+ it('should populate a trailing empty model turn after a tool response in place', async () => {
4153
+ chat.setHistory([
4154
+ { id: 'u1', content: { role: 'user', parts: [{ text: 'Search' }] } },
4155
+ {
4156
+ id: 'm1',
4157
+ content: {
4158
+ role: 'model',
4159
+ parts: [{ functionCall: { id: 'c1', name: 'grep', args: {} } }],
4160
+ },
4161
+ },
4162
+ {
4163
+ id: 'u2',
4164
+ content: {
4165
+ role: 'user',
4166
+ parts: [
4167
+ {
4168
+ functionResponse: {
4169
+ id: 'c1',
4170
+ name: 'grep',
4171
+ response: { output: 'ok' },
4172
+ },
4173
+ },
4174
+ ],
4175
+ },
4176
+ },
4177
+ { id: 'm2', content: { role: 'model', parts: [] } },
4178
+ ]);
4179
+ const updateSpy = vi.spyOn(chat.getChatRecordingService(), 'updateMessagesFromHistory');
4180
+ vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue((async function* () {
4181
+ yield {
4182
+ candidates: [
4183
+ {
4184
+ content: { role: 'model', parts: [{ text: 'Done' }] },
4185
+ finishReason: 'STOP',
4186
+ },
4187
+ ],
4188
+ };
4189
+ })());
4190
+ const stream = await chat.sendMessageStream({ model: 'gemini-2.0-flash' }, 'Next prompt', 'prompt-empty-model-turn', new AbortController().signal, LlmRole.MAIN);
4191
+ for await (const _ of stream) {
4192
+ // consume
4193
+ }
4194
+ const turns = chat.getHistoryTurns();
4195
+ expect(turns.map((t) => t.id).slice(0, 4)).toEqual([
4196
+ 'u1',
4197
+ 'm1',
4198
+ 'u2',
4199
+ 'm2',
4200
+ ]);
4201
+ expect(turns[3].content.parts).toEqual([
4202
+ { text: INTERRUPTED_RESPONSE_PLACEHOLDER },
4203
+ ]);
4204
+ expect(turns[4].content).toEqual({
4205
+ role: 'user',
4206
+ parts: [{ text: 'Next prompt' }],
4207
+ });
4208
+ expect(updateSpy).toHaveBeenCalledWith(expect.arrayContaining([
4209
+ expect.objectContaining({
4210
+ id: 'm2',
4211
+ content: {
4212
+ role: 'model',
4213
+ parts: [{ text: INTERRUPTED_RESPONSE_PLACEHOLDER }],
4214
+ },
4215
+ }),
4216
+ ]));
4217
+ });
4218
+ it('should replace an empty trailing user turn once and keep history in sync', async () => {
4219
+ chat.setHistory([
4220
+ { role: 'user', parts: [{ text: 'Hi' }] },
4221
+ { role: 'model', parts: [{ text: 'Hello' }] },
4222
+ ]);
4223
+ vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue((async function* () {
4224
+ yield {
4225
+ candidates: [
4226
+ {
4227
+ content: { role: 'model', parts: [{ text: 'Continuing' }] },
4228
+ finishReason: 'STOP',
4229
+ },
4230
+ ],
4231
+ };
4232
+ })());
4233
+ const stream = await chat.sendMessageStream({ model: 'gemini-2.0-flash' }, [{ text: '' }], 'prompt-empty-user-turn', new AbortController().signal, LlmRole.MAIN);
4234
+ for await (const _ of stream) {
4235
+ // consume
4236
+ }
4237
+ expect(chat.getHistory()).toEqual([
4238
+ { role: 'user', parts: [{ text: 'Hi' }] },
4239
+ { role: 'model', parts: [{ text: 'Hello' }] },
4240
+ { role: 'user', parts: [{ text: CONTINUE_PROMPT_TEXT }] },
4241
+ { role: 'model', parts: [{ text: 'Continuing' }] },
4242
+ ]);
4243
+ });
4244
+ });
4245
+ describe('ensureTerminalUserTurn', () => {
4246
+ it('returns the same array when the last turn is a user turn with content', () => {
4247
+ const contents = [
4248
+ { role: 'model', parts: [{ text: 'a' }] },
4249
+ { role: 'user', parts: [{ text: 'b' }] },
4250
+ ];
4251
+ expect(ensureTerminalUserTurn(contents)).toBe(contents);
4252
+ });
4253
+ it('replaces the parts of a trailing user turn without content', () => {
4254
+ const contents = [
4255
+ { role: 'model', parts: [{ text: 'a' }] },
4256
+ { role: 'user', parts: [{ text: '' }] },
4257
+ ];
4258
+ const snapshot = structuredClone(contents);
4259
+ expect(ensureTerminalUserTurn(contents)).toEqual([
4260
+ { role: 'model', parts: [{ text: 'a' }] },
4261
+ { role: 'user', parts: [{ text: CONTINUE_PROMPT_TEXT }] },
4262
+ ]);
4263
+ expect(contents).toEqual(snapshot);
4264
+ });
4265
+ it('appends matching responses after a trailing model function call turn', () => {
4266
+ const contents = [
4267
+ { role: 'user', parts: [{ text: 'go' }] },
4268
+ {
4269
+ role: 'model',
4270
+ parts: [
4271
+ { text: 'Running tools' },
4272
+ { functionCall: { id: 'read_file__1', name: 'read_file' } },
4273
+ { functionCall: { id: '2', name: ' ' } },
4274
+ ],
4275
+ },
4276
+ ];
4277
+ expect(ensureTerminalUserTurn(contents).at(-1)).toEqual({
4278
+ role: 'user',
4279
+ parts: [
4280
+ {
4281
+ functionResponse: {
4282
+ name: 'read_file',
4283
+ id: 'read_file__1',
4284
+ response: { error: INTERRUPTED_TOOL_RESPONSE_ERROR },
4285
+ },
4286
+ },
4287
+ {
4288
+ functionResponse: {
4289
+ name: 'generic_tool',
4290
+ id: '2',
4291
+ response: { error: INTERRUPTED_TOOL_RESPONSE_ERROR },
4292
+ },
4293
+ },
4294
+ ],
4295
+ });
4296
+ expect(contents).toHaveLength(2);
4297
+ });
4298
+ it('appends a continuation prompt after a trailing model text turn or for empty contents', () => {
4299
+ expect(ensureTerminalUserTurn([{ role: 'model', parts: [{ text: 'a' }] }])).toEqual([
4300
+ { role: 'model', parts: [{ text: 'a' }] },
4301
+ { role: 'user', parts: [{ text: CONTINUE_PROMPT_TEXT }] },
4302
+ ]);
4303
+ expect(ensureTerminalUserTurn([])).toEqual([
4304
+ { role: 'user', parts: [{ text: CONTINUE_PROMPT_TEXT }] },
4305
+ ]);
4306
+ });
4307
+ });
4308
+ describe('discardTrailingUnansweredToolCallTurn', () => {
4309
+ it('removes only the trailing model function call turn and keeps turn ids', () => {
4310
+ chat.setHistory([
4311
+ { id: 'u1', content: { role: 'user', parts: [{ text: 'go' }] } },
4312
+ {
4313
+ id: 'm1',
4314
+ content: {
4315
+ role: 'model',
4316
+ parts: [{ functionCall: { id: 'c1', name: 'a', args: {} } }],
4317
+ },
4318
+ },
4319
+ {
4320
+ id: 'u2',
4321
+ content: {
4322
+ role: 'user',
4323
+ parts: [
4324
+ { functionResponse: { id: 'c1', name: 'a', response: {} } },
4325
+ ],
4326
+ },
4327
+ },
4328
+ {
4329
+ id: 'm2',
4330
+ content: {
4331
+ role: 'model',
4332
+ parts: [{ functionCall: { id: 'c2', name: 'b', args: {} } }],
4333
+ },
4334
+ },
4335
+ ]);
4336
+ expect(chat.discardTrailingUnansweredToolCallTurn()).toBe(true);
4337
+ expect(chat.getHistoryTurns().map((t) => t.id)).toEqual([
4338
+ 'u1',
4339
+ 'm1',
4340
+ 'u2',
4341
+ ]);
4342
+ });
4343
+ it('does nothing when the last turn is not a model function call turn', () => {
4344
+ chat.setHistory([
4345
+ { id: 'u1', content: { role: 'user', parts: [{ text: 'go' }] } },
4346
+ { id: 'm1', content: { role: 'model', parts: [{ text: 'done' }] } },
4347
+ ]);
4348
+ expect(chat.discardTrailingUnansweredToolCallTurn()).toBe(false);
4349
+ expect(chat.getHistoryTurns().map((t) => t.id)).toEqual(['u1', 'm1']);
4350
+ });
4351
+ });
4352
+ describe('stripToolCallIdPrefixes field preservation', () => {
4353
+ it('keeps every functionCall and functionResponse field when stripping prefixes', () => {
4354
+ const result = stripToolCallIdPrefixes([
4355
+ {
4356
+ role: 'model',
4357
+ parts: [
4358
+ {
4359
+ functionCall: {
4360
+ id: 'tool__1',
4361
+ name: 'tool',
4362
+ args: { a: 1 },
4363
+ willContinue: true,
4364
+ },
4365
+ },
4366
+ ],
4367
+ },
4368
+ {
4369
+ role: 'user',
4370
+ parts: [
4371
+ {
4372
+ functionResponse: {
4373
+ id: 'tool__1',
4374
+ name: 'tool',
4375
+ response: { ok: true },
4376
+ willContinue: true,
4377
+ },
4378
+ },
4379
+ ],
4380
+ },
4381
+ ]);
4382
+ expect(result[0].parts[0].functionCall).toEqual({
4383
+ id: '1',
4384
+ name: 'tool',
4385
+ args: { a: 1 },
4386
+ willContinue: true,
4387
+ });
4388
+ expect(result[1].parts[0].functionResponse).toEqual({
4389
+ id: '1',
4390
+ name: 'tool',
4391
+ response: { ok: true },
4392
+ willContinue: true,
4393
+ });
4394
+ });
4395
+ });
4396
+ describe('coalesceConsecutiveContents', () => {
4397
+ it('does not merge adjacent contents without a role', () => {
4398
+ const contents = [
4399
+ { parts: [{ text: 'a' }] },
4400
+ { parts: [{ text: 'b' }] },
4401
+ ];
4402
+ expect(coalesceConsecutiveContents(contents)).toEqual(contents);
4403
+ });
4404
+ });
3729
4405
  });
3730
4406
  //# sourceMappingURL=geminiChat.test.js.map