diff --git a/codeagent-wrapper/main_test.go b/codeagent-wrapper/main_test.go index 0193066..e5fc37b 100644 --- a/codeagent-wrapper/main_test.go +++ b/codeagent-wrapper/main_test.go @@ -1582,6 +1582,34 @@ func TestBackendParseJSONStream_ClaudeEvents(t *testing.T) { } } +func TestBackendParseJSONStream_ClaudeEvents_ItemDoesNotForceCodex(t *testing.T) { + tests := []struct { + name string + input string + }{ + { + name: "null item", + input: `{"type":"result","result":"OK","session_id":"abc123","item":null}`, + }, + { + name: "empty object item", + input: `{"type":"result","subtype":"x","result":"OK","session_id":"abc123","item":{}}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + message, threadID := parseJSONStream(strings.NewReader(tt.input)) + if message != "OK" { + t.Fatalf("message=%q, want %q", message, "OK") + } + if threadID != "abc123" { + t.Fatalf("threadID=%q, want %q", threadID, "abc123") + } + }) + } +} + func TestBackendParseJSONStream_GeminiEvents(t *testing.T) { input := `{"type":"init","session_id":"xyz789"} {"type":"message","role":"assistant","content":"Hi","delta":true,"session_id":"xyz789"} @@ -1598,6 +1626,43 @@ func TestBackendParseJSONStream_GeminiEvents(t *testing.T) { } } +func TestBackendParseJSONStream_GeminiEvents_DeltaFalseStillDetected(t *testing.T) { + input := `{"type":"init","session_id":"xyz789"} +{"type":"message","content":"Hi","delta":false,"session_id":"xyz789"} +{"type":"result","status":"success","session_id":"xyz789"}` + + message, threadID := parseJSONStream(strings.NewReader(input)) + + if message != "Hi" { + t.Fatalf("message=%q, want %q", message, "Hi") + } + if threadID != "xyz789" { + t.Fatalf("threadID=%q, want %q", threadID, "xyz789") + } +} + +func TestBackendParseJSONStream_GeminiEvents_OnMessageTriggeredOnStatus(t *testing.T) { + input := `{"type":"init","session_id":"xyz789"} +{"type":"message","role":"assistant","content":"Hi","delta":true,"session_id":"xyz789"} +{"type":"message","content":" there","delta":true} +{"type":"result","status":"success","session_id":"xyz789"}` + + var called int + message, threadID := parseJSONStreamInternal(strings.NewReader(input), nil, nil, func() { + called++ + }) + + if message != "Hi there" { + t.Fatalf("message=%q, want %q", message, "Hi there") + } + if threadID != "xyz789" { + t.Fatalf("threadID=%q, want %q", threadID, "xyz789") + } + if called != 1 { + t.Fatalf("onMessage called=%d, want %d", called, 1) + } +} + func TestBackendParseJSONStreamWithWarn_InvalidLine(t *testing.T) { var warnings []string warnFn := func(msg string) { warnings = append(warnings, msg) } diff --git a/codeagent-wrapper/parser.go b/codeagent-wrapper/parser.go index 5f186ca..ecf27e6 100644 --- a/codeagent-wrapper/parser.go +++ b/codeagent-wrapper/parser.go @@ -85,7 +85,7 @@ type UnifiedEvent struct { // Gemini-specific fields Role string `json:"role,omitempty"` Content string `json:"content,omitempty"` - Delta bool `json:"delta,omitempty"` + Delta *bool `json:"delta,omitempty"` Status string `json:"status,omitempty"` } @@ -148,9 +148,17 @@ func parseJSONStreamInternal(r io.Reader, warnFn func(string), infoFn func(strin } // Detect backend type by field presence - isCodex := event.ThreadID != "" || len(event.Item) > 0 + isCodex := event.ThreadID != "" + if !isCodex && len(event.Item) > 0 { + var itemHeader struct { + Type string `json:"type"` + } + if json.Unmarshal(event.Item, &itemHeader) == nil && itemHeader.Type != "" { + isCodex = true + } + } isClaude := event.Subtype != "" || event.Result != "" - isGemini := event.Role != "" || event.Delta + isGemini := event.Role != "" || event.Delta != nil || event.Status != "" // Handle Codex events if isCodex { @@ -159,18 +167,6 @@ func parseJSONStreamInternal(r io.Reader, warnFn func(string), infoFn func(strin details = append(details, fmt.Sprintf("thread_id=%s", event.ThreadID)) } - // Parse item type if present - var itemType string - if len(event.Item) > 0 { - var itemHeader struct { - Type string `json:"type"` - } - if err := json.Unmarshal(event.Item, &itemHeader); err == nil { - itemType = itemHeader.Type - details = append(details, fmt.Sprintf("item_type=%s", itemType)) - } - } - if len(details) > 0 { infoFn(fmt.Sprintf("Parsed event #%d type=%s (%s)", totalEvents, event.Type, strings.Join(details, ", "))) } else { @@ -183,6 +179,16 @@ func parseJSONStreamInternal(r io.Reader, warnFn func(string), infoFn func(strin infoFn(fmt.Sprintf("thread.started event thread_id=%s", threadID)) case "item.completed": + var itemType string + if len(event.Item) > 0 { + var itemHeader struct { + Type string `json:"type"` + } + if err := json.Unmarshal(event.Item, &itemHeader); err == nil { + itemType = itemHeader.Type + } + } + if itemType == "agent_message" && len(event.Item) > 0 { // Lazy parse: only parse item content when needed var item ItemContent @@ -226,10 +232,18 @@ func parseJSONStreamInternal(r io.Reader, warnFn func(string), infoFn func(strin if event.Content != "" { geminiBuffer.WriteString(event.Content) + } + + if event.Status != "" { notifyMessage() } - infoFn(fmt.Sprintf("Parsed Gemini event #%d type=%s role=%s delta=%t status=%s content_len=%d", totalEvents, event.Type, event.Role, event.Delta, event.Status, len(event.Content))) + delta := false + if event.Delta != nil { + delta = *event.Delta + } + + infoFn(fmt.Sprintf("Parsed Gemini event #%d type=%s role=%s delta=%t status=%s content_len=%d", totalEvents, event.Type, event.Role, delta, event.Status, len(event.Content))) continue }