Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:30:36 +08:00

1247 lines
44 KiB
Go

package mcp
import (
"context"
"encoding/json"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.kenn.io/agentsview/internal/db"
"go.kenn.io/agentsview/internal/dbtest"
"go.kenn.io/agentsview/internal/service"
)
// fixedNow is the deterministic clock used in tests so the 10-minute
// self-reference exclusion window is reproducible.
var fixedNow = time.Date(2024, 6, 15, 12, 0, 0, 0, time.UTC)
func newTestToolset(t *testing.T) (*toolset, *db.DB) {
t.Helper()
d := dbtest.OpenTestDB(t)
return &toolset{
svc: service.NewDirectBackend(d, nil),
now: func() time.Time { return fixedNow },
}, d
}
func seedFTSSession(t *testing.T, d *db.DB, id, project, content, endedAt string) {
t.Helper()
dbtest.SeedSession(t, d, id, project, func(s *db.Session) {
s.MessageCount = 2
s.UserMessageCount = 1
ended := endedAt
s.EndedAt = &ended
})
require.NoError(t, d.InsertMessages([]db.Message{
dbtest.UserMsg(id, 0, content),
}))
}
func TestSearchSessions_ReturnsHitsWithOrdinal(t *testing.T) {
ts, d := newTestToolset(t)
if !d.HasFTS() {
t.Skip("FTS not available")
}
seedFTSSession(t, d, "s1", "proj-a",
"the quick brown fox", "2024-06-15T10:00:00Z")
seedFTSSession(t, d, "s2", "proj-b",
"lazy dogs", "2024-06-15T10:00:00Z")
_, out, err := ts.searchSessions(context.Background(), nil, searchSessionsIn{
Query: "fox",
})
require.NoError(t, err)
require.Len(t, out.Results, 1)
assert.Equal(t, "s1", out.Results[0].SessionID)
assert.Equal(t, "proj-a", out.Results[0].Project)
assert.Zero(t, out.ExcludedActive)
}
func TestSearchSessions_ExcludesRecentlyActive(t *testing.T) {
ts, d := newTestToolset(t)
if !d.HasFTS() {
t.Skip("FTS not available")
}
// s1 ended an hour before fixedNow -> kept; s2 ended 5 min before -> excluded.
seedFTSSession(t, d, "s1", "proj", "shared term alpha", "2024-06-15T11:00:00Z")
seedFTSSession(t, d, "s2", "proj", "shared term alpha", "2024-06-15T11:55:00Z")
out := mustSearch(t, ts, searchSessionsIn{Query: "alpha"})
require.Len(t, out.Results, 1)
assert.Equal(t, "s1", out.Results[0].SessionID)
assert.Equal(t, 1, out.ExcludedActive)
// With include_active, the recent one is returned too.
withActive := mustSearch(t, ts, searchSessionsIn{Query: "alpha", IncludeActive: true})
assert.Len(t, withActive.Results, 2)
assert.Zero(t, withActive.ExcludedActive)
}
func mustSearch(t *testing.T, ts *toolset, in searchSessionsIn) searchSessionsOut {
t.Helper()
_, out, err := ts.searchSessions(context.Background(), nil, in)
require.NoError(t, err)
return out
}
// TestSearchSessions_Pagination walks the int cursor end to end: a first
// page sets next_cursor, and feeding it back returns the following,
// non-overlapping page.
func TestSearchSessions_Pagination(t *testing.T) {
ts, d := newTestToolset(t)
if !d.HasFTS() {
t.Skip("FTS not available")
}
// Six sessions all matching "pageterm", with increasing ended_at so
// recency ordering is deterministic. All old enough to not be excluded.
for i := range 6 {
id := "p" + string(rune('0'+i))
ended := "2024-06-1" + string(rune('0'+i)) + "T10:00:00Z"
seedFTSSession(t, d, id, "proj", "pageterm body", ended)
}
page1 := mustSearch(t, ts, searchSessionsIn{Query: "pageterm", Sort: "recency", Limit: 2})
require.Len(t, page1.Results, 2)
require.NotNil(t, page1.NextCursor, "first page should set next_cursor")
page2 := mustSearch(t, ts, searchSessionsIn{
Query: "pageterm", Sort: "recency", Limit: 2, Cursor: *page1.NextCursor,
})
require.Len(t, page2.Results, 2)
seen := map[string]bool{}
for _, r := range page1.Results {
seen[r.SessionID] = true
}
for _, r := range page2.Results {
assert.False(t, seen[r.SessionID],
"page 2 must not repeat page 1 (overlap on %s)", r.SessionID)
}
}
func TestListSessions_ReturnsRows(t *testing.T) {
ts, d := newTestToolset(t)
dbtest.SeedSession(t, d, "a-1", "proj-a", func(s *db.Session) {
s.MessageCount = 4
s.UserMessageCount = 2
})
dbtest.SeedSession(t, d, "b-1", "proj-b", func(s *db.Session) {
s.MessageCount = 4
s.UserMessageCount = 2
})
_, out, err := ts.listSessions(context.Background(), nil, listSessionsIn{
Project: "proj-a",
})
require.NoError(t, err)
require.Len(t, out.Sessions, 1)
assert.Equal(t, "a-1", out.Sessions[0].SessionID)
assert.Equal(t, "proj-a", out.Sessions[0].Project)
}
func TestGetSessionOverview_ChronologicalTail(t *testing.T) {
ts, d := newTestToolset(t)
cwd := "/home/u/proj"
first := "open the door"
dbtest.SeedSession(t, d, "s1", "proj", func(s *db.Session) {
s.MessageCount = 4
s.UserMessageCount = 2
s.Cwd = cwd
s.FirstMessage = &first
})
require.NoError(t, d.InsertMessages([]db.Message{
dbtest.UserMsg("s1", 0, "open the door"),
dbtest.AsstMsg("s1", 1, "opening"),
dbtest.UserMsg("s1", 2, "now close it"),
dbtest.AsstMsg("s1", 3, "closed"),
}))
_, out, err := ts.sessionOverview(context.Background(), nil, sessionOverviewIn{
SessionID: "s1",
})
require.NoError(t, err)
assert.Equal(t, "s1", out.Session.SessionID)
assert.Equal(t, cwd, out.CWD)
assert.Equal(t, "open the door", out.FirstMessage)
require.NotEmpty(t, out.LastMessages)
// Tail is restored to chronological (ascending) ordinal order.
for i := 1; i < len(out.LastMessages); i++ {
assert.Less(t, out.LastMessages[i-1].Ordinal, out.LastMessages[i].Ordinal)
}
// The very last message is surfaced.
assert.Equal(t, "closed", out.LastMessages[len(out.LastMessages)-1].Content)
}
func TestGetSessionOverview_NotFound(t *testing.T) {
ts, _ := newTestToolset(t)
_, _, err := ts.sessionOverview(context.Background(), nil, sessionOverviewIn{
SessionID: "nope",
})
require.Error(t, err)
assert.Contains(t, err.Error(), "not found")
}
func TestGetMessages_RoleFilterAndTruncation(t *testing.T) {
ts, d := newTestToolset(t)
dbtest.SeedSession(t, d, "s1", "proj", func(s *db.Session) {
s.MessageCount = 3
s.UserMessageCount = 1
})
long := make([]byte, 50)
for i := range long {
long[i] = 'x'
}
sysMsg := db.Message{
SessionID: "s1", Ordinal: 1, Role: "system",
Content: "system noise", IsSystem: true, ContentLength: len("system noise"),
}
require.NoError(t, d.InsertMessages([]db.Message{
dbtest.UserMsg("s1", 0, string(long)),
sysMsg,
dbtest.AsstMsg("s1", 2, "short reply"),
}))
_, out, err := ts.getMessages(context.Background(), nil, getMessagesIn{
SessionID: "s1",
MaxCharsPerMessage: 10,
})
require.NoError(t, err)
// system message filtered out; user+assistant kept.
require.Len(t, out.Messages, 2)
assert.Equal(t, 1, out.Filtered, "the system message is filtered")
// The 50-char user message is truncated to 10 with FullLength set.
first := out.Messages[0]
assert.True(t, first.Truncated)
assert.Equal(t, 50, first.FullLength)
assert.Len(t, first.Content, 10)
}
// Even when a caller explicitly allow-lists the "system" role, get_messages
// must still drop IsSystem-flagged messages: the schema promises system
// messages are always excluded. The IsSystem gate short-circuits ahead of the
// role allowlist, so an allow-listed assistant message comes through while the
// equally allow-listed system message stays filtered. This pins the
// security-relevant ordering against a future change that let an explicit
// roles request surface system content.
func TestGetMessages_ExplicitSystemRoleStillFiltered(t *testing.T) {
ts, d := newTestToolset(t)
dbtest.SeedSession(t, d, "s1", "proj", func(s *db.Session) {
s.MessageCount = 3
s.UserMessageCount = 1
})
require.NoError(t, d.InsertMessages([]db.Message{
dbtest.UserMsg("s1", 0, "hello"),
{
SessionID: "s1", Ordinal: 1, Role: "system",
Content: "system noise", IsSystem: true, ContentLength: len("system noise"),
},
dbtest.AsstMsg("s1", 2, "short reply"),
}))
_, out, err := ts.getMessages(context.Background(), nil, getMessagesIn{
SessionID: "s1",
Roles: []string{"system", "assistant"},
})
require.NoError(t, err)
// The allow-listed assistant message is returned; the system message is
// not, even though "system" was also allow-listed. The user message is
// filtered because its role is not in the allowlist.
require.Len(t, out.Messages, 1)
assert.Equal(t, "assistant", out.Messages[0].Role)
for _, m := range out.Messages {
assert.NotEqual(t, "system", m.Role, "system messages must never be returned")
}
assert.Equal(t, 2, out.Filtered)
}
func TestSearchContent_SubstringMatch(t *testing.T) {
ts, d := newTestToolset(t)
// Not a one-shot: content search excludes one-shot sessions by default.
// An explicit old ended_at keeps it out of the active-session guard.
dbtest.SeedSession(t, d, "s1", "proj", func(s *db.Session) {
s.MessageCount = 3
s.UserMessageCount = 2
ended := "2024-06-15T10:00:00Z"
s.EndedAt = &ended
})
require.NoError(t, d.InsertMessages([]db.Message{
dbtest.UserMsg("s1", 0, "error code DEADBEEF here"),
dbtest.AsstMsg("s1", 1, "looking into it"),
}))
_, out, err := ts.searchContent(context.Background(), nil, searchContentIn{
Pattern: "DEADBEEF",
Mode: "substring",
})
require.NoError(t, err)
require.Len(t, out.Matches, 1)
assert.Equal(t, "s1", out.Matches[0].SessionID)
}
// TestSearchContent_ContextRedactsSecretByDefault verifies that a secret in
// a message adjacent to a search match comes back redacted in
// context_before through the real service (not a fake), proving the MCP
// transport inherits directBackend's context redaction: MCP has no reveal
// opt-in, so this path must always come out redacted.
func TestSearchContent_ContextRedactsSecretByDefault(t *testing.T) {
ts, d := newTestToolset(t)
dbtest.SeedSession(t, d, "s1", "proj", func(s *db.Session) {
s.MessageCount = 3
s.UserMessageCount = 2
ended := "2024-06-15T10:00:00Z"
s.EndedAt = &ended
})
require.NoError(t, d.InsertMessages([]db.Message{
dbtest.UserMsg("s1", 0, "my key is AKIA7QHWN2DKR4FYPLJM ok"),
dbtest.AsstMsg("s1", 1, "noted"),
dbtest.UserMsg("s1", 2, "DEADBEEF marks the match"),
}))
_, out, err := ts.searchContent(context.Background(), nil, searchContentIn{
Pattern: "DEADBEEF", Mode: "substring", Context: 2,
})
require.NoError(t, err)
require.Len(t, out.Matches, 1)
require.Len(t, out.Matches[0].ContextBefore, 2)
for _, cm := range out.Matches[0].ContextBefore {
assert.NotContains(t, cm.Content, "AKIA7QHWN2DKR4FYPLJM",
"MCP has no reveal opt-in, so context must always come back redacted: %q", cm.Content)
}
}
// search_content's self-reference guard must exclude matches from sessions
// that are active now, even when the matching message itself is old. A
// long-running current session can match on a stale line; excluding by the
// match timestamp alone would leak it. Exclusion is by session activity,
// like search_sessions.
func TestSearchContent_ExcludesActiveSessionWithOldMatch(t *testing.T) {
ts, d := newTestToolset(t)
// Active session: ended one minute before now, but its matching message
// is two hours old.
dbtest.SeedSession(t, d, "active", "proj", func(s *db.Session) {
s.MessageCount = 3
s.UserMessageCount = 2
ended := "2024-06-15T11:59:00Z"
s.EndedAt = &ended
})
require.NoError(t, d.InsertMessages([]db.Message{{
SessionID: "active", Ordinal: 0, Role: "user",
Content: "old needle here", ContentLength: len("old needle here"),
Timestamp: "2024-06-15T10:00:00Z",
}}))
// Idle session: ended two hours before now.
dbtest.SeedSession(t, d, "idle", "proj", func(s *db.Session) {
s.MessageCount = 3
s.UserMessageCount = 2
ended := "2024-06-15T10:00:00Z"
s.EndedAt = &ended
})
require.NoError(t, d.InsertMessages([]db.Message{{
SessionID: "idle", Ordinal: 0, Role: "user",
Content: "idle needle here", ContentLength: len("idle needle here"),
Timestamp: "2024-06-15T10:00:00Z",
}}))
// Default (include_active=false): the active session is excluded despite
// its old match; only the idle session is returned.
_, out, err := ts.searchContent(context.Background(), nil, searchContentIn{
Pattern: "needle", Mode: "substring",
})
require.NoError(t, err)
require.Len(t, out.Matches, 1)
assert.Equal(t, "idle", out.Matches[0].SessionID)
assert.Equal(t, 1, out.ExcludedActive)
// include_active=true returns both, excluding nothing.
_, all, err := ts.searchContent(context.Background(), nil, searchContentIn{
Pattern: "needle", Mode: "substring", IncludeActive: true,
})
require.NoError(t, err)
assert.Len(t, all.Matches, 2)
assert.Equal(t, 0, all.ExcludedActive)
}
// A freshly created/synced session can have no parsed ended_at or started_at
// yet, only created_at. Its activity must fall back to created_at so a
// current timestampless session is still excluded by the default guard,
// rather than resolving to an empty timestamp and leaking through. Uses a
// real clock because created_at is set to now by the DB at insert.
func TestSearchContent_TimestamplessSessionExcludedByCreatedAt(t *testing.T) {
d := dbtest.OpenTestDB(t)
ts := &toolset{svc: service.NewDirectBackend(d, nil), now: time.Now}
// StartedAt and EndedAt are left nil on purpose; created_at defaults to
// now in the schema, so the session is active.
dbtest.SeedSession(t, d, "fresh", "proj", func(s *db.Session) {
s.MessageCount = 3
s.UserMessageCount = 2
})
require.NoError(t, d.InsertMessages([]db.Message{
dbtest.UserMsg("fresh", 0, "needle in a fresh session"),
}))
// Default guard excludes the still-active session despite no start/end.
_, out, err := ts.searchContent(context.Background(), nil, searchContentIn{
Pattern: "needle", Mode: "substring",
})
require.NoError(t, err)
assert.Empty(t, out.Matches)
assert.Equal(t, 1, out.ExcludedActive)
// include_active=true surfaces it.
_, all, err := ts.searchContent(context.Background(), nil, searchContentIn{
Pattern: "needle", Mode: "substring", IncludeActive: true,
})
require.NoError(t, err)
assert.Len(t, all.Matches, 1)
}
func TestUsageSummary_EmptyRange(t *testing.T) {
ts, _ := newTestToolset(t)
_, out, err := ts.usageSummary(context.Background(), nil, usageSummaryIn{
From: "2024-06-01", To: "2024-06-03",
})
require.NoError(t, err)
require.NotNil(t, out)
assert.Equal(t, "2024-06-01", out.From)
assert.Equal(t, "2024-06-03", out.To)
}
// recordingService captures the request a tool builds, so MCP-layer
// request mapping can be asserted without a full backend. Unused methods
// fall through to the embedded nil interface (never called by the tools
// under test).
type recordingService struct {
service.SessionService
lastUsage service.UsageRequest
}
func (r *recordingService) UsageSummary(
_ context.Context, req service.UsageRequest,
) (*service.UsageSummaryResult, error) {
r.lastUsage = req
return &service.UsageSummaryResult{From: req.From, To: req.To}, nil
}
// get_usage_summary must request one-shot sessions (matching the REST
// /usage/summary default), since cost analysis wants every session.
func TestUsageSummary_RequestsOneShotSessions(t *testing.T) {
t.Parallel()
rec := &recordingService{}
ts := &toolset{svc: rec, now: func() time.Time { return fixedNow }}
_, _, err := ts.usageSummary(context.Background(), nil, usageSummaryIn{
From: "2024-06-01", To: "2024-06-02", Project: "p", Agent: "claude",
})
require.NoError(t, err)
assert.True(t, rec.lastUsage.IncludeOneShot,
"usage summary should include one-shot sessions")
assert.Equal(t, "p", rec.lastUsage.Project)
assert.Equal(t, "claude", rec.lastUsage.Agent)
}
// search_content excludes one-shot sessions by default, matching the
// standalone/REST behavior. (Tracked as a possible follow-up: expose an
// include_one_shot opt-in so single-exchange sessions can be searched.)
func TestSearchContent_ExcludesOneShotByDefault(t *testing.T) {
ts, d := newTestToolset(t)
// One-shot (UserMessageCount=1) with the marker.
dbtest.SeedSession(t, d, "one", "proj", func(s *db.Session) {
s.MessageCount = 1
s.UserMessageCount = 1
})
require.NoError(t, d.InsertMessages([]db.Message{
dbtest.UserMsg("one", 0, "marker ZEBRA42"),
}))
// Multi-turn with the same marker; old ended_at keeps it inactive.
dbtest.SeedSession(t, d, "multi", "proj", func(s *db.Session) {
s.MessageCount = 3
s.UserMessageCount = 2
ended := "2024-06-15T10:00:00Z"
s.EndedAt = &ended
})
require.NoError(t, d.InsertMessages([]db.Message{
dbtest.UserMsg("multi", 0, "marker ZEBRA42"),
dbtest.AsstMsg("multi", 1, "ok"),
}))
_, out, err := ts.searchContent(context.Background(), nil, searchContentIn{
Pattern: "ZEBRA42", Mode: "substring",
})
require.NoError(t, err)
require.Len(t, out.Matches, 1, "one-shot session should be excluded")
assert.Equal(t, "multi", out.Matches[0].SessionID)
}
// search_content must surface the conversation-unit citation fields
// (ordinal_range plus lineage) copied verbatim from db.ContentMatch: every
// match in a top-level assistant run carries the run's full ordinal_range
// and none of the subordinate/lineage fields set, matching the plumbing
// pinned at the db layer in TestSearchContentSubstringDerivedRunRange.
func TestSearchContent_OrdinalRangeSpansRun(t *testing.T) {
ts, d := newTestToolset(t)
dbtest.SeedSession(t, d, "run1", "proj", func(s *db.Session) {
s.MessageCount = 4
s.UserMessageCount = 2
ended := "2024-06-15T10:00:00Z"
s.EndedAt = &ended
})
require.NoError(t, d.InsertMessages([]db.Message{
dbtest.UserMsg("run1", 0, "the question"),
dbtest.AsstMsg("run1", 1, "RUNHIT step one"),
dbtest.AsstMsg("run1", 2, "RUNHIT step two"),
dbtest.UserMsg("run1", 3, "next question"),
}))
_, out, err := ts.searchContent(context.Background(), nil, searchContentIn{
Pattern: "RUNHIT", Mode: "substring",
})
require.NoError(t, err)
require.Len(t, out.Matches, 2)
for _, m := range out.Matches {
assert.Equal(t, [2]int{1, 2}, m.OrdinalRange,
"match at ordinal %d should carry the run's full range", m.Ordinal)
assert.False(t, m.Subordinate, "top-level run member")
assert.False(t, m.Sidechain, "non-sidechain run member")
assert.Empty(t, m.Relationship, "top-level relationship")
assert.Empty(t, m.ParentSessionID, "top-level parent")
}
}
// A sidechain run's matches must round-trip Subordinate and Sidechain as
// true, with ordinal_range spanning the sidechain run rather than the
// individual anchor ordinal.
func TestSearchContent_SidechainSubordinateRoundTrip(t *testing.T) {
ts, d := newTestToolset(t)
dbtest.SeedSession(t, d, "side1", "proj", func(s *db.Session) {
s.MessageCount = 3
s.UserMessageCount = 2
ended := "2024-06-15T10:00:00Z"
s.EndedAt = &ended
})
require.NoError(t, d.InsertMessages([]db.Message{
dbtest.UserMsg("side1", 0, "the question"),
{
SessionID: "side1", Ordinal: 1, Role: "assistant",
Content: "SIDEHIT step a", ContentLength: len("SIDEHIT step a"),
IsSidechain: true,
},
{
SessionID: "side1", Ordinal: 2, Role: "assistant",
Content: "SIDEHIT step b", ContentLength: len("SIDEHIT step b"),
IsSidechain: true,
},
}))
_, out, err := ts.searchContent(context.Background(), nil, searchContentIn{
Pattern: "SIDEHIT", Mode: "substring",
})
require.NoError(t, err)
require.Len(t, out.Matches, 2)
for _, m := range out.Matches {
assert.Equal(t, [2]int{1, 2}, m.OrdinalRange, "sidechain run range")
assert.True(t, m.Subordinate, "sidechain run is subordinate")
assert.True(t, m.Sidechain, "anchor sidechain flag")
assert.Empty(t, m.Relationship, "no session lineage on a same-session sidechain")
}
}
// A single top-level message match (its own conversation unit) must report
// ordinal_range == [o, o], and the omitempty subordinate/lineage fields must
// be entirely absent from the marshaled JSON rather than present as false/"".
func TestSearchContent_SingleMessageOrdinalRangeAndOmittedFields(t *testing.T) {
ts, d := newTestToolset(t)
dbtest.SeedSession(t, d, "solo1", "proj", func(s *db.Session) {
s.MessageCount = 2
s.UserMessageCount = 2
ended := "2024-06-15T10:00:00Z"
s.EndedAt = &ended
})
require.NoError(t, d.InsertMessages([]db.Message{
dbtest.UserMsg("solo1", 0, "SOLOHIT alone"),
dbtest.UserMsg("solo1", 1, "an unrelated follow-up"),
}))
_, out, err := ts.searchContent(context.Background(), nil, searchContentIn{
Pattern: "SOLOHIT", Mode: "substring",
})
require.NoError(t, err)
require.Len(t, out.Matches, 1)
m := out.Matches[0]
assert.Equal(t, 0, m.Ordinal)
assert.Equal(t, [2]int{0, 0}, m.OrdinalRange, "single-message unit is its own range")
assert.False(t, m.Subordinate)
assert.False(t, m.Sidechain)
assert.Empty(t, m.Relationship)
assert.Empty(t, m.ParentSessionID)
data, err := json.Marshal(m)
require.NoError(t, err)
var raw map[string]any
require.NoError(t, json.Unmarshal(data, &raw))
assert.Contains(t, raw, "ordinal_range", "ordinal_range is always present")
for _, key := range []string{
"subordinate", "relationship", "parent_session_id", "is_sidechain",
} {
assert.NotContains(t, raw, key,
"zero-valued omitempty field %q must be absent from the wire shape", key)
}
}
func TestGetMessages_DescAndFromAnchor(t *testing.T) {
ts, d := newTestToolset(t)
dbtest.SeedSession(t, d, "s1", "proj", func(s *db.Session) {
s.MessageCount = 5
s.UserMessageCount = 3
})
require.NoError(t, d.InsertMessages([]db.Message{
dbtest.UserMsg("s1", 0, "m0"),
dbtest.AsstMsg("s1", 1, "m1"),
dbtest.UserMsg("s1", 2, "m2"),
dbtest.AsstMsg("s1", 3, "m3"),
dbtest.UserMsg("s1", 4, "m4"),
}))
// desc with no anchor -> newest first.
_, desc, err := ts.getMessages(context.Background(), nil, getMessagesIn{
SessionID: "s1", Direction: "desc",
})
require.NoError(t, err)
require.NotEmpty(t, desc.Messages)
assert.Equal(t, 4, desc.Messages[0].Ordinal, "desc returns newest first")
// asc anchored at ordinal 2 -> starts at 2, ascending.
from := 2
_, asc, err := ts.getMessages(context.Background(), nil, getMessagesIn{
SessionID: "s1", Direction: "asc", From: &from,
})
require.NoError(t, err)
require.NotEmpty(t, asc.Messages)
assert.Equal(t, 2, asc.Messages[0].Ordinal, "asc honors the from anchor")
for i := 1; i < len(asc.Messages); i++ {
assert.Less(t, asc.Messages[i-1].Ordinal, asc.Messages[i].Ordinal)
}
}
// Ordinal 0 is a valid anchor (search_sessions can return match_ordinal 0),
// so from:0 must be honored, not treated as "omitted". With desc it anchors
// at ordinal 0 -- returning only that message -- rather than falling back to
// newest-first.
func TestGetMessages_FromZeroAnchors(t *testing.T) {
ts, d := newTestToolset(t)
dbtest.SeedSession(t, d, "s1", "proj", func(s *db.Session) {
s.MessageCount = 3
s.UserMessageCount = 2
})
require.NoError(t, d.InsertMessages([]db.Message{
dbtest.UserMsg("s1", 0, "m0"),
dbtest.AsstMsg("s1", 1, "m1"),
dbtest.UserMsg("s1", 2, "m2"),
}))
zero := 0
_, out, err := ts.getMessages(context.Background(), nil, getMessagesIn{
SessionID: "s1", Direction: "desc", From: &zero,
})
require.NoError(t, err)
require.Len(t, out.Messages, 1)
assert.Equal(t, 0, out.Messages[0].Ordinal,
"from:0 anchors at ordinal 0, not newest-first")
}
// get_messages promises system messages are always excluded. Legacy
// sessions store system-injected messages as user-role rows without the
// is_system flag, identified only by a content prefix; those must be
// excluded too, not just is_system rows.
func TestGetMessages_ExcludesSystemPrefixedUserMessage(t *testing.T) {
ts, d := newTestToolset(t)
dbtest.SeedSession(t, d, "s1", "proj", func(s *db.Session) {
s.MessageCount = 2
s.UserMessageCount = 1
})
require.NoError(t, d.InsertMessages([]db.Message{
dbtest.UserMsg("s1", 0, "real question"),
// User role, is_system not set, but a system content prefix.
{
SessionID: "s1", Ordinal: 1, Role: "user",
Content: "<task-notification>done</task-notification>",
ContentLength: 44,
},
}))
_, out, err := ts.getMessages(context.Background(), nil, getMessagesIn{
SessionID: "s1",
})
require.NoError(t, err)
require.Len(t, out.Messages, 1)
assert.Equal(t, "real question", out.Messages[0].Content)
assert.Equal(t, 1, out.Filtered, "the system-prefixed user message is filtered")
}
// get_messages returns next_from when a full page may have more rows, so a
// client can page reliably even when filtering shortens a page. next_from
// is anchored on the last scanned ordinal, and the final partial page omits
// it.
func TestGetMessages_NextFromCursor(t *testing.T) {
ts, d := newTestToolset(t)
dbtest.SeedSession(t, d, "s1", "proj", func(s *db.Session) {
s.MessageCount = 5
s.UserMessageCount = 3
ended := "2024-06-15T10:00:00Z"
s.EndedAt = &ended
})
require.NoError(t, d.InsertMessages([]db.Message{
dbtest.UserMsg("s1", 0, "m0"),
dbtest.AsstMsg("s1", 1, "m1"),
dbtest.UserMsg("s1", 2, "m2"),
dbtest.AsstMsg("s1", 3, "m3"),
dbtest.UserMsg("s1", 4, "m4"),
}))
// asc page 1: ordinals 0,1 -> next_from 2.
_, p1, err := ts.getMessages(context.Background(), nil, getMessagesIn{
SessionID: "s1", Direction: "asc", Limit: 2,
})
require.NoError(t, err)
require.Len(t, p1.Messages, 2)
assert.Equal(t, 0, p1.Messages[0].Ordinal)
require.NotNil(t, p1.NextFrom)
assert.Equal(t, 2, *p1.NextFrom)
// asc page 2 from the cursor: ordinals 2,3 -> next_from 4.
_, p2, err := ts.getMessages(context.Background(), nil, getMessagesIn{
SessionID: "s1", Direction: "asc", Limit: 2, From: p1.NextFrom,
})
require.NoError(t, err)
require.Len(t, p2.Messages, 2)
assert.Equal(t, 2, p2.Messages[0].Ordinal)
require.NotNil(t, p2.NextFrom)
assert.Equal(t, 4, *p2.NextFrom)
// asc final page: ordinal 4 only; partial page has no next cursor.
_, p3, err := ts.getMessages(context.Background(), nil, getMessagesIn{
SessionID: "s1", Direction: "asc", Limit: 2, From: p2.NextFrom,
})
require.NoError(t, err)
require.Len(t, p3.Messages, 1)
assert.Equal(t, 4, p3.Messages[0].Ordinal)
assert.Nil(t, p3.NextFrom, "final partial page omits next_from")
// desc page 1 from newest: ordinals 4,3 -> next_from 2 (anchor moves down).
_, dpage, err := ts.getMessages(context.Background(), nil, getMessagesIn{
SessionID: "s1", Direction: "desc", Limit: 2,
})
require.NoError(t, err)
require.Len(t, dpage.Messages, 2)
assert.Equal(t, 4, dpage.Messages[0].Ordinal)
require.NotNil(t, dpage.NextFrom)
assert.Equal(t, 2, *dpage.NextFrom)
}
// next_from must advance past the last SCANNED ordinal, not the last visible
// one, so a filtered message at the page boundary is not re-scanned on the
// next page. The raw page fills the limit but a system message is filtered,
// making the visible page shorter than the limit.
func TestGetMessages_NextFromUsesScannedNotVisible(t *testing.T) {
ts, d := newTestToolset(t)
dbtest.SeedSession(t, d, "s1", "proj", func(s *db.Session) {
s.MessageCount = 3
s.UserMessageCount = 2
ended := "2024-06-15T10:00:00Z"
s.EndedAt = &ended
})
require.NoError(t, d.InsertMessages([]db.Message{
dbtest.UserMsg("s1", 0, "v0"),
{
SessionID: "s1", Ordinal: 1, Role: "system",
Content: "sys", IsSystem: true, ContentLength: 3,
},
dbtest.UserMsg("s1", 2, "v2"),
}))
// asc, limit 2: raw page = ordinals 0,1; ordinal 1 (system) is filtered.
_, out, err := ts.getMessages(context.Background(), nil, getMessagesIn{
SessionID: "s1", Direction: "asc", Limit: 2,
})
require.NoError(t, err)
require.Len(t, out.Messages, 1, "filtered system message shortens the page")
assert.Equal(t, 0, out.Messages[0].Ordinal)
assert.Equal(t, 1, out.Filtered)
require.NotNil(t, out.NextFrom)
assert.Equal(t, 2, *out.NextFrom,
"next_from advances past scanned ordinal 1, not visible ordinal 0")
}
// The schemas promise that message_count counts every stored message across
// all roles (system included), and that a full get_messages pagination sweep
// reconciles against it: returned messages plus filtered add up to
// message_count for any roles filter. This pins that contract so a refactor
// of either code path cannot silently break it (issue #944).
func TestGetMessages_FilteredReconcilesWithMessageCount(t *testing.T) {
ts, d := newTestToolset(t)
// A realistic mix: user/assistant turns, an is_system-flagged row, a
// tool dump, and a legacy system-prefixed user row (parsed before
// is_system was backfilled, so the flag is unset). message_count is
// the row total, mirroring the sync engine's write-time derivation.
msgs := []db.Message{
dbtest.UserMsg("s1", 0, "hello"),
dbtest.AsstMsg("s1", 1, "hi"),
{
SessionID: "s1", Ordinal: 2, Role: "system",
Content: "sys", IsSystem: true, ContentLength: 3,
},
{
SessionID: "s1", Ordinal: 3, Role: "tool",
Content: "tool output", ContentLength: 11,
},
dbtest.UserMsg("s1", 4,
"This session is being continued from a previous conversation"),
dbtest.AsstMsg("s1", 5, "more"),
{
SessionID: "s1", Ordinal: 6, Role: "tool",
Content: "tool output 2", ContentLength: 13,
},
dbtest.UserMsg("s1", 7, "bye"),
}
dbtest.SeedSessionWithMessages(t, d, "s1", "proj", msgs,
dbtest.WithMessageCounts(len(msgs), 3))
_, ov, err := ts.sessionOverview(context.Background(), nil,
sessionOverviewIn{SessionID: "s1"})
require.NoError(t, err)
require.Equal(t, len(msgs), ov.Session.MessageCount)
tests := []struct {
name string
roles []string
}{
{"default roles", nil},
{"user assistant tool", []string{"user", "assistant", "tool"}},
{"tool only", []string{"tool"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
returned, filtered := 0, 0
var from *int
for range len(msgs) + 1 { // bounded: a sweep never needs more pages
_, out, err := ts.getMessages(context.Background(), nil,
getMessagesIn{
SessionID: "s1", Direction: "asc",
Limit: 3, From: from, Roles: tt.roles,
})
require.NoError(t, err)
returned += len(out.Messages)
filtered += out.Filtered
if out.NextFrom == nil {
break
}
from = out.NextFrom
}
assert.Equal(t, ov.Session.MessageCount, returned+filtered,
"returned + filtered must reconcile to message_count")
})
}
}
// search_sessions must exclude a session active now even when its search
// result carries no ended_at/started_at (empty SessionEndedAt), by falling
// back to created_at like search_content -- mirroring the canonical
// activity expression.
func TestSearchSessions_TimestamplessExcludedByCreatedAt(t *testing.T) {
d := dbtest.OpenTestDB(t)
if !d.HasFTS() {
t.Skip("FTS not available")
}
ts := &toolset{svc: service.NewDirectBackend(d, nil), now: time.Now}
// No ended_at/started_at; created_at defaults to now, so it is active.
dbtest.SeedSession(t, d, "fresh", "proj", func(s *db.Session) {
s.MessageCount = 2
s.UserMessageCount = 1
})
require.NoError(t, d.InsertMessages([]db.Message{
dbtest.UserMsg("fresh", 0, "uniquesearchmarker here"),
}))
_, out, err := ts.searchSessions(context.Background(), nil, searchSessionsIn{
Query: "uniquesearchmarker",
})
require.NoError(t, err)
assert.Empty(t, out.Results)
assert.Equal(t, 1, out.ExcludedActive)
_, all, err := ts.searchSessions(context.Background(), nil, searchSessionsIn{
Query: "uniquesearchmarker", IncludeActive: true,
})
require.NoError(t, err)
assert.Len(t, all.Results, 1)
}
// TestServer_EndToEnd connects a real MCP client to the server over an
// in-memory transport and calls a tool, validating registration, schema
// inference, and the structured-output round-trip through the SDK.
func TestServer_EndToEnd(t *testing.T) {
d := dbtest.OpenTestDB(t)
if !d.HasFTS() {
t.Skip("FTS not available")
}
seedFTSSession(t, d, "s1", "proj", "unique end-to-end marker", "2024-01-01T10:00:00Z")
srv := newServer(ServeOptions{
Service: service.NewDirectBackend(d, nil),
Now: func() time.Time { return fixedNow },
})
ctx := context.Background()
st, ct := newInMemoryPair(t, srv)
tools, err := ct.ListTools(ctx, nil)
require.NoError(t, err)
names := make([]string, 0, len(tools.Tools))
for _, tl := range tools.Tools {
names = append(names, tl.Name)
}
assert.ElementsMatch(t, []string{
ToolSearchSessions, ToolListSessions, ToolGetSessionOverview,
ToolGetMessages, ToolSearchContent, ToolGetUsageSummary,
}, names)
res, err := ct.CallTool(ctx, callParams("search_sessions", map[string]any{
"query": "marker",
}))
require.NoError(t, err)
require.False(t, res.IsError, "tool returned error")
var out searchSessionsOut
raw, err := json.Marshal(res.StructuredContent)
require.NoError(t, err)
require.NoError(t, json.Unmarshal(raw, &out))
require.Len(t, out.Results, 1)
assert.Equal(t, "s1", out.Results[0].SessionID)
require.NoError(t, ct.Close())
require.NoError(t, st.Wait())
}
// fakeContentSearchService captures the ContentSearchRequest a tool builds
// and returns a canned result or error, so semantic-mode passthrough and
// context-mapping can be asserted without a full backend. Unused methods
// fall through to the embedded nil interface (never called by searchContent
// when IncludeActive is set, which skips the session-activity lookup).
type fakeContentSearchService struct {
service.SessionService
lastReq service.ContentSearchRequest
result *service.ContentSearchResult
err error
}
func (f *fakeContentSearchService) SearchContent(
_ context.Context, req service.ContentSearchRequest,
) (*service.ContentSearchResult, error) {
f.lastReq = req
if f.err != nil {
return nil, f.err
}
return f.result, nil
}
// search_content must pass Mode through to the service untouched, and map
// service.ErrSemanticUnavailable to a tool error carrying the remediation
// sentence from db.ErrSemanticUnavailable ("...run 'agentsview embeddings
// build'"), not a generic failure.
func TestSearchContent_SemanticUnavailableMapsToRemediationError(t *testing.T) {
fake := &fakeContentSearchService{err: service.ErrSemanticUnavailable}
ts := &toolset{svc: fake, now: func() time.Time { return fixedNow }}
_, _, err := ts.searchContent(context.Background(), nil, searchContentIn{
Pattern: "how do I configure retries", Mode: "semantic", IncludeActive: true,
})
require.Error(t, err)
assert.ErrorIs(t, err, service.ErrSemanticUnavailable)
assert.Contains(t, err.Error(), "embeddings build")
assert.Equal(t, "semantic", fake.lastReq.Mode)
}
// search_content must reject scope outside semantic/hybrid with the same
// message the HTTP transport uses (the db layer silently ignores Scope for
// lexical modes, so the guard lives in the transport), and must not reach
// the service at all on rejection.
func TestSearchContent_ScopeRejectedOnLexicalModes(t *testing.T) {
fake := &fakeContentSearchService{result: &service.ContentSearchResult{}}
ts := &toolset{svc: fake, now: func() time.Time { return fixedNow }}
for _, mode := range []string{"", "substring", "regex", "fts"} {
t.Run("mode="+mode, func(t *testing.T) {
_, _, err := ts.searchContent(context.Background(), nil, searchContentIn{
Pattern: "needle", Mode: mode, Scope: "top", IncludeActive: true,
})
require.Error(t, err)
assert.EqualError(t, err,
"scope is only supported for semantic and hybrid search modes")
})
}
assert.Empty(t, fake.lastReq.Pattern,
"a rejected request must not reach the service")
}
// search_content must pass Scope through to the service untouched for
// semantic and hybrid modes; the db layer owns scope-value validation from
// there.
func TestSearchContent_ScopeForwardedForSemanticModes(t *testing.T) {
for _, mode := range []string{"semantic", "hybrid"} {
t.Run(mode, func(t *testing.T) {
fake := &fakeContentSearchService{result: &service.ContentSearchResult{}}
ts := &toolset{svc: fake, now: func() time.Time { return fixedNow }}
_, _, err := ts.searchContent(context.Background(), nil, searchContentIn{
Pattern: "retries", Mode: mode, Scope: "subordinate", IncludeActive: true,
})
require.NoError(t, err)
assert.Equal(t, mode, fake.lastReq.Mode)
assert.Equal(t, "subordinate", fake.lastReq.Scope,
"scope must reach the service untouched")
})
}
}
// search_content's Context parameter must reach the service, and each
// match's ContextBefore/ContextAfter (full service-level db.Message) must
// map to the MCP layer's truncated contextMessage shape, along with Score.
func TestSearchContent_ContextThreading(t *testing.T) {
score := 0.83
long := strings.Repeat("y", 600)
fake := &fakeContentSearchService{
result: &service.ContentSearchResult{
Matches: []db.ContentMatch{{
SessionID: "s1", Agent: "claude", Location: "message",
Role: "user", Ordinal: 10, Timestamp: "2024-06-15T09:00:00Z",
Snippet: "hit", Score: &score,
ContextBefore: []db.Message{
{Ordinal: 8, Role: "user", Content: "before msg"},
{Ordinal: 9, Role: "assistant", Content: long},
},
ContextAfter: []db.Message{
{Ordinal: 11, Role: "assistant", Content: "after msg"},
},
}},
},
}
ts := &toolset{svc: fake, now: func() time.Time { return fixedNow }}
_, out, err := ts.searchContent(context.Background(), nil, searchContentIn{
Pattern: "hit", Context: 5, IncludeActive: true,
})
require.NoError(t, err)
assert.Equal(t, 5, fake.lastReq.Context, "context param must reach the service")
require.Len(t, out.Matches, 1)
m := out.Matches[0]
require.NotNil(t, m.Score)
assert.InDelta(t, score, *m.Score, 0.0001)
require.Len(t, m.ContextBefore, 2)
assert.Equal(t, 8, m.ContextBefore[0].Ordinal)
assert.Equal(t, "user", m.ContextBefore[0].Role)
assert.Equal(t, "before msg", m.ContextBefore[0].Content)
assert.Len(t, m.ContextBefore[1].Content, 500, "context content is truncated to 500 chars")
require.Len(t, m.ContextAfter, 1)
assert.Equal(t, 11, m.ContextAfter[0].Ordinal)
assert.Equal(t, "after msg", m.ContextAfter[0].Content)
}
// get_messages's around/before/after form a symmetric window that is
// mutually exclusive with the linear from/direction form, and before/after
// require around. Errors come straight from the service (directBackend
// validates), so the tool error text must match its sentinels verbatim.
func TestGetMessages_AroundValidation(t *testing.T) {
ts, d := newTestToolset(t)
dbtest.SeedSession(t, d, "s1", "proj", func(s *db.Session) {
s.MessageCount = 3
s.UserMessageCount = 2
})
require.NoError(t, d.InsertMessages([]db.Message{
dbtest.UserMsg("s1", 0, "m0"),
dbtest.AsstMsg("s1", 1, "m1"),
dbtest.UserMsg("s1", 2, "m2"),
}))
anchor, from, before := 1, 0, 2
tests := []struct {
name string
in getMessagesIn
wantErr string
}{
{
name: "around with direction rejected",
in: getMessagesIn{SessionID: "s1", Around: &anchor, Direction: "desc"},
wantErr: "around is mutually exclusive with from/direction",
},
{
name: "around with from rejected",
in: getMessagesIn{SessionID: "s1", Around: &anchor, From: &from},
wantErr: "around is mutually exclusive with from/direction",
},
{
name: "before without around rejected",
in: getMessagesIn{SessionID: "s1", Before: &before},
wantErr: "before/after require around",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, _, err := ts.getMessages(context.Background(), nil, tt.in)
require.Error(t, err)
assert.Equal(t, tt.wantErr, err.Error())
})
}
}
// The around path anchors next_from on the last returned ordinal, not a
// scan-direction offset like the linear path (there is no scan direction in
// a symmetric window).
func TestGetMessages_AroundNextFromIsLastPlusOne(t *testing.T) {
ts, d := newTestToolset(t)
dbtest.SeedSession(t, d, "s1", "proj", func(s *db.Session) {
s.MessageCount = 5
s.UserMessageCount = 3
})
require.NoError(t, d.InsertMessages([]db.Message{
dbtest.UserMsg("s1", 0, "m0"),
dbtest.AsstMsg("s1", 1, "m1"),
dbtest.UserMsg("s1", 2, "m2"),
dbtest.AsstMsg("s1", 3, "m3"),
dbtest.UserMsg("s1", 4, "m4"),
}))
anchor, before, after := 2, 1, 1
_, out, err := ts.getMessages(context.Background(), nil, getMessagesIn{
SessionID: "s1", Around: &anchor, Before: &before, After: &after,
})
require.NoError(t, err)
require.Len(t, out.Messages, 3)
last := out.Messages[len(out.Messages)-1].Ordinal
require.NotNil(t, out.NextFrom)
assert.Equal(t, last+1, *out.NextFrom)
assert.Equal(t, 4, *out.NextFrom)
}
// An empty Roles on the around path must be translated to the MCP default
// (user, assistant) before reaching the service: an empty
// service.MessageFilter.Roles means "all roles" there, which would leak
// tool-role dumps that the linear path's own default excludes. The
// translated roles reach the DB-level before/after query directly (unlike
// the linear path's post-fetch filter), so the non-anchor tool rows here
// are dropped before the MCP layer ever sees them -- Filtered stays 0; the
// anchor-bypass case is covered separately below.
func TestGetMessages_AroundDefaultRolesExcludesTool(t *testing.T) {
ts, d := newTestToolset(t)
dbtest.SeedSession(t, d, "s1", "proj", func(s *db.Session) {
s.MessageCount = 5
s.UserMessageCount = 2
})
require.NoError(t, d.InsertMessages([]db.Message{
dbtest.UserMsg("s1", 0, "m0"),
{SessionID: "s1", Ordinal: 1, Role: "tool", Content: "tool dump", ContentLength: 9},
dbtest.AsstMsg("s1", 2, "m2"),
{SessionID: "s1", Ordinal: 3, Role: "tool", Content: "tool dump 2", ContentLength: 11},
dbtest.UserMsg("s1", 4, "m4"),
}))
anchor := 2
_, out, err := ts.getMessages(context.Background(), nil, getMessagesIn{
SessionID: "s1", Around: &anchor,
})
require.NoError(t, err)
require.Len(t, out.Messages, 3, "ordinals 0, 2, 4 survive; the two tool rows never reach this page")
for _, m := range out.Messages {
assert.NotEqual(t, "tool", m.Role)
}
assert.Equal(t, 0, out.Filtered)
}
// The around path always includes the anchor row server-side regardless of
// its role or system status. The MCP layer must post-filter it like any
// other message, suppressing a system anchor and counting it in Filtered
// rather than hardcoding Filtered to 0.
func TestGetMessages_AroundSuppressesSystemAnchor(t *testing.T) {
ts, d := newTestToolset(t)
dbtest.SeedSession(t, d, "s1", "proj", func(s *db.Session) {
s.MessageCount = 3
s.UserMessageCount = 2
})
require.NoError(t, d.InsertMessages([]db.Message{
dbtest.UserMsg("s1", 0, "m0"),
{
SessionID: "s1", Ordinal: 1, Role: "system",
Content: "sys", IsSystem: true, ContentLength: 3,
},
dbtest.AsstMsg("s1", 2, "m2"),
}))
anchor, before, after := 1, 1, 1
_, out, err := ts.getMessages(context.Background(), nil, getMessagesIn{
SessionID: "s1", Around: &anchor, Before: &before, After: &after,
})
require.NoError(t, err)
require.Len(t, out.Messages, 2)
for _, m := range out.Messages {
assert.NotEqual(t, 1, m.Ordinal, "the system anchor must be suppressed")
}
assert.Equal(t, 1, out.Filtered, "the suppressed anchor is counted in Filtered")
}
// The anchor query has no role predicate, so a tool-role anchor is returned
// by the service even under the MCP default roles (user, assistant). The
// MCP layer's post-filter must suppress it too and count it in Filtered,
// exactly like the system-anchor case above.
func TestGetMessages_AroundSuppressesToolRoleAnchor(t *testing.T) {
ts, d := newTestToolset(t)
dbtest.SeedSession(t, d, "s1", "proj", func(s *db.Session) {
s.MessageCount = 3
s.UserMessageCount = 2
})
require.NoError(t, d.InsertMessages([]db.Message{
dbtest.UserMsg("s1", 0, "m0"),
{SessionID: "s1", Ordinal: 1, Role: "tool", Content: "tool dump", ContentLength: 9},
dbtest.AsstMsg("s1", 2, "m2"),
}))
anchor, before, after := 1, 1, 1
_, out, err := ts.getMessages(context.Background(), nil, getMessagesIn{
SessionID: "s1", Around: &anchor, Before: &before, After: &after,
})
require.NoError(t, err)
require.Len(t, out.Messages, 2)
for _, m := range out.Messages {
assert.NotEqual(t, 1, m.Ordinal, "the tool-role anchor must be suppressed")
}
assert.Equal(t, 1, out.Filtered, "the suppressed anchor is counted in Filtered")
}
// TestGetMessages_AroundClampsOversizedWindow verifies that an oversized
// before/after request (e.g. before=10^9) cannot bypass db.MaxMessageLimit
// through the MCP get_messages tool: directBackend.Messages clamps the
// window before it ever reaches the store, so at most db.MaxMessageLimit
// messages come back even though more than that many exist on both sides
// of the anchor.
func TestGetMessages_AroundClampsOversizedWindow(t *testing.T) {
ts, d := newTestToolset(t)
const total = db.MaxMessageLimit + 50
dbtest.SeedSession(t, d, "s1", "proj", func(s *db.Session) {
s.MessageCount = total
s.UserMessageCount = total
})
require.NoError(t, d.InsertMessages(dbtest.UserMessagesf("s1", total, "m%d")))
anchor, huge := total/2, 1_000_000_000
_, out, err := ts.getMessages(context.Background(), nil, getMessagesIn{
SessionID: "s1", Around: &anchor, Before: &huge, After: &huge,
Roles: []string{"user"},
})
require.NoError(t, err)
assert.LessOrEqual(t, len(out.Messages), db.MaxMessageLimit,
"an oversized around window must be capped at db.MaxMessageLimit")
assert.Less(t, len(out.Messages), total,
"the oversized request must actually be capped below what an "+
"unclamped window would have returned")
}