diff --git a/pkg/agentic/dispatch.go b/pkg/agentic/dispatch.go index ecff0ee..7a0ed82 100644 --- a/pkg/agentic/dispatch.go +++ b/pkg/agentic/dispatch.go @@ -123,14 +123,11 @@ func (s *PrepSubsystem) spawnAgent(agent, prompt, wsDir, srcDir string) (int, st outputFile := filepath.Join(wsDir, fmt.Sprintf("agent-%s.log", agent)) proc, err := process.StartWithOptions(context.Background(), process.RunOptions{ - Command: command, - Args: args, - Dir: srcDir, - Env: []string{"TERM=dumb", "NO_COLOR=1", "CI=true", "GOWORK=off"}, - Detach: true, - KillGroup: true, - Timeout: 30 * time.Minute, - GracePeriod: 10 * time.Second, + Command: command, + Args: args, + Dir: srcDir, + Env: []string{"TERM=dumb", "NO_COLOR=1", "CI=true", "GOWORK=off"}, + Detach: true, }) if err != nil { return 0, "", coreerr.E("dispatch.spawnAgent", "failed to spawn "+agent, err) diff --git a/pkg/brain/brain_test.go b/pkg/brain/brain_test.go index bf71cc5..66c092e 100644 --- a/pkg/brain/brain_test.go +++ b/pkg/brain/brain_test.go @@ -7,6 +7,9 @@ import ( "encoding/json" "testing" "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) // --- Nil bridge tests (headless mode) --- @@ -17,9 +20,7 @@ func TestBrainRemember_Bad_NilBridge(t *testing.T) { Content: "test memory", Type: "observation", }) - if err == nil { - t.Error("expected error when bridge is nil") - } + require.Error(t, err) } func TestBrainRecall_Bad_NilBridge(t *testing.T) { @@ -27,9 +28,7 @@ func TestBrainRecall_Bad_NilBridge(t *testing.T) { _, _, err := sub.brainRecall(context.Background(), nil, RecallInput{ Query: "how does scoring work?", }) - if err == nil { - t.Error("expected error when bridge is nil") - } + require.Error(t, err) } func TestBrainForget_Bad_NilBridge(t *testing.T) { @@ -37,9 +36,7 @@ func TestBrainForget_Bad_NilBridge(t *testing.T) { _, _, err := sub.brainForget(context.Background(), nil, ForgetInput{ ID: "550e8400-e29b-41d4-a716-446655440000", }) - if err == nil { - t.Error("expected error when bridge is nil") - } + require.Error(t, err) } func TestBrainList_Bad_NilBridge(t *testing.T) { @@ -47,29 +44,31 @@ func TestBrainList_Bad_NilBridge(t *testing.T) { _, _, err := sub.brainList(context.Background(), nil, ListInput{ Project: "eaas", }) - if err == nil { - t.Error("expected error when bridge is nil") - } + require.Error(t, err) } // --- Subsystem interface tests --- func TestSubsystem_Good_Name(t *testing.T) { sub := New(nil) - if sub.Name() != "brain" { - t.Errorf("expected Name() = 'brain', got %q", sub.Name()) - } + assert.Equal(t, "brain", sub.Name()) } func TestSubsystem_Good_ShutdownNoop(t *testing.T) { sub := New(nil) - if err := sub.Shutdown(context.Background()); err != nil { - t.Errorf("Shutdown failed: %v", err) - } + assert.NoError(t, sub.Shutdown(context.Background())) } // --- Struct round-trip tests --- +// roundTrip marshals v to JSON and unmarshals into dst, failing on error. +func roundTrip(t *testing.T, v any, dst any) { + t.Helper() + data, err := json.Marshal(v) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(data, dst)) +} + func TestRememberInput_Good_RoundTrip(t *testing.T) { in := RememberInput{ Content: "LEM scoring was blind to negative emotions", @@ -80,23 +79,12 @@ func TestRememberInput_Good_RoundTrip(t *testing.T) { Supersedes: "550e8400-e29b-41d4-a716-446655440000", ExpiresIn: 24, } - data, err := json.Marshal(in) - if err != nil { - t.Fatalf("marshal failed: %v", err) - } var out RememberInput - if err := json.Unmarshal(data, &out); err != nil { - t.Fatalf("unmarshal failed: %v", err) - } - if out.Content != in.Content || out.Type != in.Type { - t.Errorf("round-trip mismatch: content or type") - } - if len(out.Tags) != 2 || out.Tags[0] != "scoring" { - t.Errorf("round-trip mismatch: tags") - } - if out.Confidence != 0.95 { - t.Errorf("round-trip mismatch: confidence %f != 0.95", out.Confidence) - } + roundTrip(t, in, &out) + assert.Equal(t, in.Content, out.Content) + assert.Equal(t, in.Type, out.Type) + assert.Equal(t, []string{"scoring", "lem"}, out.Tags) + assert.Equal(t, 0.95, out.Confidence) } func TestRememberOutput_Good_RoundTrip(t *testing.T) { @@ -105,17 +93,10 @@ func TestRememberOutput_Good_RoundTrip(t *testing.T) { MemoryID: "550e8400-e29b-41d4-a716-446655440000", Timestamp: time.Now().Truncate(time.Second), } - data, err := json.Marshal(in) - if err != nil { - t.Fatalf("marshal failed: %v", err) - } var out RememberOutput - if err := json.Unmarshal(data, &out); err != nil { - t.Fatalf("unmarshal failed: %v", err) - } - if !out.Success || out.MemoryID != in.MemoryID { - t.Errorf("round-trip mismatch: %+v != %+v", out, in) - } + roundTrip(t, in, &out) + assert.True(t, out.Success) + assert.Equal(t, in.MemoryID, out.MemoryID) } func TestRecallInput_Good_RoundTrip(t *testing.T) { @@ -127,20 +108,12 @@ func TestRecallInput_Good_RoundTrip(t *testing.T) { MinConfidence: 0.5, }, } - data, err := json.Marshal(in) - if err != nil { - t.Fatalf("marshal failed: %v", err) - } var out RecallInput - if err := json.Unmarshal(data, &out); err != nil { - t.Fatalf("unmarshal failed: %v", err) - } - if out.Query != in.Query || out.TopK != 5 { - t.Errorf("round-trip mismatch: query or topK") - } - if out.Filter.Project != "eaas" || out.Filter.MinConfidence != 0.5 { - t.Errorf("round-trip mismatch: filter") - } + roundTrip(t, in, &out) + assert.Equal(t, in.Query, out.Query) + assert.Equal(t, 5, out.TopK) + assert.Equal(t, "eaas", out.Filter.Project) + assert.Equal(t, 0.5, out.Filter.MinConfidence) } func TestMemory_Good_RoundTrip(t *testing.T) { @@ -155,17 +128,11 @@ func TestMemory_Good_RoundTrip(t *testing.T) { CreatedAt: "2026-03-03T12:00:00+00:00", UpdatedAt: "2026-03-03T12:00:00+00:00", } - data, err := json.Marshal(in) - if err != nil { - t.Fatalf("marshal failed: %v", err) - } var out Memory - if err := json.Unmarshal(data, &out); err != nil { - t.Fatalf("unmarshal failed: %v", err) - } - if out.ID != in.ID || out.AgentID != "virgil" || out.Type != "decision" { - t.Errorf("round-trip mismatch: %+v", out) - } + roundTrip(t, in, &out) + assert.Equal(t, in.ID, out.ID) + assert.Equal(t, "virgil", out.AgentID) + assert.Equal(t, "decision", out.Type) } func TestForgetInput_Good_RoundTrip(t *testing.T) { @@ -173,17 +140,10 @@ func TestForgetInput_Good_RoundTrip(t *testing.T) { ID: "550e8400-e29b-41d4-a716-446655440000", Reason: "Superseded by new approach", } - data, err := json.Marshal(in) - if err != nil { - t.Fatalf("marshal failed: %v", err) - } var out ForgetInput - if err := json.Unmarshal(data, &out); err != nil { - t.Fatalf("unmarshal failed: %v", err) - } - if out.ID != in.ID || out.Reason != in.Reason { - t.Errorf("round-trip mismatch: %+v != %+v", out, in) - } + roundTrip(t, in, &out) + assert.Equal(t, in.ID, out.ID) + assert.Equal(t, in.Reason, out.Reason) } func TestListInput_Good_RoundTrip(t *testing.T) { @@ -193,17 +153,9 @@ func TestListInput_Good_RoundTrip(t *testing.T) { AgentID: "charon", Limit: 20, } - data, err := json.Marshal(in) - if err != nil { - t.Fatalf("marshal failed: %v", err) - } var out ListInput - if err := json.Unmarshal(data, &out); err != nil { - t.Fatalf("unmarshal failed: %v", err) - } - if out.Project != "eaas" || out.Type != "decision" || out.AgentID != "charon" || out.Limit != 20 { - t.Errorf("round-trip mismatch: %+v", out) - } + roundTrip(t, in, &out) + assert.Equal(t, in, out) } func TestListOutput_Good_RoundTrip(t *testing.T) { @@ -215,15 +167,9 @@ func TestListOutput_Good_RoundTrip(t *testing.T) { {ID: "id-2", AgentID: "charon", Type: "bug", Content: "memory 2", Confidence: 0.8, CreatedAt: "2026-03-03T13:00:00+00:00", UpdatedAt: "2026-03-03T13:00:00+00:00"}, }, } - data, err := json.Marshal(in) - if err != nil { - t.Fatalf("marshal failed: %v", err) - } var out ListOutput - if err := json.Unmarshal(data, &out); err != nil { - t.Fatalf("unmarshal failed: %v", err) - } - if !out.Success || out.Count != 2 || len(out.Memories) != 2 { - t.Errorf("round-trip mismatch: %+v", out) - } + roundTrip(t, in, &out) + assert.True(t, out.Success) + assert.Equal(t, 2, out.Count) + require.Len(t, out.Memories, 2) } diff --git a/pkg/brain/bridge_test.go b/pkg/brain/bridge_test.go new file mode 100644 index 0000000..5c5200e --- /dev/null +++ b/pkg/brain/bridge_test.go @@ -0,0 +1,199 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package brain + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + ws "forge.lthn.ai/core/go-ws" + "forge.lthn.ai/core/mcp/pkg/mcp/ide" + "github.com/gorilla/websocket" + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// testWSServer creates a WS server that accepts connections and discards messages. +func testWSServer(t *testing.T) *httptest.Server { + t.Helper() + upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + defer conn.Close() + for { + if _, _, err := conn.ReadMessage(); err != nil { + return + } + } + })) + t.Cleanup(srv.Close) + return srv +} + +// testBridge creates a bridge connected to a test WS server. +func testBridge(t *testing.T) *ide.Bridge { + t.Helper() + srv := testWSServer(t) + + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + hub := ws.NewHub() + bridge := ide.NewBridge(hub, ide.Config{ + LaravelWSURL: wsURL, + ReconnectInterval: 100 * time.Millisecond, + }) + bridge.Start(context.Background()) + + require.Eventually(t, func() bool { + return bridge.Connected() + }, 2*time.Second, 10*time.Millisecond, "bridge did not connect") + + t.Cleanup(bridge.Shutdown) + return bridge +} + +// --- RegisterTools --- + +func TestSubsystem_Good_RegisterTools(t *testing.T) { + sub := New(nil) + srv := mcpsdk.NewServer(&mcpsdk.Implementation{Name: "test", Version: "0.1.0"}, nil) + sub.RegisterTools(srv) +} + +func TestDirectSubsystem_Good_RegisterTools(t *testing.T) { + t.Setenv("CORE_BRAIN_URL", "http://localhost") + t.Setenv("CORE_BRAIN_KEY", "test-key") + sub := NewDirect() + srv := mcpsdk.NewServer(&mcpsdk.Implementation{Name: "test", Version: "0.1.0"}, nil) + sub.RegisterTools(srv) +} + +// --- Subsystem with connected bridge --- + +func TestBrainRemember_Good_WithBridge(t *testing.T) { + sub := New(testBridge(t)) + _, out, err := sub.brainRemember(context.Background(), nil, RememberInput{ + Content: "test memory", + Type: "observation", + Tags: []string{"test"}, + Project: "core", + }) + require.NoError(t, err) + assert.True(t, out.Success) + assert.False(t, out.Timestamp.IsZero()) +} + +func TestBrainRecall_Good_WithBridge(t *testing.T) { + sub := New(testBridge(t)) + _, out, err := sub.brainRecall(context.Background(), nil, RecallInput{ + Query: "architecture", + TopK: 5, + }) + require.NoError(t, err) + assert.True(t, out.Success) + assert.Empty(t, out.Memories) +} + +func TestBrainForget_Good_WithBridge(t *testing.T) { + sub := New(testBridge(t)) + _, out, err := sub.brainForget(context.Background(), nil, ForgetInput{ + ID: "mem-123", + Reason: "outdated", + }) + require.NoError(t, err) + assert.True(t, out.Success) + assert.Equal(t, "mem-123", out.Forgotten) + assert.False(t, out.Timestamp.IsZero()) +} + +func TestBrainList_Good_WithBridge(t *testing.T) { + sub := New(testBridge(t)) + _, out, err := sub.brainList(context.Background(), nil, ListInput{ + Project: "core", + Type: "decision", + Limit: 10, + }) + require.NoError(t, err) + assert.True(t, out.Success) + assert.Empty(t, out.Memories) +} + +// --- Provider handlers with connected bridge --- + +func TestRememberHandler_Good_WithBridge(t *testing.T) { + p := NewProvider(testBridge(t), nil) + body, _ := json.Marshal(RememberInput{ + Content: "provider test memory", + Type: "fact", + Tags: []string{"test"}, + Project: "agent", + Confidence: 0.9, + }) + w := providerRequest(t, p, "POST", "/api/brain/remember", body) + assert.Equal(t, http.StatusOK, w.Code) +} + +func TestRememberHandler_Bad_InvalidBody(t *testing.T) { + p := NewProvider(testBridge(t), nil) + w := providerRequest(t, p, "POST", "/api/brain/remember", []byte("{")) + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestRecallHandler_Good_WithBridge(t *testing.T) { + p := NewProvider(testBridge(t), nil) + body, _ := json.Marshal(RecallInput{Query: "test", TopK: 5}) + w := providerRequest(t, p, "POST", "/api/brain/recall", body) + assert.Equal(t, http.StatusOK, w.Code) +} + +func TestRecallHandler_Bad_InvalidBody(t *testing.T) { + p := NewProvider(testBridge(t), nil) + w := providerRequest(t, p, "POST", "/api/brain/recall", []byte("bad")) + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestForgetHandler_Good_WithBridge(t *testing.T) { + p := NewProvider(testBridge(t), nil) + body, _ := json.Marshal(ForgetInput{ID: "mem-abc", Reason: "outdated"}) + w := providerRequest(t, p, "POST", "/api/brain/forget", body) + assert.Equal(t, http.StatusOK, w.Code) +} + +func TestForgetHandler_Bad_InvalidBody(t *testing.T) { + p := NewProvider(testBridge(t), nil) + w := providerRequest(t, p, "POST", "/api/brain/forget", []byte("{")) + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestListHandler_Good_WithBridge(t *testing.T) { + p := NewProvider(testBridge(t), nil) + w := providerRequest(t, p, "GET", "/api/brain/list?project=core&type=decision&limit=10", nil) + assert.Equal(t, http.StatusOK, w.Code) +} + +func TestStatusHandler_Good_WithBridge(t *testing.T) { + p := NewProvider(testBridge(t), nil) + w := providerRequest(t, p, "GET", "/api/brain/status", nil) + + assert.Equal(t, http.StatusOK, w.Code) + var resp map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + data, _ := resp["data"].(map[string]any) + assert.Equal(t, true, data["connected"]) +} + +// --- emitEvent with hub --- + +func TestEmitEvent_Good_WithHub(t *testing.T) { + hub := ws.NewHub() + p := NewProvider(nil, hub) + p.emitEvent("brain.test", map[string]any{"key": "value"}) +} diff --git a/pkg/brain/direct_test.go b/pkg/brain/direct_test.go new file mode 100644 index 0000000..041748f --- /dev/null +++ b/pkg/brain/direct_test.go @@ -0,0 +1,357 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package brain + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + + coreio "forge.lthn.ai/core/go-io" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// newTestDirect returns a DirectSubsystem wired to the given test server. +func newTestDirect(srv *httptest.Server) *DirectSubsystem { + return &DirectSubsystem{apiURL: srv.URL, apiKey: "test-key", client: srv.Client()} +} + +// jsonHandler returns an http.Handler that responds with the given JSON payload. +func jsonHandler(payload any) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(payload) + }) +} + +// errorHandler returns an http.Handler that responds with the given status and body. +func errorHandler(status int, body string) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(status) + w.Write([]byte(body)) + }) +} + +// --- DirectSubsystem construction --- + +func TestNewDirect_Good_Defaults(t *testing.T) { + t.Setenv("CORE_BRAIN_URL", "") + t.Setenv("CORE_BRAIN_KEY", "") + + sub := NewDirect() + assert.Equal(t, "https://api.lthn.sh", sub.apiURL) + assert.NotNil(t, sub.client) +} + +func TestNewDirect_Good_CustomEnv(t *testing.T) { + t.Setenv("CORE_BRAIN_URL", "https://custom.api.test") + t.Setenv("CORE_BRAIN_KEY", "test-key-123") + + sub := NewDirect() + assert.Equal(t, "https://custom.api.test", sub.apiURL) + assert.Equal(t, "test-key-123", sub.apiKey) +} + +func TestNewDirect_Good_KeyFromFile(t *testing.T) { + t.Setenv("CORE_BRAIN_URL", "") + t.Setenv("CORE_BRAIN_KEY", "") + + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + keyDir := filepath.Join(tmpHome, ".claude") + require.NoError(t, coreio.Local.EnsureDir(keyDir)) + require.NoError(t, coreio.Local.Write(filepath.Join(keyDir, "brain.key"), " file-key-456 \n")) + + sub := NewDirect() + assert.Equal(t, "file-key-456", sub.apiKey) +} + +func TestDirectSubsystem_Good_Name(t *testing.T) { + sub := &DirectSubsystem{} + assert.Equal(t, "brain", sub.Name()) +} + +func TestDirectSubsystem_Good_Shutdown(t *testing.T) { + sub := &DirectSubsystem{} + assert.NoError(t, sub.Shutdown(context.Background())) +} + +// --- apiCall --- + +func TestApiCall_Bad_NoAPIKey(t *testing.T) { + sub := &DirectSubsystem{apiURL: "http://localhost", apiKey: ""} + _, err := sub.apiCall(context.Background(), "GET", "/test", nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "no API key") +} + +func TestApiCall_Good_GET(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "GET", r.Method) + assert.Equal(t, "/v1/test", r.URL.Path) + assert.Equal(t, "Bearer test-key", r.Header.Get("Authorization")) + assert.Equal(t, "application/json", r.Header.Get("Accept")) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{"status": "ok"}) + })) + defer srv.Close() + + result, err := newTestDirect(srv).apiCall(context.Background(), "GET", "/v1/test", nil) + require.NoError(t, err) + assert.Equal(t, "ok", result["status"]) +} + +func TestApiCall_Good_POSTWithBody(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "POST", r.Method) + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + + var body map[string]any + json.NewDecoder(r.Body).Decode(&body) + assert.Equal(t, "hello", body["content"]) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{"id": "mem-123"}) + })) + defer srv.Close() + + result, err := newTestDirect(srv).apiCall(context.Background(), "POST", "/v1/brain/remember", map[string]any{"content": "hello"}) + require.NoError(t, err) + assert.Equal(t, "mem-123", result["id"]) +} + +func TestApiCall_Bad_ServerError(t *testing.T) { + srv := httptest.NewServer(errorHandler(http.StatusInternalServerError, `{"error":"internal"}`)) + defer srv.Close() + + _, err := newTestDirect(srv).apiCall(context.Background(), "GET", "/v1/test", nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "500") +} + +func TestApiCall_Bad_InvalidJSON(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/plain") + w.Write([]byte("not json")) + })) + defer srv.Close() + + _, err := newTestDirect(srv).apiCall(context.Background(), "GET", "/v1/test", nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "parse response") +} + +func TestApiCall_Bad_ConnectionRefused(t *testing.T) { + sub := &DirectSubsystem{apiURL: "http://127.0.0.1:1", apiKey: "test-key", client: http.DefaultClient} + _, err := sub.apiCall(context.Background(), "GET", "/v1/test", nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "API call failed") +} + +func TestApiCall_Bad_BadRequest(t *testing.T) { + srv := httptest.NewServer(errorHandler(http.StatusBadRequest, `{"error":"bad input"}`)) + defer srv.Close() + + _, err := newTestDirect(srv).apiCall(context.Background(), "GET", "/v1/test", nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "400") +} + +// --- remember --- + +func TestRemember_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "POST", r.Method) + assert.Equal(t, "/v1/brain/remember", r.URL.Path) + + var body map[string]any + json.NewDecoder(r.Body).Decode(&body) + assert.Equal(t, "test content", body["content"]) + assert.Equal(t, "observation", body["type"]) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{"id": "mem-abc"}) + })) + defer srv.Close() + + _, out, err := newTestDirect(srv).remember(context.Background(), nil, RememberInput{ + Content: "test content", + Type: "observation", + Tags: []string{"test"}, + Project: "core", + }) + require.NoError(t, err) + assert.True(t, out.Success) + assert.Equal(t, "mem-abc", out.MemoryID) + assert.False(t, out.Timestamp.IsZero()) +} + +func TestRemember_Bad_APIError(t *testing.T) { + srv := httptest.NewServer(errorHandler(http.StatusInternalServerError, `{"error":"db down"}`)) + defer srv.Close() + + _, out, err := newTestDirect(srv).remember(context.Background(), nil, RememberInput{ + Content: "test", + Type: "fact", + }) + require.Error(t, err) + assert.False(t, out.Success) +} + +// --- recall --- + +func TestRecall_Good_WithMemories(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "POST", r.Method) + assert.Equal(t, "/v1/brain/recall", r.URL.Path) + + var body map[string]any + json.NewDecoder(r.Body).Decode(&body) + assert.Equal(t, "architecture", body["query"]) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "memories": []any{ + map[string]any{ + "id": "mem-1", + "content": "Use Qdrant for vector search", + "type": "decision", + "project": "agent", + "agent_id": "virgil", + "score": 0.95, + "source": "manual", + "created_at": "2026-03-03T12:00:00Z", + }, + map[string]any{ + "id": "mem-2", + "content": "DuckDB for embedded use", + "type": "architecture", + "project": "agent", + "agent_id": "cladius", + "score": 0.88, + "created_at": "2026-03-04T10:00:00Z", + }, + }, + }) + })) + defer srv.Close() + + _, out, err := newTestDirect(srv).recall(context.Background(), nil, RecallInput{ + Query: "architecture", + TopK: 5, + }) + require.NoError(t, err) + assert.True(t, out.Success) + assert.Equal(t, 2, out.Count) + require.Len(t, out.Memories, 2) + + assert.Equal(t, "mem-1", out.Memories[0].ID) + assert.Equal(t, "Use Qdrant for vector search", out.Memories[0].Content) + assert.Equal(t, "decision", out.Memories[0].Type) + assert.Equal(t, "virgil", out.Memories[0].AgentID) + assert.Equal(t, 0.95, out.Memories[0].Confidence) + assert.Contains(t, out.Memories[0].Tags, "source:manual") + + assert.Equal(t, "mem-2", out.Memories[1].ID) +} + +func TestRecall_Good_DefaultTopK(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + json.NewDecoder(r.Body).Decode(&body) + assert.Equal(t, float64(10), body["top_k"]) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{"memories": []any{}}) + })) + defer srv.Close() + + _, out, err := newTestDirect(srv).recall(context.Background(), nil, RecallInput{ + Query: "test", + TopK: 0, + }) + require.NoError(t, err) + assert.True(t, out.Success) + assert.Equal(t, 0, out.Count) +} + +func TestRecall_Good_WithFilters(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + json.NewDecoder(r.Body).Decode(&body) + assert.Equal(t, "cladius", body["agent_id"]) + assert.Equal(t, "eaas", body["project"]) + assert.Equal(t, "decision", body["type"]) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{"memories": []any{}}) + })) + defer srv.Close() + + _, _, err := newTestDirect(srv).recall(context.Background(), nil, RecallInput{ + Query: "scoring", + TopK: 5, + Filter: RecallFilter{ + AgentID: "cladius", + Project: "eaas", + Type: "decision", + }, + }) + require.NoError(t, err) +} + +func TestRecall_Good_EmptyMemories(t *testing.T) { + srv := httptest.NewServer(jsonHandler(map[string]any{"memories": []any{}})) + defer srv.Close() + + _, out, err := newTestDirect(srv).recall(context.Background(), nil, RecallInput{Query: "nothing"}) + require.NoError(t, err) + assert.True(t, out.Success) + assert.Equal(t, 0, out.Count) + assert.Empty(t, out.Memories) +} + +func TestRecall_Bad_APIError(t *testing.T) { + srv := httptest.NewServer(errorHandler(http.StatusServiceUnavailable, `{"error":"qdrant down"}`)) + defer srv.Close() + + _, out, err := newTestDirect(srv).recall(context.Background(), nil, RecallInput{Query: "test"}) + require.Error(t, err) + assert.False(t, out.Success) +} + +// --- forget --- + +func TestForget_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "DELETE", r.Method) + assert.Equal(t, "/v1/brain/forget/mem-123", r.URL.Path) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{"deleted": true}) + })) + defer srv.Close() + + _, out, err := newTestDirect(srv).forget(context.Background(), nil, ForgetInput{ + ID: "mem-123", + Reason: "outdated", + }) + require.NoError(t, err) + assert.True(t, out.Success) + assert.Equal(t, "mem-123", out.Forgotten) + assert.False(t, out.Timestamp.IsZero()) +} + +func TestForget_Bad_APIError(t *testing.T) { + srv := httptest.NewServer(errorHandler(http.StatusNotFound, `{"error":"not found"}`)) + defer srv.Close() + + _, out, err := newTestDirect(srv).forget(context.Background(), nil, ForgetInput{ID: "nonexistent"}) + require.Error(t, err) + assert.False(t, out.Success) +} diff --git a/pkg/brain/messaging_test.go b/pkg/brain/messaging_test.go new file mode 100644 index 0000000..57ad7a8 --- /dev/null +++ b/pkg/brain/messaging_test.go @@ -0,0 +1,304 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package brain + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// localDirect returns a DirectSubsystem that never hits the network. +// Suitable for tests that validate input before making API calls. +func localDirect() *DirectSubsystem { + return &DirectSubsystem{apiURL: "http://localhost", apiKey: "test-key", client: http.DefaultClient} +} + +// --- sendMessage --- + +func TestSendMessage_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "POST", r.Method) + assert.Equal(t, "/v1/messages/send", r.URL.Path) + + var body map[string]any + json.NewDecoder(r.Body).Decode(&body) + assert.Equal(t, "charon", body["to"]) + assert.Equal(t, "deploy complete", body["content"]) + assert.Equal(t, "status update", body["subject"]) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{"id": float64(42)}, + }) + })) + defer srv.Close() + + _, out, err := newTestDirect(srv).sendMessage(context.Background(), nil, SendInput{ + To: "charon", + Content: "deploy complete", + Subject: "status update", + }) + require.NoError(t, err) + assert.True(t, out.Success) + assert.Equal(t, 42, out.ID) + assert.Equal(t, "charon", out.To) +} + +func TestSendMessage_Bad_EmptyTo(t *testing.T) { + _, _, err := localDirect().sendMessage(context.Background(), nil, SendInput{ + To: "", + Content: "hello", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "to and content are required") +} + +func TestSendMessage_Bad_EmptyContent(t *testing.T) { + _, _, err := localDirect().sendMessage(context.Background(), nil, SendInput{ + To: "charon", + Content: "", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "to and content are required") +} + +func TestSendMessage_Bad_APIError(t *testing.T) { + srv := httptest.NewServer(errorHandler(http.StatusInternalServerError, `{"error":"queue full"}`)) + defer srv.Close() + + _, out, err := newTestDirect(srv).sendMessage(context.Background(), nil, SendInput{ + To: "charon", + Content: "hello", + }) + require.Error(t, err) + assert.False(t, out.Success) +} + +// --- inbox --- + +func TestInbox_Good_WithMessages(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "GET", r.Method) + assert.Contains(t, r.URL.Path, "/v1/messages/inbox") + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "data": []any{ + map[string]any{ + "id": float64(1), + "from": "charon", + "to": "cladius", + "subject": "status", + "content": "deploy done", + "read": true, + "created_at": "2026-03-10T12:00:00Z", + }, + map[string]any{ + "id": float64(2), + "from": "clotho", + "to": "cladius", + "subject": "review", + "content": "PR ready", + "read": false, + "created_at": "2026-03-10T13:00:00Z", + }, + }, + }) + })) + defer srv.Close() + + _, out, err := newTestDirect(srv).inbox(context.Background(), nil, InboxInput{Agent: "cladius"}) + require.NoError(t, err) + assert.True(t, out.Success) + require.Len(t, out.Messages, 2) + assert.Equal(t, 1, out.Messages[0].ID) + assert.Equal(t, "charon", out.Messages[0].From) + assert.Equal(t, "deploy done", out.Messages[0].Content) + assert.True(t, out.Messages[0].Read) + assert.Equal(t, 2, out.Messages[1].ID) + assert.False(t, out.Messages[1].Read) +} + +func TestInbox_Good_EmptyInbox(t *testing.T) { + srv := httptest.NewServer(jsonHandler(map[string]any{"data": []any{}})) + defer srv.Close() + + _, out, err := newTestDirect(srv).inbox(context.Background(), nil, InboxInput{Agent: "cladius"}) + require.NoError(t, err) + assert.True(t, out.Success) + assert.Empty(t, out.Messages) +} + +func TestInbox_Bad_APIError(t *testing.T) { + srv := httptest.NewServer(errorHandler(http.StatusInternalServerError, `{"error":"db down"}`)) + defer srv.Close() + + _, out, err := newTestDirect(srv).inbox(context.Background(), nil, InboxInput{}) + require.Error(t, err) + assert.False(t, out.Success) +} + +// --- conversation --- + +func TestConversation_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "GET", r.Method) + assert.Contains(t, r.URL.Path, "/v1/messages/conversation/charon") + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "data": []any{ + map[string]any{ + "id": float64(10), + "from": "cladius", + "to": "charon", + "content": "how is the deploy?", + "created_at": "2026-03-10T12:00:00Z", + }, + map[string]any{ + "id": float64(11), + "from": "charon", + "to": "cladius", + "content": "all green", + "created_at": "2026-03-10T12:01:00Z", + }, + }, + }) + })) + defer srv.Close() + + _, out, err := newTestDirect(srv).conversation(context.Background(), nil, ConversationInput{Agent: "charon"}) + require.NoError(t, err) + assert.True(t, out.Success) + require.Len(t, out.Messages, 2) + assert.Equal(t, "how is the deploy?", out.Messages[0].Content) + assert.Equal(t, "all green", out.Messages[1].Content) +} + +func TestConversation_Bad_EmptyAgent(t *testing.T) { + _, _, err := localDirect().conversation(context.Background(), nil, ConversationInput{Agent: ""}) + require.Error(t, err) + assert.Contains(t, err.Error(), "agent is required") +} + +func TestConversation_Bad_APIError(t *testing.T) { + srv := httptest.NewServer(errorHandler(http.StatusNotFound, `{"error":"agent not found"}`)) + defer srv.Close() + + _, out, err := newTestDirect(srv).conversation(context.Background(), nil, ConversationInput{Agent: "nonexistent"}) + require.Error(t, err) + assert.False(t, out.Success) +} + +// --- parseMessages --- + +func TestParseMessages_Good(t *testing.T) { + result := map[string]any{ + "data": []any{ + map[string]any{ + "id": float64(5), + "from": "alice", + "to": "bob", + "subject": "hello", + "content": "hi there", + "read": true, + "created_at": "2026-03-10T10:00:00Z", + }, + }, + } + msgs := parseMessages(result) + require.Len(t, msgs, 1) + assert.Equal(t, 5, msgs[0].ID) + assert.Equal(t, "alice", msgs[0].From) + assert.Equal(t, "bob", msgs[0].To) + assert.Equal(t, "hello", msgs[0].Subject) + assert.Equal(t, "hi there", msgs[0].Content) + assert.True(t, msgs[0].Read) + assert.Equal(t, "2026-03-10T10:00:00Z", msgs[0].CreatedAt) +} + +func TestParseMessages_Good_EmptyData(t *testing.T) { + msgs := parseMessages(map[string]any{"data": []any{}}) + assert.Empty(t, msgs) +} + +func TestParseMessages_Good_NoDataKey(t *testing.T) { + msgs := parseMessages(map[string]any{"other": "value"}) + assert.Empty(t, msgs) +} + +func TestParseMessages_Good_NilResult(t *testing.T) { + assert.Empty(t, parseMessages(nil)) +} + +// --- toInt --- + +func TestToInt_Good_Float64(t *testing.T) { + assert.Equal(t, 42, toInt(float64(42))) +} + +func TestToInt_Good_Zero(t *testing.T) { + assert.Equal(t, 0, toInt(float64(0))) +} + +func TestToInt_Bad_String(t *testing.T) { + assert.Equal(t, 0, toInt("not a number")) +} + +func TestToInt_Bad_Nil(t *testing.T) { + assert.Equal(t, 0, toInt(nil)) +} + +func TestToInt_Bad_Int(t *testing.T) { + // Go JSON decode always uses float64, so int returns 0. + assert.Equal(t, 0, toInt(42)) +} + +// --- Messaging struct round-trips --- + +func TestSendInput_Good_RoundTrip(t *testing.T) { + in := SendInput{To: "charon", Content: "hello", Subject: "test"} + var out SendInput + roundTrip(t, in, &out) + assert.Equal(t, in, out) +} + +func TestSendOutput_Good_RoundTrip(t *testing.T) { + in := SendOutput{Success: true, ID: 42, To: "charon"} + var out SendOutput + roundTrip(t, in, &out) + assert.Equal(t, in, out) +} + +func TestInboxOutput_Good_RoundTrip(t *testing.T) { + in := InboxOutput{ + Success: true, + Messages: []MessageItem{ + {ID: 1, From: "a", To: "b", Content: "hi", Read: false, CreatedAt: "2026-03-10T12:00:00Z"}, + }, + } + var out InboxOutput + roundTrip(t, in, &out) + assert.Equal(t, in.Success, out.Success) + require.Len(t, out.Messages, 1) + assert.Equal(t, "a", out.Messages[0].From) +} + +func TestConversationOutput_Good_RoundTrip(t *testing.T) { + in := ConversationOutput{ + Success: true, + Messages: []MessageItem{ + {ID: 10, From: "x", To: "y", Content: "thread", Read: true, CreatedAt: "2026-03-10T14:00:00Z"}, + }, + } + var out ConversationOutput + roundTrip(t, in, &out) + assert.True(t, out.Success) + require.Len(t, out.Messages, 1) +} diff --git a/pkg/brain/provider_test.go b/pkg/brain/provider_test.go new file mode 100644 index 0000000..f2ed95d --- /dev/null +++ b/pkg/brain/provider_test.go @@ -0,0 +1,140 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package brain + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func init() { + gin.SetMode(gin.TestMode) +} + +// setupRouter creates a gin engine with provider routes registered. +func setupRouter(p *BrainProvider) *gin.Engine { + r := gin.New() + g := r.Group(p.BasePath()) + p.RegisterRoutes(g) + return r +} + +// providerRequest performs an HTTP request against the provider router and returns the recorder. +func providerRequest(t *testing.T, p *BrainProvider, method, path string, body []byte) *httptest.ResponseRecorder { + t.Helper() + r := setupRouter(p) + w := httptest.NewRecorder() + var req *http.Request + if body != nil { + req, _ = http.NewRequest(method, path, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + } else { + req, _ = http.NewRequest(method, path, nil) + } + r.ServeHTTP(w, req) + return w +} + +// --- Provider construction --- + +func TestNewProvider_Good(t *testing.T) { + p := NewProvider(nil, nil) + assert.NotNil(t, p) + assert.Nil(t, p.bridge) + assert.Nil(t, p.hub) +} + +func TestBrainProvider_Good_Name(t *testing.T) { + assert.Equal(t, "brain", NewProvider(nil, nil).Name()) +} + +func TestBrainProvider_Good_BasePath(t *testing.T) { + assert.Equal(t, "/api/brain", NewProvider(nil, nil).BasePath()) +} + +func TestBrainProvider_Good_Channels(t *testing.T) { + channels := NewProvider(nil, nil).Channels() + assert.Len(t, channels, 3) + assert.Contains(t, channels, "brain.remember.complete") + assert.Contains(t, channels, "brain.recall.complete") + assert.Contains(t, channels, "brain.forget.complete") +} + +func TestBrainProvider_Good_Element(t *testing.T) { + el := NewProvider(nil, nil).Element() + assert.Equal(t, "core-brain-panel", el.Tag) + assert.Equal(t, "/assets/brain-panel.js", el.Source) +} + +func TestBrainProvider_Good_Describe(t *testing.T) { + descs := NewProvider(nil, nil).Describe() + assert.Len(t, descs, 5) + + paths := make([]string, len(descs)) + for i, d := range descs { + paths[i] = d.Method + " " + d.Path + } + assert.Contains(t, paths, "POST /remember") + assert.Contains(t, paths, "POST /recall") + assert.Contains(t, paths, "POST /forget") + assert.Contains(t, paths, "GET /list") + assert.Contains(t, paths, "GET /status") +} + +// --- Handler: status --- + +func TestStatus_Good_NilBridge(t *testing.T) { + p := NewProvider(nil, nil) + w := providerRequest(t, p, "GET", "/api/brain/status", nil) + + assert.Equal(t, http.StatusOK, w.Code) + var resp map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + data, _ := resp["data"].(map[string]any) + assert.Equal(t, false, data["connected"]) +} + +// --- Nil bridge handlers return 503 --- + +func TestRememberHandler_Bad_NilBridge(t *testing.T) { + body, _ := json.Marshal(map[string]any{"content": "test memory", "type": "observation"}) + w := providerRequest(t, NewProvider(nil, nil), "POST", "/api/brain/remember", body) + assert.Equal(t, http.StatusServiceUnavailable, w.Code) +} + +func TestRememberHandler_Bad_NilBridgeInvalidBody(t *testing.T) { + // nil bridge returns 503 before JSON validation. + w := providerRequest(t, NewProvider(nil, nil), "POST", "/api/brain/remember", []byte("not json")) + assert.Equal(t, http.StatusServiceUnavailable, w.Code) +} + +func TestRecallHandler_Bad_NilBridge(t *testing.T) { + body, _ := json.Marshal(map[string]any{"query": "test"}) + w := providerRequest(t, NewProvider(nil, nil), "POST", "/api/brain/recall", body) + assert.Equal(t, http.StatusServiceUnavailable, w.Code) +} + +func TestForgetHandler_Bad_NilBridge(t *testing.T) { + body, _ := json.Marshal(map[string]any{"id": "mem-123"}) + w := providerRequest(t, NewProvider(nil, nil), "POST", "/api/brain/forget", body) + assert.Equal(t, http.StatusServiceUnavailable, w.Code) +} + +func TestListHandler_Bad_NilBridge(t *testing.T) { + w := providerRequest(t, NewProvider(nil, nil), "GET", "/api/brain/list", nil) + assert.Equal(t, http.StatusServiceUnavailable, w.Code) +} + +// --- emitEvent --- + +func TestEmitEvent_Good_NilHub(t *testing.T) { + p := NewProvider(nil, nil) + p.emitEvent("brain.test", map[string]any{"foo": "bar"}) +}