From af0da6b82285b05bc0991bc950217ee777dee0f3 Mon Sep 17 00:00:00 2001 From: toki Date: Tue, 5 May 2026 07:12:47 +0900 Subject: [PATCH] refactor: reorganize cli adapters test structure - Remove redundant test files (cancel_reason_test, cli_test, codex_exec_args_test, emitters_internal_test, factory_test, registry_test) - Consolidate adapter tests into adapters_blackbox_test.go - Add cli_internal_test.go for internal CLI tests - Update lifecycle_blackbox_test.go --- ...stry_test.go => adapters_blackbox_test.go} | 174 ++++-- .../adapters/cli/cancel_reason_test.go | 42 -- .../adapters/cli/cli_internal_test.go | 424 ++++++++++++++ apps/node/internal/adapters/cli/cli_test.go | 117 ---- .../adapters/cli/codex_exec_args_test.go | 43 -- .../adapters/cli/emitters_internal_test.go | 551 ------------------ .../adapters/cli/lifecycle_blackbox_test.go | 97 +++ apps/node/internal/adapters/factory_test.go | 79 --- 8 files changed, 638 insertions(+), 889 deletions(-) rename apps/node/internal/adapters/{registry_test.go => adapters_blackbox_test.go} (66%) delete mode 100644 apps/node/internal/adapters/cli/cancel_reason_test.go create mode 100644 apps/node/internal/adapters/cli/cli_internal_test.go delete mode 100644 apps/node/internal/adapters/cli/cli_test.go delete mode 100644 apps/node/internal/adapters/cli/codex_exec_args_test.go delete mode 100644 apps/node/internal/adapters/cli/emitters_internal_test.go delete mode 100644 apps/node/internal/adapters/factory_test.go diff --git a/apps/node/internal/adapters/registry_test.go b/apps/node/internal/adapters/adapters_blackbox_test.go similarity index 66% rename from apps/node/internal/adapters/registry_test.go rename to apps/node/internal/adapters/adapters_blackbox_test.go index b4eba8b..00640ef 100644 --- a/apps/node/internal/adapters/registry_test.go +++ b/apps/node/internal/adapters/adapters_blackbox_test.go @@ -12,12 +12,80 @@ import ( iop "iop/proto/gen/iop" ) -// lifecycleAdapter records Start/Stop calls. +// --- BuildFromPayload tests --- + +func TestBuildFromPayload_MockAlwaysPresent(t *testing.T) { + reg, err := adapters.BuildFromPayload(&iop.NodeConfigPayload{}, zap.NewNop()) + if err != nil { + t.Fatalf("build from payload: %v", err) + } + if _, ok := reg.Get("mock"); !ok { + t.Fatal("expected mock adapter to be registered") + } + if got := len(reg.All()); got != 1 { + t.Fatalf("expected 1 adapter, got %d", got) + } +} + +func TestBuildFromPayload_OllamaEnabled(t *testing.T) { + reg, err := adapters.BuildFromPayload(&iop.NodeConfigPayload{ + Adapters: []*iop.AdapterConfig{ + { + Type: "ollama", + Enabled: true, + Config: &iop.AdapterConfig_Ollama{Ollama: &iop.OllamaAdapterConfig{BaseUrl: "http://localhost:11434"}}, + }, + }, + }, zap.NewNop()) + if err != nil { + t.Fatalf("build from payload: %v", err) + } + if _, ok := reg.Get("mock"); !ok { + t.Fatal("expected mock adapter to be registered") + } + if _, ok := reg.Get("ollama"); !ok { + t.Fatal("expected ollama adapter to be registered") + } +} + +func TestBuildFromPayload_MultipleAdapters(t *testing.T) { + reg, err := adapters.BuildFromPayload(&iop.NodeConfigPayload{ + Adapters: []*iop.AdapterConfig{ + {Type: "ollama", Enabled: true, Config: &iop.AdapterConfig_Ollama{Ollama: &iop.OllamaAdapterConfig{BaseUrl: "x"}}}, + {Type: "vllm", Enabled: true, Config: &iop.AdapterConfig_Vllm{Vllm: &iop.VllmAdapterConfig{Endpoint: "y"}}}, + {Type: "cli", Enabled: true, Config: &iop.AdapterConfig_Cli{Cli: &iop.CLIAdapterConfig{ + Profiles: map[string]*iop.CLIProfileConfig{ + "codex": {Command: "codex", Persistent: true, ResponseIdleTimeoutMs: 1500, StartupIdleTimeoutMs: 300}, + }, + }}}, + }, + }, zap.NewNop()) + if err != nil { + t.Fatalf("build from payload: %v", err) + } + for _, name := range []string{"mock", "ollama", "vllm", "cli"} { + if _, ok := reg.Get(name); !ok { + t.Fatalf("expected %s adapter to be registered", name) + } + } +} + +func TestBuildFromPayload_UnknownType(t *testing.T) { + _, err := adapters.BuildFromPayload(&iop.NodeConfigPayload{ + Adapters: []*iop.AdapterConfig{ + {Type: "unknown", Enabled: true}, + }, + }, zap.NewNop()) + if err == nil { + t.Fatal("expected unknown adapter type error") + } +} + +// --- Registry lifecycle tests --- + type lifecycleAdapter struct { - name string - started []string - stopped []string - log *[]string + name string + log *[]string } func (a *lifecycleAdapter) Name() string { return a.name } @@ -36,6 +104,49 @@ func (a *lifecycleAdapter) Stop(_ context.Context) error { return nil } +type failingLifecycleAdapter struct { + name string + log *[]string +} + +func (a *failingLifecycleAdapter) Name() string { return a.name } +func (a *failingLifecycleAdapter) Capabilities(_ context.Context) (noderuntime.Capabilities, error) { + return noderuntime.Capabilities{AdapterName: a.name}, nil +} +func (a *failingLifecycleAdapter) Execute(_ context.Context, _ noderuntime.ExecutionSpec, _ noderuntime.EventSink) error { + return nil +} +func (a *failingLifecycleAdapter) Start(_ context.Context) error { + *a.log = append(*a.log, "start:"+a.name) + return fmt.Errorf("start failed: %s", a.name) +} +func (a *failingLifecycleAdapter) Stop(_ context.Context) error { + *a.log = append(*a.log, "stop:"+a.name) + return nil +} + +type failingStopAdapter struct { + name string + log *[]string + stopErr error +} + +func (a *failingStopAdapter) Name() string { return a.name } +func (a *failingStopAdapter) Capabilities(_ context.Context) (noderuntime.Capabilities, error) { + return noderuntime.Capabilities{AdapterName: a.name}, nil +} +func (a *failingStopAdapter) Execute(_ context.Context, _ noderuntime.ExecutionSpec, _ noderuntime.EventSink) error { + return nil +} +func (a *failingStopAdapter) Start(_ context.Context) error { + *a.log = append(*a.log, "start:"+a.name) + return nil +} +func (a *failingStopAdapter) Stop(_ context.Context) error { + *a.log = append(*a.log, "stop:"+a.name) + return a.stopErr +} + func TestRegistryLifecycle_StartStopOrder(t *testing.T) { log := []string{} reg := adapters.NewRegistry() @@ -61,28 +172,6 @@ func TestRegistryLifecycle_StartStopOrder(t *testing.T) { } } -// failingLifecycleAdapter starts successfully only once then always errors. -type failingLifecycleAdapter struct { - name string - log *[]string -} - -func (a *failingLifecycleAdapter) Name() string { return a.name } -func (a *failingLifecycleAdapter) Capabilities(_ context.Context) (noderuntime.Capabilities, error) { - return noderuntime.Capabilities{AdapterName: a.name}, nil -} -func (a *failingLifecycleAdapter) Execute(_ context.Context, _ noderuntime.ExecutionSpec, _ noderuntime.EventSink) error { - return nil -} -func (a *failingLifecycleAdapter) Start(_ context.Context) error { - *a.log = append(*a.log, "start:"+a.name) - return fmt.Errorf("start failed: %s", a.name) -} -func (a *failingLifecycleAdapter) Stop(_ context.Context) error { - *a.log = append(*a.log, "stop:"+a.name) - return nil -} - func TestRegistryLifecycle_StartFailureStopsStartedAdapters(t *testing.T) { log := []string{} reg := adapters.NewRegistry() @@ -105,33 +194,9 @@ func TestRegistryLifecycle_StartFailureStopsStartedAdapters(t *testing.T) { } } -// failingStopAdapter stops successfully once then always errors. -type failingStopAdapter struct { - name string - log *[]string - stopErr error -} - -func (a *failingStopAdapter) Name() string { return a.name } -func (a *failingStopAdapter) Capabilities(_ context.Context) (noderuntime.Capabilities, error) { - return noderuntime.Capabilities{AdapterName: a.name}, nil -} -func (a *failingStopAdapter) Execute(_ context.Context, _ noderuntime.ExecutionSpec, _ noderuntime.EventSink) error { - return nil -} -func (a *failingStopAdapter) Start(_ context.Context) error { - *a.log = append(*a.log, "start:"+a.name) - return nil -} -func (a *failingStopAdapter) Stop(_ context.Context) error { - *a.log = append(*a.log, "stop:"+a.name) - return a.stopErr -} - func TestRegistryLifecycle_StopContinuesOnFailingAdapter(t *testing.T) { log := []string{} reg := adapters.NewRegistry() - // first stops successfully, second fails reg.Register(&lifecycleAdapter{name: "first", log: &log}) reg.Register(&failingStopAdapter{name: "second", log: &log, stopErr: fmt.Errorf("stop failed: second")}) @@ -139,14 +204,10 @@ func TestRegistryLifecycle_StopContinuesOnFailingAdapter(t *testing.T) { if err := reg.Start(ctx); err != nil { t.Fatalf("Start: %v", err) } - - err := reg.Stop(ctx) - // Should return error because second adapter fails - if err == nil { + if err := reg.Stop(ctx); err == nil { t.Fatal("expected non-nil error from Stop") } - // Both adapters should have been stopped regardless of error want := []string{"start:first", "start:second", "stop:second", "stop:first"} if len(log) != len(want) { t.Fatalf("expected log %v, got %v", want, log) @@ -164,7 +225,6 @@ func TestRegistryLifecycle_NonLifecycleAdapterSkipped(t *testing.T) { t.Fatalf("build: %v", err) } ctx := context.Background() - // mock adapter has no lifecycle — should not panic or error if err := reg.Start(ctx); err != nil { t.Fatalf("Start: %v", err) } diff --git a/apps/node/internal/adapters/cli/cancel_reason_test.go b/apps/node/internal/adapters/cli/cancel_reason_test.go deleted file mode 100644 index dbbbdfb..0000000 --- a/apps/node/internal/adapters/cli/cancel_reason_test.go +++ /dev/null @@ -1,42 +0,0 @@ -package cli - -import ( - "context" - "io" - "testing" -) - -func TestCancelEventForContext_DeadlineMapsToTimeout(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 0) - defer cancel() - <-ctx.Done() - got := cancelEventForContext(ctx.Err()) - if got != "timeout" { - t.Fatalf("expected 'timeout', got %q", got) - } -} - -func TestCancelEventForContext_CanceledMapsToUserCancel(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - cancel() - <-ctx.Done() - got := cancelEventForContext(ctx.Err()) - if got != "user-cancel" { - t.Fatalf("expected 'user-cancel', got %q", got) - } -} - -func TestCancelEventForContext_NilError(t *testing.T) { - got := cancelEventForContext(nil) - if got != "context-done" { - t.Fatalf("expected 'context-done', got %q", got) - } -} - -func TestCancelEventForContext_SiblingError(t *testing.T) { - type customErr struct{ error } - got := cancelEventForContext(customErr{io.EOF}) - if got != "context-done" { - t.Fatalf("expected 'context-done', got %q", got) - } -} diff --git a/apps/node/internal/adapters/cli/cli_internal_test.go b/apps/node/internal/adapters/cli/cli_internal_test.go new file mode 100644 index 0000000..ad75bce --- /dev/null +++ b/apps/node/internal/adapters/cli/cli_internal_test.go @@ -0,0 +1,424 @@ +package cli + +import ( + "context" + "errors" + "io" + "strings" + "testing" + + "iop/apps/node/internal/runtime" + "iop/packages/config" +) + +// --- cancel reason tests --- + +func TestCancelEventForContext_DeadlineMapsToTimeout(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 0) + defer cancel() + <-ctx.Done() + got := cancelEventForContext(ctx.Err()) + if got != "timeout" { + t.Fatalf("expected 'timeout', got %q", got) + } +} + +func TestCancelEventForContext_CanceledMapsToUserCancel(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + <-ctx.Done() + got := cancelEventForContext(ctx.Err()) + if got != "user-cancel" { + t.Fatalf("expected 'user-cancel', got %q", got) + } +} + +func TestCancelEventForContext_NilError(t *testing.T) { + got := cancelEventForContext(nil) + if got != "context-done" { + t.Fatalf("expected 'context-done', got %q", got) + } +} + +func TestCancelEventForContext_SiblingError(t *testing.T) { + type customErr struct{ error } + got := cancelEventForContext(customErr{io.EOF}) + if got != "context-done" { + t.Fatalf("expected 'context-done', got %q", got) + } +} + +// --- codex exec args tests --- + +func TestCodexExecArgs_NoSessionUsesProfileArgs(t *testing.T) { + profile := config.CLIProfileConf{ + Command: "codex", + Args: []string{"exec", "--json"}, + } + + args := codexExecArgs(profile, "", "hello prompt") + expected := []string{"exec", "--json", "hello prompt"} + if len(args) != len(expected) { + t.Fatalf("expected %d args, got %d: %v", len(expected), len(args), args) + } + for i, v := range expected { + if args[i] != v { + t.Errorf("args[%d]: expected %q, got %q", i, v, args[i]) + } + } +} + +func TestCodexExecArgs_WithSessionUsesResumeArgs(t *testing.T) { + profile := config.CLIProfileConf{ + Args: []string{"exec", "--json"}, + ResumeArgs: []string{"exec", "resume", "--color", "never"}, + } + + args := codexExecArgs(profile, "session-abc", "hello prompt") + expected := []string{"exec", "resume", "--color", "never", "session-abc", "hello prompt"} + if len(args) != len(expected) { + t.Fatalf("expected %d args, got %d: %v", len(expected), len(args), args) + } + for i, v := range expected { + if args[i] != v { + t.Errorf("args[%d]: expected %q, got %q", i, v, args[i]) + } + } +} + +// --- 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 +} + +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 +} + +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 := &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) + } + 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)) + } + } + 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) + } +} + +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) + } + raw := outBuf.String() + if !strings.Contains(raw, `{"type":"text"`) { + t.Fatalf("outBuf missing expected line: %q", raw) + } + if newlineCount := strings.Count(raw, "\n"); newlineCount != 2 { + t.Fatalf("expected 2 newlines in outBuf, got %d", newlineCount) + } + if outputTokens != 2 { + t.Fatalf("expected 2 outputTokens, got %d", outputTokens) + } +} + +func TestDriveJSONLines_ScannerBufferMax(t *testing.T) { + longLine := strings.Repeat("x", 100) + `{"type":"text"}` + outBuf := &strings.Builder{} + sink := &testSink{} + outReader := strings.NewReader(longLine + "\n") + + outputTokens, err := driveJSONLines(context.Background(), outReader, sink, "run-buf", outBuf, &mockLineEmitter{name: "buf"}, 50) + if err != nil { + t.Fatalf("driveJSONLines: %v", err) + } + if outputTokens != 0 { + t.Fatalf("expected 0 outputTokens for line too long for buffer, got %d", outputTokens) + } +} + +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) + } +} + +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) + } + if outputTokens != 3 { + t.Fatalf("expected 3 outputTokens, got %d", outputTokens) + } +} + +// --- emitter unit tests (edge cases not covered by blackbox tests) --- + +func TestStreamJSONEmitter_SkipsNonAssistantRoles(t *testing.T) { + e := streamJSONEmitter{} + events, _ := e.Emit(`{"type":"message","role":"user","content":"hi"}`) + if len(events) != 0 { + t.Fatalf("expected 0 events for user role, got %d", len(events)) + } +} + +func TestStreamJSONEmitter_ErrorEvent(t *testing.T) { + e := streamJSONEmitter{} + events, _ := e.Emit(`{"type":"error","error":"something broke"}`) + if len(events) != 1 || events[0].Type != runtime.EventTypeError || events[0].Error != "something broke" { + t.Fatalf("unexpected events: %+v", events) + } +} + +func TestStreamJSONEmitter_EmptyContentSkipped(t *testing.T) { + e := streamJSONEmitter{} + events, _ := e.Emit(`{"type":"message","role":"assistant","content":""}`) + if len(events) != 0 { + t.Fatalf("expected 0 events for empty content, got %d", len(events)) + } +} + +func TestClaudeJSONEmitter_ErrorResult(t *testing.T) { + e := claudeJSONEmitter{} + events, _ := e.Emit(`{"type":"result","is_error":true,"result":"API timeout"}`) + if len(events) != 1 || events[0].Type != runtime.EventTypeError || events[0].Error != "API timeout" { + t.Fatalf("unexpected events: %+v", events) + } +} + +func TestClaudeJSONEmitter_NonTextDeltaSkipped(t *testing.T) { + e := claudeJSONEmitter{} + events, _ := e.Emit(`{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"image_delta","data":"base64"}}}`) + if len(events) != 0 { + t.Fatalf("expected 0 events for non-text delta, got %d", len(events)) + } +} + +func TestCodexJSONEmitter_ItemDeltaBecomesDelta(t *testing.T) { + e := codexJSONEmitter{} + events, _ := e.Emit(`{"type":"item.delta","item":{"type":"agent_message","delta":"Doing."}}`) + if len(events) != 1 || events[0].Delta != "Doing." { + t.Fatalf("unexpected events: %+v", events) + } +} + +func TestCodexJSONEmitter_OutputTextDeltaBecomesDelta(t *testing.T) { + e := codexJSONEmitter{} + events, _ := e.Emit(`{"type":"response.output_text.delta","delta":"chunk"}`) + if len(events) != 1 || events[0].Delta != "chunk" { + t.Fatalf("unexpected events: %+v", events) + } +} + +func TestCodexJSONEmitter_ContentOutputTextFallsBackToDelta(t *testing.T) { + e := codexJSONEmitter{} + events, _ := e.Emit(`{"type":"item.updated","item":{"type":"agent_message","message":{"content":[{"type":"output_text","text":"partial text"}]}}}`) + if len(events) != 1 || events[0].Delta != "partial text" { + t.Fatalf("unexpected events: %+v", events) + } +} + +func TestCodexJSONEmitter_TurnFailedBecomesError(t *testing.T) { + e := codexJSONEmitter{} + events, _ := e.Emit(`{"type":"turn.failed","error":{"message":"quota exceeded"}}`) + if len(events) != 1 || events[0].Type != runtime.EventTypeError || events[0].Error != "quota exceeded" { + t.Fatalf("unexpected events: %+v", events) + } +} + +func TestCodexJSONEmitter_StandardErrorEvent(t *testing.T) { + e := codexJSONEmitter{} + events, _ := e.Emit(`{"type":"error","message":"network timeout"}`) + if len(events) != 1 || events[0].Error != "network timeout" { + t.Fatalf("unexpected events: %+v", events) + } +} + +func TestCodexJSONEmitter_NonAgentMessageSkipped(t *testing.T) { + e := codexJSONEmitter{} + events, _ := e.Emit(`{"type":"item.completed","item":{"type":"tool_call","text":"ls -la"}}`) + if len(events) != 0 { + t.Fatalf("expected 0 events for non-agent_message item, got %d", len(events)) + } +} + +func TestClineJSONEmitter_CompletionError(t *testing.T) { + e := clineJSONEmitter{} + events, _ := e.Emit(`{"type":"completion","status":"error","error":"task failed"}`) + if len(events) != 1 || events[0].Error != "task failed" { + t.Fatalf("unexpected events: %+v", events) + } +} + +func TestClineJSONEmitter_SayErrorWithFallback(t *testing.T) { + e := clineJSONEmitter{} + events, _ := e.Emit(`{"type":"say","say":"error","message":"fallback msg"}`) + if len(events) != 1 || events[0].Error != "fallback msg" { + t.Fatalf("unexpected events: %+v", events) + } +} + +func TestClineJSONEmitter_CompletionErrorWithoutErrorField(t *testing.T) { + e := clineJSONEmitter{} + events, _ := e.Emit(`{"type":"completion","status":"error"}`) + if len(events) != 1 || events[0].Error != "cline task failed" { + t.Fatalf("unexpected events: %+v", events) + } +} + +// --- emitter registry 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) + } + } +} + +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) + } + } + if len(jsonEmitters) != len(expectedKeys) { + t.Errorf("expected %d registered emitters, got %d", len(expectedKeys), len(jsonEmitters)) + } +} diff --git a/apps/node/internal/adapters/cli/cli_test.go b/apps/node/internal/adapters/cli/cli_test.go deleted file mode 100644 index 2441ad1..0000000 --- a/apps/node/internal/adapters/cli/cli_test.go +++ /dev/null @@ -1,117 +0,0 @@ -package cli_test - -import ( - "context" - "strings" - "testing" - - "go.uber.org/zap" - - "iop/apps/node/internal/adapters/cli" - "iop/apps/node/internal/adapters/cli/status" - "iop/apps/node/internal/runtime" - "iop/packages/config" -) - -func TestCLIHandleCommandUsageStatusUsesSelectedAgent(t *testing.T) { - c := cli.New(config.CLIConf{ - Enabled: true, - Profiles: map[string]config.CLIProfileConf{ - "codex": { - Command: "/tmp/fake-codex", - }, - "cline-m1": { - Command: "cline-m1", - }, - }, - }, zap.NewNop()) - - // Inject fake checker - c.StatusChecker = func(ctx context.Context, agent string, profile config.CLIProfileConf) (*status.UsageStatus, error) { - if agent == "codex" { - if profile.Command != "/tmp/fake-codex" { - t.Errorf("expected command /tmp/fake-codex, got %q", profile.Command) - } - return &status.UsageStatus{RawOutput: "fake-codex-status"}, nil - } - return nil, nil - } - - // Test missing agent - _, err := c.HandleCommand(context.Background(), runtime.CommandRequest{ - Type: runtime.CommandTypeUsageStatus, - Model: "unknown", - }) - if err == nil || !strings.Contains(err.Error(), "unknown agent") { - t.Errorf("expected unknown agent error, got %v", err) - } - - // Test codex - resp, err := c.HandleCommand(context.Background(), runtime.CommandRequest{ - RequestID: "req-123", - Type: runtime.CommandTypeUsageStatus, - Model: "codex", - SessionID: "sess-456", - Adapter: "cli", - }) - if err != nil { - t.Fatalf("HandleCommand failed: %v", err) - } - if resp.UsageStatus == nil || resp.UsageStatus.RawOutput != "fake-codex-status" { - t.Errorf("unexpected usage status: %+v", resp.UsageStatus) - } - if resp.RequestID != "req-123" { - t.Errorf("expected RequestID req-123, got %s", resp.RequestID) - } - if resp.SessionID != "sess-456" { - t.Errorf("expected SessionID sess-456, got %s", resp.SessionID) - } - - // Test unsupported command type - _, err = c.HandleCommand(context.Background(), runtime.CommandRequest{ - Type: runtime.CommandType("invalid"), - Model: "codex", - }) - if err == nil || !strings.Contains(err.Error(), "unsupported command") { - t.Errorf("expected unsupported command error, got %v", err) - } -} - -func TestCapabilities_OnlyConfiguredProfiles(t *testing.T) { - c := cli.New(config.CLIConf{ - Enabled: true, - Profiles: map[string]config.CLIProfileConf{ - "foo": {Command: "foo"}, - "bar": {Command: "bar"}, - }, - }, zap.NewNop()) - - caps, err := c.Capabilities(context.Background()) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - want := []string{"bar", "foo"} - if len(caps.Models) != len(want) { - t.Fatalf("expected models %v, got %v", want, caps.Models) - } - for i, m := range want { - if caps.Models[i] != m { - t.Errorf("Models[%d] = %q, want %q", i, caps.Models[i], m) - } - } -} - -func TestCapabilities_EmptyProfilesReturnsEmptyModels(t *testing.T) { - c := cli.New(config.CLIConf{ - Enabled: true, - Profiles: map[string]config.CLIProfileConf{}, - }, zap.NewNop()) - - caps, err := c.Capabilities(context.Background()) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(caps.Models) != 0 { - t.Errorf("expected empty models, got %v", caps.Models) - } -} diff --git a/apps/node/internal/adapters/cli/codex_exec_args_test.go b/apps/node/internal/adapters/cli/codex_exec_args_test.go deleted file mode 100644 index 8a05870..0000000 --- a/apps/node/internal/adapters/cli/codex_exec_args_test.go +++ /dev/null @@ -1,43 +0,0 @@ -package cli - -import ( - "testing" - - "iop/packages/config" -) - -func TestCodexExecArgs_NoSessionUsesProfileArgs(t *testing.T) { - profile := config.CLIProfileConf{ - Command: "codex", - Args: []string{"exec", "--json"}, - } - - args := codexExecArgs(profile, "", "hello prompt") - expected := []string{"exec", "--json", "hello prompt"} - if len(args) != len(expected) { - t.Fatalf("expected %d args, got %d: %v", len(expected), len(args), args) - } - for i, v := range expected { - if args[i] != v { - t.Errorf("args[%d]: expected %q, got %q", i, v, args[i]) - } - } -} - -func TestCodexExecArgs_WithSessionUsesResumeArgs(t *testing.T) { - profile := config.CLIProfileConf{ - Args: []string{"exec", "--json"}, - ResumeArgs: []string{"exec", "resume", "--color", "never"}, - } - - args := codexExecArgs(profile, "session-abc", "hello prompt") - expected := []string{"exec", "resume", "--color", "never", "session-abc", "hello prompt"} - if len(args) != len(expected) { - t.Fatalf("expected %d args, got %d: %v", len(expected), len(args), args) - } - for i, v := range expected { - if args[i] != v { - t.Errorf("args[%d]: expected %q, got %q", i, v, args[i]) - } - } -} diff --git a/apps/node/internal/adapters/cli/emitters_internal_test.go b/apps/node/internal/adapters/cli/emitters_internal_test.go deleted file mode 100644 index 0bd300b..0000000 --- a/apps/node/internal/adapters/cli/emitters_internal_test.go +++ /dev/null @@ -1,551 +0,0 @@ -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) - } -} diff --git a/apps/node/internal/adapters/cli/lifecycle_blackbox_test.go b/apps/node/internal/adapters/cli/lifecycle_blackbox_test.go index d407e73..8da65b0 100644 --- a/apps/node/internal/adapters/cli/lifecycle_blackbox_test.go +++ b/apps/node/internal/adapters/cli/lifecycle_blackbox_test.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "path/filepath" + "strings" "testing" "time" @@ -11,6 +12,7 @@ import ( clipkg "iop/apps/node/internal/adapters/cli" "iop/apps/node/internal/adapters/cli/internal/testutil" + "iop/apps/node/internal/adapters/cli/status" noderuntime "iop/apps/node/internal/runtime" "iop/packages/config" ) @@ -382,3 +384,98 @@ func TestCLIStopStopsAllLogicalSessions(t *testing.T) { } } } + +func TestCLIHandleCommandUsageStatusUsesSelectedAgent(t *testing.T) { + c := clipkg.New(config.CLIConf{ + Enabled: true, + Profiles: map[string]config.CLIProfileConf{ + "codex": {Command: "/tmp/fake-codex"}, + "cline-m1": {Command: "cline-m1"}, + }, + }, zap.NewNop()) + + c.StatusChecker = func(ctx context.Context, agent string, profile config.CLIProfileConf) (*status.UsageStatus, error) { + if agent == "codex" { + if profile.Command != "/tmp/fake-codex" { + t.Errorf("expected command /tmp/fake-codex, got %q", profile.Command) + } + return &status.UsageStatus{RawOutput: "fake-codex-status"}, nil + } + return nil, nil + } + + _, err := c.HandleCommand(context.Background(), noderuntime.CommandRequest{ + Type: noderuntime.CommandTypeUsageStatus, + Model: "unknown", + }) + if err == nil || !strings.Contains(err.Error(), "unknown agent") { + t.Errorf("expected unknown agent error, got %v", err) + } + + resp, err := c.HandleCommand(context.Background(), noderuntime.CommandRequest{ + RequestID: "req-123", + Type: noderuntime.CommandTypeUsageStatus, + Model: "codex", + SessionID: "sess-456", + Adapter: "cli", + }) + if err != nil { + t.Fatalf("HandleCommand failed: %v", err) + } + if resp.UsageStatus == nil || resp.UsageStatus.RawOutput != "fake-codex-status" { + t.Errorf("unexpected usage status: %+v", resp.UsageStatus) + } + if resp.RequestID != "req-123" { + t.Errorf("expected RequestID req-123, got %s", resp.RequestID) + } + if resp.SessionID != "sess-456" { + t.Errorf("expected SessionID sess-456, got %s", resp.SessionID) + } + + _, err = c.HandleCommand(context.Background(), noderuntime.CommandRequest{ + Type: noderuntime.CommandType("invalid"), + Model: "codex", + }) + if err == nil || !strings.Contains(err.Error(), "unsupported command") { + t.Errorf("expected unsupported command error, got %v", err) + } +} + +func TestCapabilities_OnlyConfiguredProfiles(t *testing.T) { + c := clipkg.New(config.CLIConf{ + Enabled: true, + Profiles: map[string]config.CLIProfileConf{ + "foo": {Command: "foo"}, + "bar": {Command: "bar"}, + }, + }, zap.NewNop()) + + caps, err := c.Capabilities(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := []string{"bar", "foo"} + if len(caps.Models) != len(want) { + t.Fatalf("expected models %v, got %v", want, caps.Models) + } + for i, m := range want { + if caps.Models[i] != m { + t.Errorf("Models[%d] = %q, want %q", i, caps.Models[i], m) + } + } +} + +func TestCapabilities_EmptyProfilesReturnsEmptyModels(t *testing.T) { + c := clipkg.New(config.CLIConf{ + Enabled: true, + Profiles: map[string]config.CLIProfileConf{}, + }, zap.NewNop()) + + caps, err := c.Capabilities(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(caps.Models) != 0 { + t.Errorf("expected empty models, got %v", caps.Models) + } +} diff --git a/apps/node/internal/adapters/factory_test.go b/apps/node/internal/adapters/factory_test.go deleted file mode 100644 index 6db5fd8..0000000 --- a/apps/node/internal/adapters/factory_test.go +++ /dev/null @@ -1,79 +0,0 @@ -package adapters_test - -import ( - "testing" - - "go.uber.org/zap" - - "iop/apps/node/internal/adapters" - iop "iop/proto/gen/iop" -) - -func TestBuildFromPayload_MockAlwaysPresent(t *testing.T) { - reg, err := adapters.BuildFromPayload(&iop.NodeConfigPayload{}, zap.NewNop()) - if err != nil { - t.Fatalf("build from payload: %v", err) - } - if _, ok := reg.Get("mock"); !ok { - t.Fatal("expected mock adapter to be registered") - } - if got := len(reg.All()); got != 1 { - t.Fatalf("expected 1 adapter, got %d", got) - } -} - -func TestBuildFromPayload_OllamaEnabled(t *testing.T) { - reg, err := adapters.BuildFromPayload(&iop.NodeConfigPayload{ - Adapters: []*iop.AdapterConfig{ - { - Type: "ollama", - Enabled: true, - Config: &iop.AdapterConfig_Ollama{ - Ollama: &iop.OllamaAdapterConfig{BaseUrl: "http://localhost:11434"}, - }, - }, - }, - }, zap.NewNop()) - if err != nil { - t.Fatalf("build from payload: %v", err) - } - if _, ok := reg.Get("mock"); !ok { - t.Fatal("expected mock adapter to be registered") - } - if _, ok := reg.Get("ollama"); !ok { - t.Fatal("expected ollama adapter to be registered") - } -} - -func TestBuildFromPayload_MultipleAdapters(t *testing.T) { - reg, err := adapters.BuildFromPayload(&iop.NodeConfigPayload{ - Adapters: []*iop.AdapterConfig{ - {Type: "ollama", Enabled: true, Config: &iop.AdapterConfig_Ollama{Ollama: &iop.OllamaAdapterConfig{BaseUrl: "x"}}}, - {Type: "vllm", Enabled: true, Config: &iop.AdapterConfig_Vllm{Vllm: &iop.VllmAdapterConfig{Endpoint: "y"}}}, - {Type: "cli", Enabled: true, Config: &iop.AdapterConfig_Cli{Cli: &iop.CLIAdapterConfig{ - Profiles: map[string]*iop.CLIProfileConfig{ - "codex": {Command: "codex", Persistent: true, ResponseIdleTimeoutMs: 1500, StartupIdleTimeoutMs: 300}, - }, - }}}, - }, - }, zap.NewNop()) - if err != nil { - t.Fatalf("build from payload: %v", err) - } - for _, name := range []string{"mock", "ollama", "vllm", "cli"} { - if _, ok := reg.Get(name); !ok { - t.Fatalf("expected %s adapter to be registered", name) - } - } -} - -func TestBuildFromPayload_UnknownType(t *testing.T) { - _, err := adapters.BuildFromPayload(&iop.NodeConfigPayload{ - Adapters: []*iop.AdapterConfig{ - {Type: "unknown", Enabled: true}, - }, - }, zap.NewNop()) - if err == nil { - t.Fatal("expected unknown adapter type error") - } -}