Skip to content

Commit 1b81b5e

Browse files
committed
fix(tools): parse Gemini and Codex streaming JSON formats
parseStreamLine only handled Claude's format. Now handles: - Gemini: {"type":"message","role":"assistant","content":"..."} - Codex: {"type":"item.completed","item":{"type":"agent_message","text":"..."}} Also adds streaming integration tests for Gemini and Codex, gated on PATH detection and auth availability.
1 parent b0e5616 commit 1b81b5e

File tree

2 files changed

+126
-6
lines changed

2 files changed

+126
-6
lines changed

agent/tools/agent_stream.go

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -128,25 +128,42 @@ func (r *StreamRunner) buildEnv() []string {
128128
return env
129129
}
130130

131-
// streamJSON is the minimal structure of a Claude stream-json event.
131+
// streamJSON is a unified structure for parsing streaming events from all CLIs.
132132
type streamJSON struct {
133133
Type string `json:"type"`
134134
Content string `json:"content"`
135135
Result string `json:"result"`
136+
Role string `json:"role"`
137+
Delta bool `json:"delta"`
138+
Item struct {
139+
Type string `json:"type"`
140+
Text string `json:"text"`
141+
} `json:"item"`
136142
}
137143

138144
func parseStreamLine(line []byte) StreamEvent {
139145
var sj streamJSON
140146
if err := json.Unmarshal(line, &sj); err != nil {
141147
return StreamEvent{}
142148
}
149+
if sj.Type == "" {
150+
return StreamEvent{}
151+
}
152+
153+
// Gemini: {"type":"message","role":"assistant","content":"Hello","delta":true}
154+
if sj.Type == "message" && sj.Role == "assistant" {
155+
return StreamEvent{Type: "text", Content: sj.Content}
156+
}
157+
158+
// Codex: {"type":"item.completed","item":{"type":"agent_message","text":"Hello"}}
159+
if sj.Type == "item.completed" && sj.Item.Type == "agent_message" {
160+
return StreamEvent{Type: "text", Content: sj.Item.Text}
161+
}
162+
163+
// Claude/Gemini: {"type":"text"|"result", "content":"...", "result":"..."}
143164
content := sj.Content
144165
if content == "" {
145166
content = sj.Result
146167
}
147-
typ := sj.Type
148-
if typ == "" {
149-
return StreamEvent{}
150-
}
151-
return StreamEvent{Type: typ, Content: content}
168+
return StreamEvent{Type: sj.Type, Content: content}
152169
}

agent/tools/agent_stream_test.go

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package tools
33
import (
44
"context"
55
"os/exec"
6+
"strings"
67
"testing"
78
"time"
89
)
@@ -45,6 +46,52 @@ func TestParseStreamLine_InvalidJSON(t *testing.T) {
4546
}
4647
}
4748

49+
func TestParseStreamLine_GeminiMessage(t *testing.T) {
50+
line := []byte(`{"type":"message","role":"assistant","content":"Hello","delta":true}`)
51+
evt := parseStreamLine(line)
52+
if evt.Type != "text" {
53+
t.Errorf("Type = %q, want text", evt.Type)
54+
}
55+
if evt.Content != "Hello" {
56+
t.Errorf("Content = %q", evt.Content)
57+
}
58+
}
59+
60+
func TestParseStreamLine_GeminiResult(t *testing.T) {
61+
line := []byte(`{"type":"result","status":"success","stats":{"total_tokens":100}}`)
62+
evt := parseStreamLine(line)
63+
if evt.Type != "result" {
64+
t.Errorf("Type = %q, want result", evt.Type)
65+
}
66+
}
67+
68+
func TestParseStreamLine_GeminiUserMessage(t *testing.T) {
69+
line := []byte(`{"type":"message","role":"user","content":"prompt"}`)
70+
evt := parseStreamLine(line)
71+
if evt.Type == "text" {
72+
t.Error("user messages should not be parsed as text events")
73+
}
74+
}
75+
76+
func TestParseStreamLine_CodexItemCompleted(t *testing.T) {
77+
line := []byte(`{"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"Hello"}}`)
78+
evt := parseStreamLine(line)
79+
if evt.Type != "text" {
80+
t.Errorf("Type = %q, want text", evt.Type)
81+
}
82+
if evt.Content != "Hello" {
83+
t.Errorf("Content = %q", evt.Content)
84+
}
85+
}
86+
87+
func TestParseStreamLine_CodexReasoningIgnored(t *testing.T) {
88+
line := []byte(`{"type":"item.completed","item":{"type":"reasoning","text":"thinking..."}}`)
89+
evt := parseStreamLine(line)
90+
if evt.Type == "text" {
91+
t.Error("reasoning items should not be parsed as text events")
92+
}
93+
}
94+
4895
func TestParseStreamLine_EmptyType(t *testing.T) {
4996
line := []byte(`{"content":"orphan"}`)
5097
evt := parseStreamLine(line)
@@ -168,3 +215,59 @@ func TestStreamRunner_RealClaude(t *testing.T) {
168215
t.Error("expected at least one event")
169216
}
170217
}
218+
219+
func TestStreamRunner_RealGemini(t *testing.T) {
220+
if _, err := exec.LookPath("gemini"); err != nil {
221+
t.Skip("gemini not on PATH")
222+
}
223+
agents := DetectAgents()
224+
var info AgentInfo
225+
for _, a := range agents {
226+
if a.Kind == AgentGemini {
227+
info = a
228+
break
229+
}
230+
}
231+
r := NewStreamRunner(info)
232+
var eventCount int
233+
out, err := r.RunStream(context.Background(), "Say hello in exactly one word.", 30*time.Second, func(evt StreamEvent) {
234+
eventCount++
235+
})
236+
if err != nil {
237+
if strings.Contains(err.Error(), "Permission") || strings.Contains(err.Error(), "denied") || strings.Contains(err.Error(), "auth") {
238+
t.Skipf("gemini auth not configured: %v", err)
239+
}
240+
t.Fatalf("RunStream: %v", err)
241+
}
242+
if out == "" {
243+
t.Error("expected non-empty output")
244+
}
245+
}
246+
247+
func TestStreamRunner_RealCodex(t *testing.T) {
248+
if _, err := exec.LookPath("codex"); err != nil {
249+
t.Skip("codex not on PATH")
250+
}
251+
agents := DetectAgents()
252+
var info AgentInfo
253+
for _, a := range agents {
254+
if a.Kind == AgentCodex {
255+
info = a
256+
break
257+
}
258+
}
259+
r := NewStreamRunner(info)
260+
var eventCount int
261+
out, err := r.RunStream(context.Background(), "Say hello in exactly one word.", 30*time.Second, func(evt StreamEvent) {
262+
eventCount++
263+
})
264+
if err != nil {
265+
if strings.Contains(err.Error(), "auth") || strings.Contains(err.Error(), "API key") || strings.Contains(err.Error(), "login") {
266+
t.Skipf("codex auth not configured: %v", err)
267+
}
268+
t.Fatalf("RunStream: %v", err)
269+
}
270+
if out == "" {
271+
t.Error("expected non-empty output")
272+
}
273+
}

0 commit comments

Comments
 (0)