Skip to content

Commit cb4b2b7

Browse files
committed
fix: merge streamed model history turns
1 parent 7c331c6 commit cb4b2b7

2 files changed

Lines changed: 144 additions & 6 deletions

File tree

google/genai/chats.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,12 +96,27 @@ def _extract_curated_history(
9696
is_valid = False
9797
i += 1
9898
if is_valid:
99-
curated_history.extend(current_output)
99+
curated_history.extend(_merge_model_outputs(current_output))
100100
elif curated_history:
101101
curated_history.pop()
102102
return curated_history
103103

104104

105+
def _merge_model_outputs(contents: list[Content]) -> list[Content]:
106+
"""Merge adjacent model chunks into one turn for request history."""
107+
merged: list[Content] = []
108+
for content in contents:
109+
if (
110+
content.role == "model"
111+
and merged
112+
and merged[-1].role == "model"
113+
):
114+
merged[-1].parts = (merged[-1].parts or []) + (content.parts or [])
115+
else:
116+
merged.append(content.model_copy())
117+
return merged
118+
119+
105120
class _BaseChat:
106121
"""Base chat session."""
107122

@@ -166,7 +181,7 @@ def record_history(
166181
self._comprehensive_history.extend(output_contents)
167182
if is_valid:
168183
self._curated_history.extend(input_contents)
169-
self._curated_history.extend(output_contents)
184+
self._curated_history.extend(_merge_model_outputs(output_contents))
170185

171186
def get_history(self, curated: bool = False) -> list[Content]:
172187
"""Returns the chat history.

google/genai/tests/chats/test_get_history.py

Lines changed: 127 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,43 @@ def mock_generate_content_stream_afc_history():
156156
yield mock_generate_content
157157

158158

159+
@pytest.fixture
160+
def mock_generate_content_stream_with_split_model_output():
161+
with mock.patch.object(
162+
models.Models, 'generate_content_stream'
163+
) as mock_generate_content:
164+
mock_generate_content.return_value = [
165+
types.GenerateContentResponse(
166+
candidates=[
167+
types.Candidate(
168+
content=types.Content(
169+
role='model',
170+
parts=[types.Part.from_text(text='reasoning')],
171+
),
172+
)
173+
]
174+
),
175+
types.GenerateContentResponse(
176+
candidates=[
177+
types.Candidate(
178+
content=types.Content(
179+
role='model',
180+
parts=[
181+
types.Part(
182+
function_call=types.FunctionCall(
183+
name='foo', args={'bar': 'baz'}
184+
)
185+
)
186+
],
187+
),
188+
finish_reason=types.FinishReason.STOP,
189+
)
190+
]
191+
),
192+
]
193+
yield mock_generate_content
194+
195+
159196
def test_history_start_with_valid_model_content():
160197
history = [
161198
types.Content(
@@ -251,7 +288,15 @@ def test_history_with_consecutive_valid_model_outputs():
251288
chat = chats_module.create(model='gemini-2.5-flash', history=history)
252289

253290
assert chat.get_history() == history
254-
assert chat.get_history(curated=True) == history
291+
assert chat.get_history(curated=True) == [
292+
types.Content(
293+
role='model',
294+
parts=[
295+
types.Part.from_text(text='model output 1'),
296+
types.Part.from_text(text='model output 2'),
297+
],
298+
),
299+
]
255300

256301

257302
def test_history_with_valid_and_invalid_model_output():
@@ -343,7 +388,19 @@ def test_sync_chat_create():
343388
chat = chats_module.create(model='gemini-2.5-flash', history=history)
344389

345390
assert chat.get_history() == history
346-
assert chat.get_history(curated=True) == history
391+
assert chat.get_history(curated=True) == [
392+
types.Content(
393+
role='user', parts=[types.Part.from_text(text='user input turn 1')]
394+
),
395+
types.Content(
396+
role='model',
397+
parts=[
398+
types.Part.from_text(text='model output turn 1'),
399+
types.Part.from_text(text='model output turn 1'),
400+
types.Part.from_text(text='user input turn 2'),
401+
],
402+
),
403+
]
347404

348405

349406
def test_async_chat_create():
@@ -374,7 +431,20 @@ def test_async_chat_create():
374431
chat = chats_module.create(model='gemini-2.5-flash', history=history)
375432

376433
assert chat.get_history() == history
377-
assert chat.get_history(curated=True) == history
434+
assert chat.get_history(curated=True) == [
435+
types.Content(
436+
role='user', parts=[types.Part.from_text(text='user input turn 1')]
437+
),
438+
types.Content(
439+
role='model',
440+
parts=[
441+
types.Part.from_text(text='model output turn 1'),
442+
types.Part.from_text(text='model output turn 1'),
443+
types.Part.from_text(text='user input turn 2'),
444+
types.Part.from_text(text='model output turn 2'),
445+
],
446+
),
447+
]
378448

379449

380450
def test_sync_chat_create_with_history_dict():
@@ -470,7 +540,15 @@ def test_history_with_invalid_turns():
470540
comprehensive_history.append(invalid_output)
471541
curated_history = []
472542
curated_history.append(valid_input)
473-
curated_history.extend(valid_output)
543+
curated_history.append(
544+
types.Content(
545+
role='model',
546+
parts=[
547+
valid_output[0].parts[0],
548+
valid_output[1].parts[0],
549+
],
550+
)
551+
)
474552

475553
models_module = models.Models(mock_api_client)
476554
chats_module = chats.Chats(modules=models_module)
@@ -596,3 +674,48 @@ def test_chat_stream_with_afc_history(mock_generate_content_stream_afc_history):
596674
]
597675
assert chat.get_history() == expected_history
598676
assert chat.get_history(curated=True) == expected_history
677+
678+
679+
def test_chat_stream_merges_model_chunks_for_curated_history(
680+
mock_generate_content_stream_with_split_model_output,
681+
):
682+
models_module = models.Models(mock_api_client)
683+
chats_module = chats.Chats(modules=models_module)
684+
chat = chats_module.create(model='gemini-2.5-flash')
685+
686+
for _ in chat.send_message_stream('Hello'):
687+
pass
688+
689+
expected_comprehensive_history = [
690+
types.UserContent(parts=[types.Part.from_text(text='Hello')]),
691+
types.Content(
692+
role='model',
693+
parts=[types.Part.from_text(text='reasoning')],
694+
),
695+
types.Content(
696+
role='model',
697+
parts=[
698+
types.Part(
699+
function_call=types.FunctionCall(
700+
name='foo', args={'bar': 'baz'}
701+
)
702+
)
703+
],
704+
),
705+
]
706+
expected_curated_history = [
707+
types.UserContent(parts=[types.Part.from_text(text='Hello')]),
708+
types.Content(
709+
role='model',
710+
parts=[
711+
types.Part.from_text(text='reasoning'),
712+
types.Part(
713+
function_call=types.FunctionCall(
714+
name='foo', args={'bar': 'baz'}
715+
)
716+
),
717+
],
718+
),
719+
]
720+
assert chat.get_history() == expected_comprehensive_history
721+
assert chat.get_history(curated=True) == expected_curated_history

0 commit comments

Comments
 (0)