iop/apps/node/internal/adapters/cli/emitters_internal_test.go
toki 0fb7c67b18 feat: cli emitter interface refactoring and edge routing config update
- Refactor CLI emitters interface and internal test cases
- Update node.go to use new emitter patterns
- Add sink tests for node persistence validation
- Update edge.yaml for multi-point routing configuration
2026-05-05 06:38:07 +09:00

551 lines
17 KiB
Go

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)
}
}