Skip to content

Commit 9c50935

Browse files
Ambient Code Botclaude
andcommitted
feat: add reasoning event support to Gemini CLI bridge
Add support for emitting REASONING_MESSAGE_START/CONTENT/END AG-UI events in the Gemini CLI adapter by parsing 'thinking' events from the NDJSON stream. This reuses the existing reasoning event models from the Claude SDK adapter. The Gemini CLI internally tracks thinking via GeminiEventType.Thought but does not yet surface it in the stream-json output format. This change prepares the adapter to handle thinking events as soon as upstream support is added, while remaining a no-op for current CLI versions. Closes #1127 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent f2ca496 commit 9c50935

3 files changed

Lines changed: 389 additions & 3 deletions

File tree

components/runners/ambient-runner/ag_ui_gemini_cli/adapter.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,18 @@
2828
MessagesSnapshotEvent,
2929
)
3030

31+
from ag_ui_claude_sdk.reasoning_events import (
32+
ReasoningStartEvent,
33+
ReasoningEndEvent,
34+
ReasoningMessageStartEvent,
35+
ReasoningMessageContentEvent,
36+
ReasoningMessageEndEvent,
37+
)
38+
3139
from .types import (
3240
InitEvent,
3341
MessageEvent,
42+
ThinkingEvent,
3443
ToolUseEvent,
3544
ToolResultEvent,
3645
ErrorEvent,
@@ -65,6 +74,9 @@ def _summarize_event(event: object) -> str:
6574
return f"severity={event.severity} msg={event.message[:80]}"
6675
if isinstance(event, ResultEvent):
6776
return f"status={event.status} stats={event.stats}"
77+
if isinstance(event, ThinkingEvent):
78+
preview = (event.content or "")[:80]
79+
return f"delta={event.delta} content={preview!r}"
6880
return ""
6981

7082

@@ -100,6 +112,10 @@ async def run(
100112
accumulated_text = ""
101113
message_timestamp_ms: int | None = None
102114

115+
# Reasoning/thinking state
116+
reasoning_open = False
117+
reasoning_message_id: str | None = None
118+
103119
# Tool tracking
104120
current_tool_call_id: str | None = None
105121

@@ -142,12 +158,67 @@ async def run(
142158
)
143159
continue
144160

161+
# ── thinking ──
162+
if isinstance(event, ThinkingEvent):
163+
if not reasoning_open:
164+
reasoning_message_id = str(uuid.uuid4())
165+
yield ReasoningStartEvent(
166+
threadId=thread_id,
167+
runId=run_id,
168+
messageId=reasoning_message_id,
169+
)
170+
yield ReasoningMessageStartEvent(
171+
threadId=thread_id,
172+
runId=run_id,
173+
messageId=reasoning_message_id,
174+
)
175+
reasoning_open = True
176+
177+
if event.content:
178+
yield ReasoningMessageContentEvent(
179+
threadId=thread_id,
180+
runId=run_id,
181+
messageId=reasoning_message_id,
182+
delta=event.content,
183+
)
184+
185+
# Non-delta thinking: close immediately
186+
if not event.delta and reasoning_open:
187+
yield ReasoningMessageEndEvent(
188+
threadId=thread_id,
189+
runId=run_id,
190+
messageId=reasoning_message_id,
191+
)
192+
yield ReasoningEndEvent(
193+
threadId=thread_id,
194+
runId=run_id,
195+
messageId=reasoning_message_id,
196+
)
197+
reasoning_open = False
198+
reasoning_message_id = None
199+
continue
200+
145201
# ── message (assistant, delta) ──
146202
if isinstance(event, MessageEvent):
147203
# Skip user messages (already in input)
148204
if event.role == "user":
149205
continue
150206

207+
# Close open reasoning block before text output
208+
if reasoning_open and reasoning_message_id:
209+
yield ReasoningMessageEndEvent(
210+
threadId=thread_id,
211+
runId=run_id,
212+
messageId=reasoning_message_id,
213+
)
214+
yield ReasoningEndEvent(
215+
threadId=thread_id,
216+
runId=run_id,
217+
messageId=reasoning_message_id,
218+
)
219+
reasoning_open = False
220+
reasoning_message_id = None
221+
151222
if event.role == "assistant" and event.delta:
152223
# First text chunk: open a text message
153224
if not text_message_open:
@@ -218,6 +289,21 @@ async def run(
218289

219290
# ── tool_use ──
220291
if isinstance(event, ToolUseEvent):
292+
# Close open reasoning block before tool call
293+
if reasoning_open and reasoning_message_id:
294+
yield ReasoningMessageEndEvent(
295+
threadId=thread_id,
296+
runId=run_id,
297+
messageId=reasoning_message_id,
298+
)
299+
yield ReasoningEndEvent(
300+
threadId=thread_id,
301+
runId=run_id,
302+
messageId=reasoning_message_id,
303+
)
304+
reasoning_open = False
305+
reasoning_message_id = None
306+
221307
# Close any open text message before tool call
222308
if text_message_open and current_message_id:
223309
yield TextMessageEndEvent(
@@ -356,6 +442,19 @@ async def run(
356442

357443
except Exception as exc:
358444
logger.error("Error in Gemini CLI adapter run: %s", exc)
445+
# Clean up open reasoning block
446+
if reasoning_open and reasoning_message_id:
447+
try:
448+
yield ReasoningMessageEndEvent(
449+
threadId=thread_id, runId=run_id, messageId=reasoning_message_id,
450+
)
451+
yield ReasoningEndEvent(
452+
threadId=thread_id, runId=run_id, messageId=reasoning_message_id,
453+
)
454+
except Exception:
455+
pass
456+
reasoning_open = False
457+
359458
# Clean up open text message
360459
if text_message_open and current_message_id:
361460
try:
@@ -374,6 +473,18 @@ async def run(
374473
message=str(exc),
375474
)
376475
finally:
476+
# Safety: close any hanging reasoning block
477+
if reasoning_open and reasoning_message_id:
478+
try:
479+
yield ReasoningMessageEndEvent(
480+
threadId=thread_id, runId=run_id, messageId=reasoning_message_id,
481+
)
482+
yield ReasoningEndEvent(
483+
threadId=thread_id, runId=run_id, messageId=reasoning_message_id,
484+
)
485+
except Exception:
486+
pass
487+
377488
# Safety: close any hanging text message
378489
if text_message_open and current_message_id:
379490
try:

components/runners/ambient-runner/ag_ui_gemini_cli/types.py

100644100755
Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Dataclasses for the 6 Gemini CLI JSONL event types."""
1+
"""Dataclasses for Gemini CLI JSONL event types."""
22

33
import json
44
import logging
@@ -8,7 +8,7 @@
88

99
# Type tag used to dispatch parsed events.
1010
_EVENT_TYPES = frozenset(
11-
{"init", "message", "tool_use", "tool_result", "error", "result"}
11+
{"init", "message", "tool_use", "tool_result", "error", "result", "thinking"}
1212
)
1313

1414

@@ -65,19 +65,36 @@ class ResultEvent:
6565
stats: dict | None = None
6666

6767

68+
@dataclass
69+
class ThinkingEvent:
70+
"""Gemini CLI thinking/reasoning event.
71+
72+
Emitted when the model produces reasoning traces (requires the CLI to
73+
expose ``thinking`` events in its ``stream-json`` output). The Gemini
74+
CLI internally tracks thinking via a ``ThoughtSummary`` structure with
75+
``subject`` and ``description`` fields.
76+
"""
77+
78+
type: str # "thinking"
79+
timestamp: str
80+
content: str = ""
81+
delta: bool = False
82+
83+
6884
_TYPE_MAP = {
6985
"init": InitEvent,
7086
"message": MessageEvent,
7187
"tool_use": ToolUseEvent,
7288
"tool_result": ToolResultEvent,
7389
"error": ErrorEvent,
7490
"result": ResultEvent,
91+
"thinking": ThinkingEvent,
7592
}
7693

7794

7895
def parse_event(
7996
line: str,
80-
) -> InitEvent | MessageEvent | ToolUseEvent | ToolResultEvent | ErrorEvent | ResultEvent | None:
97+
) -> InitEvent | MessageEvent | ToolUseEvent | ToolResultEvent | ErrorEvent | ResultEvent | ThinkingEvent | None:
8198
"""Parse a JSON line into the appropriate event dataclass.
8299
83100
Returns ``None`` when the line cannot be parsed or has an unknown type.

0 commit comments

Comments
 (0)