Skip to content

Commit de9d3e4

Browse files
declan-scaleclaude
andcommitted
fix(claude-code): reset thinking once-guard per block to avoid duplicates [greptile]
_saw_thinking_stream is set on a thinking block's first delta (to claim its index in _streamed_block_indexes) but was only reset at the materialised-message boundary. If one streaming turn produced a SECOND thinking block, the guard stayed True, the second block's index was never claimed, and the final assistant envelope re-emitted it — duplicate Start/Delta/Done. Reset the guard on content_block_stop so it is scoped per thinking block. Adds a regression test with two streamed thinking blocks in one turn. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 996efdf commit de9d3e4

2 files changed

Lines changed: 46 additions & 0 deletions

File tree

src/agentex/lib/adk/_modules/_claude_code_sync.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,11 @@ async def convert_claude_code_to_agentex_events(
345345
_thinking_open = False
346346
_thinking_buf = ""
347347
_pending_thinking_block_index = None
348+
# Reset the once-guard per thinking block: a turn can stream a
349+
# second thinking block, and without this the guard stays True,
350+
# the second block's index is never claimed, and the final
351+
# assistant envelope re-emits it (duplicate Start/Delta/Done).
352+
_saw_thinking_stream = False
348353
if _thinking_index is not None:
349354
yield StreamTaskMessageDone(type="done", index=_thinking_index)
350355
_thinking_index = None

tests/lib/adk/test_claude_code_sync.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,47 @@ async def test_streamed_thinking_emits_reasoning_start_deltas_done(self):
247247
assert deltas[1].delta.content_delta == " step two"
248248
assert len(dones) == 1
249249

250+
async def test_two_streamed_thinking_blocks_not_re_emitted(self):
251+
"""A turn that streams two thinking blocks must claim both indices, so the
252+
final assistant envelope does not re-emit the second one."""
253+
254+
def _thinking_block(idx: int, text: str) -> list:
255+
return [
256+
{
257+
"type": "stream_event",
258+
"event": {"type": "content_block_start", "index": idx, "content_block": {"type": "thinking"}},
259+
},
260+
{
261+
"type": "stream_event",
262+
"event": {
263+
"type": "content_block_delta",
264+
"index": idx,
265+
"delta": {"type": "thinking_delta", "thinking": text},
266+
},
267+
},
268+
{"type": "stream_event", "event": {"type": "content_block_stop", "index": idx}},
269+
]
270+
271+
envelopes = [
272+
*_thinking_block(0, "first thought"),
273+
*_thinking_block(1, "second thought"),
274+
# Final assistant envelope repeats both thinking blocks — neither should re-emit.
275+
{
276+
"type": "assistant",
277+
"message": {
278+
"content": [
279+
{"type": "thinking", "thinking": "first thought"},
280+
{"type": "thinking", "thinking": "second thought"},
281+
]
282+
},
283+
},
284+
]
285+
out = await _collect(convert_claude_code_to_agentex_events(_aiter(envelopes)))
286+
reasoning_starts = [
287+
e for e in out if isinstance(e, StreamTaskMessageStart) and isinstance(e.content, ReasoningContent)
288+
]
289+
assert len(reasoning_starts) == 2, "each streamed thinking block emitted exactly once (no duplicate)"
290+
250291
async def test_thinking_block_start_with_no_deltas_allows_assistant_to_fill(self):
251292
"""A thinking block_start without any deltas leaves the final assistant block
252293
free to emit the thinking text (the block index is not claimed as streamed)."""

0 commit comments

Comments
 (0)