fix: parser critical bugs and add PR #86 compatibility

Fixes 3 critical bugs in parser.go:

1. Gemini detection: Change Delta from bool to *bool for proper field
   presence detection. Fixes issue where delta:false events were lost.

2. Codex detection: Tighten logic to only classify as Codex if
   thread_id exists OR item.type is non-empty. Prevents Claude events
   with item:null/item:{} from being misclassified and dropped.

3. Performance: Move itemHeader parsing inside item.completed case,
   only parse when actually needed.

Additional changes:
- Implement PR #86's Gemini notifyMessage trigger on Status field
  instead of Content, resolving parser.go conflict between PRs
- Add regression tests for all fixed scenarios
- All tests pass: go test ./...

Co-authored-by: Codeagent (Codex)

Generated with SWE-Agent.ai

Co-Authored-By: SWE-Agent.ai <noreply@swe-agent.ai>
This commit is contained in:
cexll
2025-12-21 13:55:48 +08:00
parent a08dd62b59
commit 4dd735034e
2 changed files with 95 additions and 16 deletions

View File

@@ -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) { func TestBackendParseJSONStream_GeminiEvents(t *testing.T) {
input := `{"type":"init","session_id":"xyz789"} input := `{"type":"init","session_id":"xyz789"}
{"type":"message","role":"assistant","content":"Hi","delta":true,"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) { func TestBackendParseJSONStreamWithWarn_InvalidLine(t *testing.T) {
var warnings []string var warnings []string
warnFn := func(msg string) { warnings = append(warnings, msg) } warnFn := func(msg string) { warnings = append(warnings, msg) }

View File

@@ -85,7 +85,7 @@ type UnifiedEvent struct {
// Gemini-specific fields // Gemini-specific fields
Role string `json:"role,omitempty"` Role string `json:"role,omitempty"`
Content string `json:"content,omitempty"` Content string `json:"content,omitempty"`
Delta bool `json:"delta,omitempty"` Delta *bool `json:"delta,omitempty"`
Status string `json:"status,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 // 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 != "" isClaude := event.Subtype != "" || event.Result != ""
isGemini := event.Role != "" || event.Delta isGemini := event.Role != "" || event.Delta != nil || event.Status != ""
// Handle Codex events // Handle Codex events
if isCodex { 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)) 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 { if len(details) > 0 {
infoFn(fmt.Sprintf("Parsed event #%d type=%s (%s)", totalEvents, event.Type, strings.Join(details, ", "))) infoFn(fmt.Sprintf("Parsed event #%d type=%s (%s)", totalEvents, event.Type, strings.Join(details, ", ")))
} else { } 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)) infoFn(fmt.Sprintf("thread.started event thread_id=%s", threadID))
case "item.completed": 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 { if itemType == "agent_message" && len(event.Item) > 0 {
// Lazy parse: only parse item content when needed // Lazy parse: only parse item content when needed
var item ItemContent var item ItemContent
@@ -226,10 +232,18 @@ func parseJSONStreamInternal(r io.Reader, warnFn func(string), infoFn func(strin
if event.Content != "" { if event.Content != "" {
geminiBuffer.WriteString(event.Content) geminiBuffer.WriteString(event.Content)
}
if event.Status != "" {
notifyMessage() 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 continue
} }