f99010fae1
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Windows) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Has been cancelled
182 lines
6.4 KiB
Go
182 lines
6.4 KiB
Go
package server_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"go.kenn.io/agentsview/internal/db"
|
|
"go.kenn.io/agentsview/internal/service"
|
|
)
|
|
|
|
// fakeHitsVectorSearcher returns canned semantic hits. ResolveMessageUnits
|
|
// resolves against the units implied by the hits, mirroring the db-layer
|
|
// fake, so hybrid requests do not error.
|
|
type fakeHitsVectorSearcher struct{ hits []db.VectorHit }
|
|
|
|
func (f fakeHitsVectorSearcher) SemanticSearch(
|
|
_ context.Context, _ string, _ int,
|
|
) ([]db.VectorHit, error) {
|
|
return f.hits, nil
|
|
}
|
|
|
|
func (f fakeHitsVectorSearcher) ResolveMessageUnits(
|
|
_ context.Context, refs []db.MessageRef,
|
|
) ([]db.UnitRef, error) {
|
|
return make([]db.UnitRef, len(refs)), nil
|
|
}
|
|
|
|
// TestSearchContentScopeInvalidValueRejected pins the enum gate on the
|
|
// scope query param: an out-of-enum value is rejected up front (Huma 422
|
|
// remapped to 400), matching how mode is validated.
|
|
func TestSearchContentScopeInvalidValueRejected(t *testing.T) {
|
|
te := setup(t)
|
|
te.db.SetVectorSearcher(fakeTransientVectorSearcher{})
|
|
|
|
w := te.wrappedRequest(http.MethodGet,
|
|
"/api/v1/search/content?pattern=fox&mode=semantic&scope=bogus",
|
|
withHeader("X-AgentsView-Search-Intent", "semantic"))
|
|
assertStatus(t, w, http.StatusBadRequest)
|
|
assert.Contains(t, w.Body.String(), "scope")
|
|
}
|
|
|
|
// TestSearchContentScopeRequiresSemanticOrHybridMode pins that scope is
|
|
// only meaningful for semantic/hybrid: setting it on any other mode is
|
|
// rejected rather than silently ignored.
|
|
func TestSearchContentScopeRequiresSemanticOrHybridMode(t *testing.T) {
|
|
te := setup(t)
|
|
|
|
for _, q := range []string{
|
|
"pattern=fox&scope=top",
|
|
"pattern=fox&mode=substring&scope=top",
|
|
"pattern=fox&mode=regex&scope=all",
|
|
"pattern=fox&mode=fts&scope=subordinate",
|
|
} {
|
|
w := te.get(t, "/api/v1/search/content?"+q)
|
|
assertStatus(t, w, http.StatusBadRequest)
|
|
assert.Contains(t, w.Body.String(), "semantic",
|
|
"error should point at the semantic/hybrid-only restriction")
|
|
}
|
|
}
|
|
|
|
// TestSearchContentScopeFiltersSemanticResults exercises the scope param
|
|
// end to end: scope=top drops the subordinate unit, scope=subordinate
|
|
// keeps only it, and the default returns both even though include_children
|
|
// is not set (precedence over the sidebar-child exclusion).
|
|
func TestSearchContentScopeFiltersSemanticResults(t *testing.T) {
|
|
te := setup(t)
|
|
te.seedSession(t, "top-sess", "proj", 2)
|
|
te.seedMessages(t, "top-sess", 1, func(_ int, m *db.Message) {
|
|
m.Content = "zebra at top level"
|
|
})
|
|
te.seedSession(t, "sub-sess", "proj", 2, func(s *db.Session) {
|
|
s.ParentSessionID = new("top-sess")
|
|
s.RelationshipType = "subagent"
|
|
})
|
|
te.seedMessages(t, "sub-sess", 1, func(_ int, m *db.Message) {
|
|
m.Content = "zebra inside the subagent"
|
|
})
|
|
te.db.SetVectorSearcher(fakeHitsVectorSearcher{hits: []db.VectorHit{
|
|
{SessionID: "sub-sess", Ordinal: 0, Subordinate: true, Score: 0.9,
|
|
Snippet: "zebra inside the subagent"},
|
|
{SessionID: "top-sess", Ordinal: 0, Score: 0.5,
|
|
Snippet: "zebra at top level"},
|
|
}})
|
|
|
|
search := func(t *testing.T, scope string) []string {
|
|
t.Helper()
|
|
path := "/api/v1/search/content?pattern=zebra&mode=semantic"
|
|
if scope != "" {
|
|
path += "&scope=" + scope
|
|
}
|
|
w := te.wrappedRequest(http.MethodGet, path,
|
|
withHeader("X-AgentsView-Search-Intent", "semantic"))
|
|
assertStatus(t, w, http.StatusOK)
|
|
res := decode[service.ContentSearchResult](t, w)
|
|
ids := make([]string, 0, len(res.Matches))
|
|
for _, m := range res.Matches {
|
|
ids = append(ids, m.SessionID)
|
|
}
|
|
return ids
|
|
}
|
|
|
|
def := search(t, "")
|
|
require.Contains(t, def, "sub-sess",
|
|
"default scope must return the subordinate unit despite include_children being unset")
|
|
assert.Contains(t, def, "top-sess")
|
|
|
|
assert.Equal(t, []string{"top-sess"}, search(t, "top"))
|
|
assert.Equal(t, []string{"sub-sess"}, search(t, "subordinate"))
|
|
assert.ElementsMatch(t, []string{"top-sess", "sub-sess"}, search(t, "all"))
|
|
}
|
|
|
|
// TestSearchContentSemanticResponseCarriesUnitRangeAndLineage pins the HTTP
|
|
// wire shape for run-grouped semantic hits: ordinal stays the anchor while
|
|
// ordinal_range, subordinate, and the lineage keys ride along. ordinal_range
|
|
// is always present, so the fixture's top-level single-message hit at
|
|
// ordinal 0 still serializes "ordinal_range":[0,0] even though its other
|
|
// zero-valued unit/lineage fields are omitted via omitempty.
|
|
func TestSearchContentSemanticResponseCarriesUnitRangeAndLineage(t *testing.T) {
|
|
te := setup(t)
|
|
te.seedSession(t, "top-sess", "proj", 2)
|
|
te.seedMessages(t, "top-sess", 1, func(_ int, m *db.Message) {
|
|
m.Content = "zebra at top level"
|
|
})
|
|
te.seedSession(t, "sub-sess", "proj", 3, func(s *db.Session) {
|
|
s.ParentSessionID = new("top-sess")
|
|
s.RelationshipType = "subagent"
|
|
})
|
|
te.seedMessages(t, "sub-sess", 3, func(i int, m *db.Message) {
|
|
if i > 0 {
|
|
m.Role = "assistant"
|
|
m.IsSidechain = true
|
|
m.Content = "zebra step inside the subagent"
|
|
}
|
|
})
|
|
te.db.SetVectorSearcher(fakeHitsVectorSearcher{hits: []db.VectorHit{
|
|
{SessionID: "sub-sess", Ordinal: 1, OrdinalStart: 1, OrdinalEnd: 2,
|
|
Subordinate: true, Score: 0.9, Snippet: "zebra step inside the subagent"},
|
|
{SessionID: "top-sess", Ordinal: 0, Score: 0.5,
|
|
Snippet: "zebra at top level"},
|
|
}})
|
|
|
|
w := te.wrappedRequest(http.MethodGet,
|
|
"/api/v1/search/content?pattern=zebra&mode=semantic",
|
|
withHeader("X-AgentsView-Search-Intent", "semantic"))
|
|
assertStatus(t, w, http.StatusOK)
|
|
|
|
var res struct {
|
|
Matches []map[string]any `json:"matches"`
|
|
}
|
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &res))
|
|
require.Len(t, res.Matches, 2)
|
|
byID := map[string]map[string]any{}
|
|
for _, m := range res.Matches {
|
|
byID[m["session_id"].(string)] = m
|
|
}
|
|
|
|
sub, ok := byID["sub-sess"]
|
|
require.True(t, ok, "subordinate run hit present")
|
|
assert.EqualValues(t, 1, sub["ordinal"], "ordinal stays the anchor")
|
|
assert.Equal(t, []any{float64(1), float64(2)}, sub["ordinal_range"])
|
|
assert.Equal(t, true, sub["subordinate"])
|
|
assert.Equal(t, "subagent", sub["relationship"])
|
|
assert.Equal(t, "top-sess", sub["parent_session_id"])
|
|
assert.Equal(t, true, sub["is_sidechain"])
|
|
|
|
top, ok := byID["top-sess"]
|
|
require.True(t, ok, "top-level hit present")
|
|
assert.Equal(t, []any{float64(0), float64(0)}, top["ordinal_range"],
|
|
"ordinal_range is always present, even for a zero-valued single-message hit")
|
|
for _, key := range []string{
|
|
"subordinate", "relationship", "parent_session_id", "is_sidechain",
|
|
} {
|
|
assert.NotContains(t, top, key,
|
|
"zero-valued lineage keys must be omitted from the wire")
|
|
}
|
|
}
|