package cli import ( "context" "errors" "strings" "testing" "iop/apps/node/internal/runtime" ) // --- driveJSONLines tests --- type testSink struct { events []runtime.RuntimeEvent } func (s *testSink) Emit(_ context.Context, e runtime.RuntimeEvent) error { s.events = append(s.events, e) return nil } func TestDriveJSONLines_DispatchesEmitterEvents(t *testing.T) { input := `{"type":"message","role":"assistant","content":"hello"} not-json {"type":"message","role":"assistant","content":"world"}` outBuf := &strings.Builder{} sink := &testSink{} outReader := strings.NewReader(input + "\n") // mockEmitter returns two events per line. mockEmitter := &mockLineEmitter{ name: "mock", emitFn: func(line string) ([]runtime.RuntimeEvent, error) { return []runtime.RuntimeEvent{ {Type: runtime.EventTypeDelta, Delta: "a:" + line}, {Type: runtime.EventTypeDelta, Delta: "b:" + line}, }, nil }, } outputTokens, err := driveJSONLines(context.Background(), outReader, sink, "run-1", outBuf, mockEmitter, 4*1024*1024) if err != nil { t.Fatalf("driveJSONLines: %v", err) } if got := len(sink.events); got != 4 { t.Fatalf("expected 4 events, got %d", got) } // Verify RunID and Timestamp are set. for i, ev := range sink.events { if ev.RunID != "run-1" { t.Errorf("event %d: RunID = %q, want %q", i, ev.RunID, "run-1") } if ev.Timestamp.IsZero() { t.Errorf("event %d: Timestamp is zero", i) } if ev.Type == runtime.EventTypeDelta { outputTokens += len(strings.Fields(ev.Delta)) } } // outBuf should contain all raw lines. raw := outBuf.String() if !strings.Contains(raw, `{"type":"message"`) { t.Fatalf("outBuf missing JSON line: %q", raw) } } func TestDriveJSONLines_StopsOnEmitterError(t *testing.T) { input := `{"type":"text"} {"type":"error"}` outBuf := &strings.Builder{} sink := &testSink{} outReader := strings.NewReader(input + "\n") callCount := 0 mockEmitter := &mockLineEmitter{ name: "mock", emitFn: func(line string) ([]runtime.RuntimeEvent, error) { callCount++ if callCount == 2 { return nil, errors.New("emitter failure") } return []runtime.RuntimeEvent{{Type: runtime.EventTypeDelta, Delta: "ok"}}, nil }, } _, err := driveJSONLines(context.Background(), outReader, sink, "run-2", outBuf, mockEmitter, 4*1024*1024) if err == nil { t.Fatal("expected error, got nil") } if err.Error() != "emitter failure" { t.Fatalf("unexpected error: %v", err) } if callCount != 2 { t.Fatalf("expected emitter called 2 times, got %d", callCount) } } // mockLineEmitter implements lineEmitter for testing. type mockLineEmitter struct { name string emitFn func(line string) ([]runtime.RuntimeEvent, error) } func (m *mockLineEmitter) Name() string { return m.name } func (m *mockLineEmitter) Emit(line string) ([]runtime.RuntimeEvent, error) { if m.emitFn != nil { return m.emitFn(line) } return nil, nil } // --- stream-json emitter tests --- func TestStreamJSONEmitter_AssistantMessageBecomesDelta(t *testing.T) { e := streamJSONEmitter{} line := `{"type":"message","role":"assistant","content":"Hello world"}` events, err := e.Emit(line) if err != nil { t.Fatalf("Emit: %v", err) } if len(events) != 1 { t.Fatalf("expected 1 event, got %d", len(events)) } if events[0].Type != runtime.EventTypeDelta { t.Fatalf("event type = %q, want %q", events[0].Type, runtime.EventTypeDelta) } if events[0].Delta != "Hello world" { t.Fatalf("delta = %q, want %q", events[0].Delta, "Hello world") } } func TestStreamJSONEmitter_SkipsNonAssistantRoles(t *testing.T) { e := streamJSONEmitter{} line := `{"type":"message","role":"user","content":"hi"}` events, _ := e.Emit(line) if len(events) != 0 { t.Fatalf("expected 0 events for user role, got %d", len(events)) } } func TestStreamJSONEmitter_ErrorEvent(t *testing.T) { e := streamJSONEmitter{} line := `{"type":"error","error":"something broke"}` events, _ := e.Emit(line) if len(events) != 1 { t.Fatalf("expected 1 event, got %d", len(events)) } if events[0].Type != runtime.EventTypeError { t.Fatalf("event type = %q, want %q", events[0].Type, runtime.EventTypeError) } if events[0].Error != "something broke" { t.Fatalf("error = %q, want %q", events[0].Error, "something broke") } } func TestStreamJSONEmitter_EmptyContentSkipped(t *testing.T) { e := streamJSONEmitter{} line := `{"type":"message","role":"assistant","content":""}` events, _ := e.Emit(line) if len(events) != 0 { t.Fatalf("expected 0 events for empty content, got %d", len(events)) } } // --- claude-json emitter tests --- func TestClaudeJSONEmitter_TextDelta(t *testing.T) { e := claudeJSONEmitter{} line := `{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"partial"}}}` events, _ := e.Emit(line) if len(events) != 1 { t.Fatalf("expected 1 event, got %d", len(events)) } if events[0].Delta != "partial" { t.Fatalf("delta = %q, want %q", events[0].Delta, "partial") } } func TestClaudeJSONEmitter_ErrorResult(t *testing.T) { e := claudeJSONEmitter{} line := `{"type":"result","is_error":true,"result":"API timeout"}` events, _ := e.Emit(line) if len(events) != 1 { t.Fatalf("expected 1 event, got %d", len(events)) } if events[0].Type != runtime.EventTypeError { t.Fatalf("event type = %q, want %q", events[0].Type, runtime.EventTypeError) } if events[0].Error != "API timeout" { t.Fatalf("error = %q, want %q", events[0].Error, "API timeout") } } func TestClaudeJSONEmitter_NonTextDeltaSkipped(t *testing.T) { e := claudeJSONEmitter{} line := `{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"image_delta","data":"base64"}}}` events, _ := e.Emit(line) if len(events) != 0 { t.Fatalf("expected 0 events for non-text delta, got %d", len(events)) } } // --- codex-json emitter tests --- func TestCodexJSONEmitter_AgentMessageBecomesDelta(t *testing.T) { e := codexJSONEmitter{} line := `{"type":"item.completed","item":{"type":"agent_message","text":"Done."}}` events, _ := e.Emit(line) if len(events) != 1 { t.Fatalf("expected 1 event, got %d", len(events)) } if events[0].Delta != "Done." { t.Fatalf("delta = %q, want %q", events[0].Delta, "Done.") } } func TestCodexJSONEmitter_ItemDeltaBecomesDelta(t *testing.T) { e := codexJSONEmitter{} line := `{"type":"item.delta","item":{"type":"agent_message","delta":"Doing."}}` events, _ := e.Emit(line) if len(events) != 1 { t.Fatalf("expected 1 event, got %d", len(events)) } if events[0].Delta != "Doing." { t.Fatalf("delta = %q, want %q", events[0].Delta, "Doing.") } } func TestCodexJSONEmitter_OutputTextDeltaBecomesDelta(t *testing.T) { e := codexJSONEmitter{} line := `{"type":"response.output_text.delta","delta":"chunk"}` events, _ := e.Emit(line) if len(events) != 1 { t.Fatalf("expected 1 event, got %d", len(events)) } if events[0].Delta != "chunk" { t.Fatalf("delta = %q, want %q", events[0].Delta, "chunk") } } func TestCodexJSONEmitter_ContentOutputTextFallsBackToDelta(t *testing.T) { e := codexJSONEmitter{} line := `{"type":"item.updated","item":{"type":"agent_message","message":{"content":[{"type":"output_text","text":"partial text"}]}}}` events, _ := e.Emit(line) if len(events) != 1 { t.Fatalf("expected 1 event, got %d", len(events)) } if events[0].Delta != "partial text" { t.Fatalf("delta = %q, want %q", events[0].Delta, "partial text") } } func TestCodexJSONEmitter_TurnFailedBecomesError(t *testing.T) { e := codexJSONEmitter{} line := `{"type":"turn.failed","error":{"message":"quota exceeded"}}` events, _ := e.Emit(line) if len(events) != 1 { t.Fatalf("expected 1 event, got %d", len(events)) } if events[0].Type != runtime.EventTypeError { t.Fatalf("event type = %q, want %q", events[0].Type, runtime.EventTypeError) } if events[0].Error != "quota exceeded" { t.Fatalf("error = %q, want %q", events[0].Error, "quota exceeded") } } func TestCodexJSONEmitter_StandardErrorEvent(t *testing.T) { e := codexJSONEmitter{} line := `{"type":"error","message":"network timeout"}` events, _ := e.Emit(line) if len(events) != 1 { t.Fatalf("expected 1 event, got %d", len(events)) } if events[0].Error != "network timeout" { t.Fatalf("error = %q, want %q", events[0].Error, "network timeout") } } func TestCodexJSONEmitter_NonAgentMessageSkipped(t *testing.T) { e := codexJSONEmitter{} line := `{"type":"item.completed","item":{"type":"tool_call","text":"ls -la"}}` events, _ := e.Emit(line) if len(events) != 0 { t.Fatalf("expected 0 events for non-agent_message item, got %d", len(events)) } } // --- opencode-json emitter tests --- func TestOpencodeJSONEmitter_TextPart(t *testing.T) { e := opencodeJSONEmitter{} line := `{"type":"text","part":{"type":"text","text":"response text"}}` events, _ := e.Emit(line) if len(events) != 1 { t.Fatalf("expected 1 event, got %d", len(events)) } if events[0].Delta != "response text" { t.Fatalf("delta = %q, want %q", events[0].Delta, "response text") } } func TestOpencodeJSONEmitter_NestedErrorMessage(t *testing.T) { e := opencodeJSONEmitter{} line := `{"type":"error","error":{"name":"UnknownError","data":{"message":"Model not found"}}}` events, _ := e.Emit(line) if len(events) != 1 { t.Fatalf("expected 1 event, got %d", len(events)) } if events[0].Error != "Model not found" { t.Fatalf("error = %q, want %q", events[0].Error, "Model not found") } } func TestOpencodeJSONEmitter_FallbackToErrorName(t *testing.T) { e := opencodeJSONEmitter{} line := `{"type":"error","error":{"name":"ProviderUnavailable"}}` events, _ := e.Emit(line) if len(events) != 1 { t.Fatalf("expected 1 event, got %d", len(events)) } if events[0].Error != "ProviderUnavailable" { t.Fatalf("error = %q, want %q", events[0].Error, "ProviderUnavailable") } } func TestOpencodeJSONEmitter_EmptyTextPartSkipped(t *testing.T) { e := opencodeJSONEmitter{} line := `{"type":"text","part":{"type":"text","text":""}}` events, _ := e.Emit(line) if len(events) != 0 { t.Fatalf("expected 0 events for empty text, got %d", len(events)) } } // --- cline-json emitter tests --- func TestClineJSONEmitter_TextEvent(t *testing.T) { e := clineJSONEmitter{} line := `{"type":"say","say":"text","text":"Hello from Cline"}` events, _ := e.Emit(line) if len(events) != 1 { t.Fatalf("expected 1 event, got %d", len(events)) } if events[0].Delta != "Hello from Cline" { t.Fatalf("delta = %q, want %q", events[0].Delta, "Hello from Cline") } } func TestClineJSONEmitter_AskApiReqFailedBecomesError(t *testing.T) { e := clineJSONEmitter{} line := `{"type":"ask","ask":"api_req_failed","text":"model unavailable"}` events, _ := e.Emit(line) if len(events) != 1 { t.Fatalf("expected 1 event, got %d", len(events)) } if events[0].Type != runtime.EventTypeError { t.Fatalf("event type = %q, want %q", events[0].Type, runtime.EventTypeError) } if events[0].Error != "model unavailable" { t.Fatalf("error = %q, want %q", events[0].Error, "model unavailable") } } func TestClineJSONEmitter_CompletionError(t *testing.T) { e := clineJSONEmitter{} line := `{"type":"completion","status":"error","error":"task failed"}` events, _ := e.Emit(line) if len(events) != 1 { t.Fatalf("expected 1 event, got %d", len(events)) } if events[0].Error != "task failed" { t.Fatalf("error = %q, want %q", events[0].Error, "task failed") } } func TestClineJSONEmitter_SayErrorWithFallback(t *testing.T) { e := clineJSONEmitter{} line := `{"type":"say","say":"error","message":"fallback msg"}` events, _ := e.Emit(line) if len(events) != 1 { t.Fatalf("expected 1 event, got %d", len(events)) } if events[0].Error != "fallback msg" { t.Fatalf("error = %q, want %q", events[0].Error, "fallback msg") } } func TestClineJSONEmitter_CompletionErrorWithoutErrorField(t *testing.T) { e := clineJSONEmitter{} line := `{"type":"completion","status":"error"}` events, _ := e.Emit(line) if len(events) != 1 { t.Fatalf("expected 1 event, got %d", len(events)) } if events[0].Error != "cline task failed" { t.Fatalf("error = %q, want %q", events[0].Error, "cline task failed") } } // --- Emitter Name tests --- func TestEmitters_HaveDistinctNames(t *testing.T) { testCases := []struct { emitter lineEmitter want string }{ {streamJSONEmitter{}, "stream-json"}, {claudeJSONEmitter{}, "claude-json"}, {codexJSONEmitter{}, "codex-json"}, {opencodeJSONEmitter{}, "opencode-json"}, {clineJSONEmitter{}, "cline-json"}, } for _, tc := range testCases { if got := tc.emitter.Name(); got != tc.want { t.Errorf("%T.Name() = %q, want %q", tc.emitter, got, tc.want) } } } // --- jsonEmitters registry consistency test --- func TestJsonEmitters_RegistryMatchesImpls(t *testing.T) { expectedKeys := []string{"stream-json", "claude-json", "codex-json", "opencode-json", "cline-json"} for _, key := range expectedKeys { reg, ok := jsonEmitters[key] if !ok { t.Fatalf("jsonEmitters[%q] not found in registry", key) } if reg.emitter.Name() != key { t.Errorf("jsonEmitters[%q].emitter.Name() = %q, want %q", key, reg.emitter.Name(), key) } if reg.scanBufMax < 1024 { t.Errorf("jsonEmitters[%q].scanBufMax = %d, expected at least 1024", key, reg.scanBufMax) } } // Ensure no extra keys in registry. if len(jsonEmitters) != len(expectedKeys) { t.Errorf("expected %d registered emitters, got %d", len(expectedKeys), len(jsonEmitters)) } } // --- driveJSONLines integration: raw output accumulation --- func TestDriveJSONLines_AccumulatesRawOutput(t *testing.T) { lines := `{"type":"text","part":{"type":"text","text":"a"}} {"type":"text","part":{"type":"text","text":"b"}}` outBuf := &strings.Builder{} sink := &testSink{} outReader := strings.NewReader(lines + "\n") outputTokens, err := driveJSONLines(context.Background(), outReader, sink, "run-int", outBuf, opencodeJSONEmitter{}, 4*1024*1024) if err != nil { t.Fatalf("driveJSONLines: %v", err) } // outBuf should contain the raw lines with newlines. raw := outBuf.String() if !strings.Contains(raw, `{"type":"text"`) { t.Fatalf("outBuf missing expected line: %q", raw) } // Count newlines in outBuf — should match input lines. newlineCount := strings.Count(raw, "\n") if newlineCount != 2 { t.Fatalf("expected 2 newlines in outBuf, got %d", newlineCount) } // 2 delta events emitted, each with 1 word in delta. if outputTokens != 2 { t.Fatalf("expected 2 outputTokens, got %d", outputTokens) } } // --- driveJSONLines: scanner buffer size respected --- func TestDriveJSONLines_ScannerBufferMax(t *testing.T) { // Create a line larger than a small scanBufMax. longLine := strings.Repeat("x", 100) + `{"type":"text"}` outBuf := &strings.Builder{} sink := &testSink{} outReader := strings.NewReader(longLine + "\n") // With a 50-byte buffer max, the line will be cut off at 50 bytes // and won't start with '{', so it will be skipped. outputTokens, err := driveJSONLines(context.Background(), outReader, sink, "run-buf", outBuf, &mockLineEmitter{name: "buf"}, 50) if err != nil { t.Fatalf("driveJSONLines: %v", err) } // The line starts with 'x' not '{', so emitter is never called, // and no events emitted, outputTokens == 0. if outputTokens != 0 { t.Fatalf("expected 0 outputTokens for line too long for buffer, got %d", outputTokens) } } // --- driveJSONLines: empty and non-JSON lines are skipped --- func TestDriveJSONLines_SkipsEmptyAndNonJSONLines(t *testing.T) { input := "\n\nnot json at all\n \n{\"type\":\"text\"}\n" outBuf := &strings.Builder{} sink := &testSink{} outReader := strings.NewReader(input) callCount := 0 mockEmitter := &mockLineEmitter{ name: "mock", emitFn: func(line string) ([]runtime.RuntimeEvent, error) { callCount++ return []runtime.RuntimeEvent{{Type: runtime.EventTypeDelta, Delta: line}}, nil }, } _, err := driveJSONLines(context.Background(), outReader, sink, "run-skip", outBuf, mockEmitter, 4*1024*1024) if err != nil { t.Fatalf("driveJSONLines: %v", err) } if callCount != 1 { t.Fatalf("expected emitter called 1 time, got %d", callCount) } } // --- driveJSONLines: outputTokens counted only for delta events --- func TestDriveJSONLines_OutputTokensCountedForDeltaOnly(t *testing.T) { outBuf := &strings.Builder{} sink := &testSink{} outReader := strings.NewReader(`{"type":"error"} {"type":"delta"}` + "\n") mockEmitter := &mockLineEmitter{ name: "mock", emitFn: func(line string) ([]runtime.RuntimeEvent, error) { if strings.Contains(line, "error") { return []runtime.RuntimeEvent{{Type: runtime.EventTypeError, Error: "bad"}}, nil } return []runtime.RuntimeEvent{{Type: runtime.EventTypeDelta, Delta: "one two three"}}, nil }, } outputTokens, err := driveJSONLines(context.Background(), outReader, sink, "run-tokens", outBuf, mockEmitter, 4*1024*1024) if err != nil { t.Fatalf("driveJSONLines: %v", err) } // "one two three" has 3 fields; error event contributes 0. if outputTokens != 3 { t.Fatalf("expected 3 outputTokens, got %d", outputTokens) } }