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

784 lines
30 KiB
Go

//go:build pgtest
package postgres
import (
"context"
"errors"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.kenn.io/agentsview/internal/db"
)
// contentSearchSchema is an isolated schema for content-search tests so
// they don't interfere with other pgtest suites that reuse testSchema.
const contentSearchSchema = "agentsview_content_search_test"
// setupContentSearch creates a fresh schema and returns a *Store pointing
// at it plus a raw *sql.DB for direct inserts.
func setupContentSearch(t *testing.T) *Store {
t.Helper()
pgURL := testPGURL(t)
pg, err := Open(pgURL, contentSearchSchema, true)
require.NoError(t, err, "Open")
defer pg.Close()
_, err = pg.Exec(`DROP SCHEMA IF EXISTS ` + contentSearchSchema + ` CASCADE`)
require.NoError(t, err, "drop schema")
ctx := context.Background()
require.NoError(t, EnsureSchema(ctx, pg, contentSearchSchema), "EnsureSchema")
store, err := NewStore(pgURL, contentSearchSchema, true)
require.NoError(t, err, "NewStore")
t.Cleanup(func() { store.Close() })
return store
}
// insertCSSession inserts a session into the content-search schema via the
// store's raw DB handle. Sessions need user_message_count > 1 so that the
// one-shot exclusion does not hide them from the default filter.
func insertCSSession(
t *testing.T, store *Store,
id, project, agent, startedAt, endedAt string,
) {
t.Helper()
_, err := store.DB().Exec(`
INSERT INTO sessions
(id, machine, project, agent, first_message,
started_at, ended_at, message_count, user_message_count)
VALUES ($1, 'test-machine', $2, $3, 'seed',
$4::timestamptz, $5::timestamptz, 10, 5)
ON CONFLICT (id) DO NOTHING`,
id, project, agent, startedAt, endedAt,
)
require.NoError(t, err, "insert session %s", id)
}
func setCSSessionBranch(t *testing.T, store *Store, id, branch string) {
t.Helper()
_, err := store.DB().Exec(
`UPDATE sessions SET git_branch = $1 WHERE id = $2`,
branch, id,
)
require.NoError(t, err, "set session branch %s", id)
}
// insertCSMessage inserts a message; isSystem=true sets is_system.
func insertCSMessage(
t *testing.T, store *Store,
sessionID string, ordinal int, role, content, ts string, isSystem bool,
) {
t.Helper()
_, err := store.DB().Exec(`
INSERT INTO messages
(session_id, ordinal, role, content, timestamp,
content_length, is_system)
VALUES ($1, $2, $3, $4, $5::timestamptz, $6, $7)
ON CONFLICT DO NOTHING`,
sessionID, ordinal, role, content, ts, len(content), isSystem,
)
require.NoError(t, err, "insert message ord=%d", ordinal)
}
// insertCSToolCall inserts a tool_call row.
func insertCSToolCall(
t *testing.T, store *Store,
sessionID string, messageOrdinal, callIndex int,
toolName, toolUseID, inputJSON, resultContent string,
) {
t.Helper()
_, err := store.DB().Exec(`
INSERT INTO tool_calls
(session_id, message_ordinal, call_index, tool_name,
category, tool_use_id, input_json, result_content)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT DO NOTHING`,
sessionID, messageOrdinal, callIndex, toolName,
toolName, toolUseID, inputJSON, resultContent,
)
require.NoError(t, err, "insert tool_call")
}
// insertCSToolResultEvent inserts a tool_result_event row.
func insertCSToolResultEvent(
t *testing.T, store *Store,
sessionID string, messageOrdinal, callIndex, eventIndex int,
toolUseID, content string,
) {
t.Helper()
_, err := store.DB().Exec(`
INSERT INTO tool_result_events
(session_id, tool_call_message_ordinal, call_index,
tool_use_id, source, status, content, event_index)
VALUES ($1, $2, $3, $4, 'stdout', 'ok', $5, $6)
ON CONFLICT DO NOTHING`,
sessionID, messageOrdinal, callIndex, toolUseID, content, eventIndex,
)
require.NoError(t, err, "insert tool_result_event")
}
// ---- tests ----
// TestPGSearchContentSubstringMessages verifies substring match in messages.
func TestPGSearchContentSubstringMessages(t *testing.T) {
store := setupContentSearch(t)
insertCSSession(t, store, "cs-m1", "proj", "claude",
"2026-05-01T10:00:00Z", "2026-05-01T10:30:00Z")
insertCSMessage(t, store, "cs-m1", 0, "user",
"please find the DATABASE_URL value", "2026-05-01T10:00:00Z", false)
insertCSMessage(t, store, "cs-m1", 1, "assistant",
"no match here", "2026-05-01T10:00:01Z", false)
ctx := context.Background()
got, err := store.SearchContent(ctx, db.ContentSearchFilter{
Pattern: "database_url", Mode: "substring",
Sources: []string{"messages"}, Limit: 50,
IncludeOneShot: true,
})
require.NoError(t, err, "SearchContent")
require.Len(t, got.Matches, 1, "matches=%+v", got.Matches)
m := got.Matches[0]
assert.Equal(t, "cs-m1", m.SessionID)
assert.Equal(t, "message", m.Location)
assert.Equal(t, 0, m.Ordinal)
assert.Equal(t, "user", m.Role)
assert.NotEmpty(t, m.Snippet)
}
// TestPGSearchContentRedactsStraddlingSecret pins the PG default (non-reveal)
// guarantee: a secret adjacent to the match that extends past the snippet
// window must not leak; reveal opts out and shows raw bytes.
func TestPGSearchContentRedactsStraddlingSecret(t *testing.T) {
store := setupContentSearch(t)
pem := "-----BEGIN RSA PRIVATE KEY-----\n" +
strings.Repeat("MIIBSECRETKEYMATERIAL0123456789ABCDEF\n", 5) +
"-----END RSA PRIVATE KEY-----"
insertCSSession(t, store, "cs-sec", "proj", "claude",
"2026-05-01T10:00:00Z", "2026-05-01T10:30:00Z")
insertCSMessage(t, store, "cs-sec", 0, "user",
"deploy with this attached key "+pem+" ok", "2026-05-01T10:00:00Z", false)
ctx := context.Background()
base := db.ContentSearchFilter{
Pattern: "attached key", Mode: "substring",
Sources: []string{"messages"}, Limit: 50, IncludeOneShot: true,
}
got, err := store.SearchContent(ctx, base)
require.NoError(t, err, "SearchContent")
require.Len(t, got.Matches, 1, "matches=%+v", got.Matches)
assert.NotContains(t, got.Matches[0].Snippet, "SECRETKEYMATERIAL",
"default snippet leaked key material")
base.RevealSecrets = true
rev, err := store.SearchContent(ctx, base)
require.NoError(t, err, "SearchContent reveal")
assert.Contains(t, rev.Matches[0].Snippet, "SECRETKEYMATERIAL",
"reveal snippet should show raw bytes")
}
// TestPGSearchContentSubstringToolInput verifies substring match in tool_input.
func TestPGSearchContentSubstringToolInput(t *testing.T) {
store := setupContentSearch(t)
insertCSSession(t, store, "cs-ti1", "proj", "claude",
"2026-05-01T10:00:00Z", "2026-05-01T10:30:00Z")
insertCSMessage(t, store, "cs-ti1", 0, "assistant",
"running it", "2026-05-01T10:00:00Z", false)
insertCSToolCall(t, store, "cs-ti1", 0, 0,
"Bash", "tu1", `{"command":"printenv"}`, "output here")
ctx := context.Background()
got, err := store.SearchContent(ctx, db.ContentSearchFilter{
Pattern: "printenv", Mode: "substring",
Sources: []string{"tool_input"}, Limit: 50,
})
require.NoError(t, err, "SearchContent")
require.Len(t, got.Matches, 1, "matches=%+v", got.Matches)
m := got.Matches[0]
assert.Equal(t, "tool_input", m.Location)
assert.Equal(t, "Bash", m.ToolName)
}
// TestPGSearchContentSubstringToolResult verifies substring match in tool_result
// result_content (no events -> result_content branch).
func TestPGSearchContentSubstringToolResult(t *testing.T) {
store := setupContentSearch(t)
insertCSSession(t, store, "cs-tr1", "proj", "claude",
"2026-05-01T10:00:00Z", "2026-05-01T10:30:00Z")
insertCSMessage(t, store, "cs-tr1", 0, "assistant",
"running it", "2026-05-01T10:00:00Z", false)
insertCSToolCall(t, store, "cs-tr1", 0, 0,
"Bash", "tu1", `{"command":"ls"}`, "AWS_SECRET=topsecretvalue123")
ctx := context.Background()
got, err := store.SearchContent(ctx, db.ContentSearchFilter{
Pattern: "topsecretvalue", Mode: "substring",
Sources: []string{"tool_result"}, Limit: 50,
})
require.NoError(t, err, "SearchContent")
require.Len(t, got.Matches, 1, "matches=%+v", got.Matches)
assert.Equal(t, "tool_result", got.Matches[0].Location)
}
// TestPGSearchContentToolResultEvents verifies that the tool_result_events
// branch is searched.
func TestPGSearchContentToolResultEvents(t *testing.T) {
store := setupContentSearch(t)
insertCSSession(t, store, "cs-tre1", "proj", "claude",
"2026-05-01T10:00:00Z", "2026-05-01T10:30:00Z")
insertCSMessage(t, store, "cs-tre1", 0, "assistant",
"running it", "2026-05-01T10:00:00Z", false)
// tool_call with no result_content but has a result event.
insertCSToolCall(t, store, "cs-tre1", 0, 0,
"Bash", "tu1", `{"command":"ls"}`, "")
insertCSToolResultEvent(t, store, "cs-tre1", 0, 0, 0,
"tu1", "EVENTNEEDLE in event output")
ctx := context.Background()
got, err := store.SearchContent(ctx, db.ContentSearchFilter{
Pattern: "EVENTNEEDLE", Mode: "substring",
Sources: []string{"tool_result"}, Limit: 50,
})
require.NoError(t, err, "SearchContent")
require.Len(t, got.Matches, 1, "matches=%+v", got.Matches)
assert.Equal(t, "tool_result", got.Matches[0].Location)
}
// TestPGSearchContentToolResultDedup verifies dedup: when a tool_use_id has
// result events, only the events branch matches, not result_content.
func TestPGSearchContentToolResultDedup(t *testing.T) {
store := setupContentSearch(t)
insertCSSession(t, store, "cs-dup1", "proj", "claude",
"2026-05-01T10:00:00Z", "2026-05-01T10:30:00Z")
insertCSMessage(t, store, "cs-dup1", 0, "assistant",
"running it", "2026-05-01T10:00:00Z", false)
// result_content also contains the needle — but the call has an event,
// so the NOT EXISTS guard should suppress the result_content branch.
insertCSToolCall(t, store, "cs-dup1", 0, 0,
"Bash", "tu1", `{"command":"echo"}`,
"DUPNEEDLE in result_content")
insertCSToolResultEvent(t, store, "cs-dup1", 0, 0, 0,
"tu1", "DUPNEEDLE in event")
ctx := context.Background()
got, err := store.SearchContent(ctx, db.ContentSearchFilter{
Pattern: "DUPNEEDLE", Mode: "substring",
Sources: []string{"tool_result"}, Limit: 50,
})
require.NoError(t, err, "SearchContent")
// Should be exactly 1 match (from the event, not result_content).
require.Len(t, got.Matches, 1, "dedup: matches=%+v", got.Matches)
}
// TestPGSearchContentSourcesSelector verifies that searching only "messages"
// does not return tool_input or tool_result hits.
func TestPGSearchContentSourcesSelector(t *testing.T) {
store := setupContentSearch(t)
insertCSSession(t, store, "cs-src1", "proj", "claude",
"2026-05-01T10:00:00Z", "2026-05-01T10:30:00Z")
insertCSMessage(t, store, "cs-src1", 0, "assistant",
"running it", "2026-05-01T10:00:00Z", false)
insertCSToolCall(t, store, "cs-src1", 0, 0,
"Bash", "tu1", `{"command":"SRCNEEDLE"}`, "SRCNEEDLE in result")
ctx := context.Background()
// Only messages — must not match tool_input or tool_result.
got, err := store.SearchContent(ctx, db.ContentSearchFilter{
Pattern: "SRCNEEDLE", Mode: "substring",
Sources: []string{"messages"}, Limit: 50,
})
require.NoError(t, err, "SearchContent")
assert.Empty(t, got.Matches,
"messages-only source returned tool match")
// Both tool sources — must match.
all, err := store.SearchContent(ctx, db.ContentSearchFilter{
Pattern: "SRCNEEDLE", Mode: "substring",
Sources: []string{"tool_input", "tool_result"}, Limit: 50,
})
require.NoError(t, err, "SearchContent all")
assert.NotEmpty(t, all.Matches,
"expected matches when sources include tool_input/tool_result")
}
// TestPGSearchContentExcludeSystem verifies that is_system=true messages are
// excluded when ExcludeSystem=true.
func TestPGSearchContentExcludeSystem(t *testing.T) {
store := setupContentSearch(t)
insertCSSession(t, store, "cs-sys1", "proj", "claude",
"2026-05-01T10:00:00Z", "2026-05-01T10:30:00Z")
// is_system = true
insertCSMessage(t, store, "cs-sys1", 0, "user",
"SYSNEEDLE in a system message", "2026-05-01T10:00:00Z", true)
ctx := context.Background()
// Default (ExcludeSystem=false) should find it.
with, err := store.SearchContent(ctx, db.ContentSearchFilter{
Pattern: "SYSNEEDLE", Mode: "substring",
Sources: []string{"messages"}, Limit: 50,
})
require.NoError(t, err, "SearchContent with system")
assert.Len(t, with.Matches, 1)
// ExcludeSystem=true should suppress it.
without, err := store.SearchContent(ctx, db.ContentSearchFilter{
Pattern: "SYSNEEDLE", Mode: "substring",
Sources: []string{"messages"}, ExcludeSystem: true, Limit: 50,
})
require.NoError(t, err, "SearchContent exclude system")
assert.Empty(t, without.Matches,
"ExcludeSystem=true should suppress system messages")
}
// TestPGSearchContentProjectFilter verifies the Project session filter.
func TestPGSearchContentProjectFilter(t *testing.T) {
store := setupContentSearch(t)
insertCSSession(t, store, "cs-pf-a", "alpha", "claude",
"2026-05-01T10:00:00Z", "2026-05-01T10:30:00Z")
insertCSMessage(t, store, "cs-pf-a", 0, "user",
"PROJNEEDLE alpha", "2026-05-01T10:00:00Z", false)
insertCSSession(t, store, "cs-pf-b", "beta", "claude",
"2026-05-01T11:00:00Z", "2026-05-01T11:30:00Z")
insertCSMessage(t, store, "cs-pf-b", 0, "user",
"PROJNEEDLE beta", "2026-05-01T11:00:00Z", false)
ctx := context.Background()
got, err := store.SearchContent(ctx, db.ContentSearchFilter{
Pattern: "PROJNEEDLE", Mode: "substring",
Sources: []string{"messages"},
Project: "alpha",
Limit: 50,
})
require.NoError(t, err, "SearchContent project filter")
for _, m := range got.Matches {
assert.Equal(t, "alpha", m.Project, "project filter leak")
}
assert.NotEmpty(t, got.Matches, "expected matches in alpha project")
}
func TestPGSearchContentGitBranchFilter(t *testing.T) {
store := setupContentSearch(t)
insertCSSession(t, store, "cs-branch-alpha-main", "alpha", "claude",
"2026-05-01T10:00:00Z", "2026-05-01T10:30:00Z")
setCSSessionBranch(t, store, "cs-branch-alpha-main", "main")
insertCSMessage(t, store, "cs-branch-alpha-main", 0, "user",
"BRANCHNEEDLE alpha main", "2026-05-01T10:00:00Z", false)
insertCSSession(t, store, "cs-branch-alpha-feature", "alpha", "claude",
"2026-05-01T11:00:00Z", "2026-05-01T11:30:00Z")
setCSSessionBranch(t, store, "cs-branch-alpha-feature", "feature")
insertCSMessage(t, store, "cs-branch-alpha-feature", 0, "user",
"BRANCHNEEDLE alpha feature", "2026-05-01T11:00:00Z", false)
insertCSSession(t, store, "cs-branch-beta-main", "beta", "claude",
"2026-05-01T12:00:00Z", "2026-05-01T12:30:00Z")
setCSSessionBranch(t, store, "cs-branch-beta-main", "main")
insertCSMessage(t, store, "cs-branch-beta-main", 0, "user",
"BRANCHNEEDLE beta main", "2026-05-01T12:00:00Z", false)
ctx := context.Background()
got, err := store.SearchContent(ctx, db.ContentSearchFilter{
Pattern: "BRANCHNEEDLE",
Mode: "substring",
Sources: []string{"messages"},
GitBranch: db.EncodeBranchFilterToken("alpha", "main"),
IncludeOneShot: true,
Limit: 50,
})
require.NoError(t, err)
require.Len(t, got.Matches, 1)
assert.Equal(t, "cs-branch-alpha-main", got.Matches[0].SessionID)
}
// TestPGSearchContentPagination verifies Limit+1 sentinel and NextCursor.
func TestPGSearchContentPagination(t *testing.T) {
store := setupContentSearch(t)
insertCSSession(t, store, "cs-page", "proj", "claude",
"2026-05-01T10:00:00Z", "2026-05-01T10:30:00Z")
for i := 0; i < 3; i++ {
insertCSMessage(t, store, "cs-page", i, "user",
"PAGENEEDLE msg", "2026-05-01T10:00:0"+string(rune('0'+i))+"Z", false)
}
ctx := context.Background()
first, err := store.SearchContent(ctx, db.ContentSearchFilter{
Pattern: "PAGENEEDLE", Mode: "substring",
Sources: []string{"messages"}, Limit: 2,
})
require.NoError(t, err, "SearchContent page1")
require.Len(t, first.Matches, 2)
require.NotZero(t, first.NextCursor, "page1: expected NextCursor to be set")
second, err := store.SearchContent(ctx, db.ContentSearchFilter{
Pattern: "PAGENEEDLE", Mode: "substring",
Sources: []string{"messages"}, Limit: 2, Cursor: first.NextCursor,
})
require.NoError(t, err, "SearchContent page2")
require.Len(t, second.Matches, 1)
assert.Zero(t, second.NextCursor, "page2: last page")
}
// TestPGSearchContentPaginationStableAcrossTies seeds one message ordinal that
// yields three hits tying on (session, ordinal): the message body, the tool
// input, and the tool result. The src/row_id tie-break must make page-by-page
// retrieval reproduce the single-page order with no duplicates or gaps.
func TestPGSearchContentPaginationStableAcrossTies(t *testing.T) {
store := setupContentSearch(t)
insertCSSession(t, store, "cs-tie", "proj", "claude",
"2026-05-01T10:00:00Z", "2026-05-01T10:30:00Z")
insertCSMessage(t, store, "cs-tie", 0, "assistant",
"FINDME in message body", "2026-05-01T10:00:00Z", false)
insertCSToolCall(t, store, "cs-tie", 0, 0,
"Bash", "tu1", `{"command":"FINDME"}`, "FINDME in result")
ctx := context.Background()
base := db.ContentSearchFilter{
Pattern: "FINDME", Mode: "substring",
Sources: []string{"messages", "tool_input", "tool_result"},
}
full := base
full.Limit = 50
all, err := store.SearchContent(ctx, full)
require.NoError(t, err, "SearchContent full")
require.Len(t, all.Matches, 3, "tied matches: %+v", all.Matches)
wantOrder := []string{"message", "tool_input", "tool_result"}
for i, loc := range wantOrder {
assert.Equal(t, loc, all.Matches[i].Location, "match %d", i)
}
var paged []db.ContentMatch
for cursor := 0; ; {
p := base
p.Limit = 1
p.Cursor = cursor
page, err := store.SearchContent(ctx, p)
require.NoError(t, err, "SearchContent page at cursor %d", cursor)
paged = append(paged, page.Matches...)
if page.NextCursor == 0 {
break
}
cursor = page.NextCursor
}
require.Len(t, paged, len(all.Matches), "duplicates or gaps")
for i := range all.Matches {
assert.Equal(t, all.Matches[i].Location, paged[i].Location,
"row %d: paged vs single-page", i)
}
}
// TestPGSearchContentEmptyToolUseIDNotSuppressed mirrors the SQLite guard: an
// empty-tool_use_id call's result_content must not be suppressed because a
// different empty-ID call in the session has a result event.
func TestPGSearchContentEmptyToolUseIDNotSuppressed(t *testing.T) {
store := setupContentSearch(t)
insertCSSession(t, store, "cs-empti", "proj", "claude",
"2026-05-01T10:00:00Z", "2026-05-01T10:30:00Z")
insertCSMessage(t, store, "cs-empti", 0, "assistant",
"running tools", "2026-05-01T10:00:00Z", false)
// Call 0: empty tool_use_id, result in result_content, no events.
insertCSToolCall(t, store, "cs-empti", 0, 0,
"Bash", "", `{"command":"a"}`, "FINDA in result")
// Call 1: empty tool_use_id, result delivered as an event.
insertCSToolCall(t, store, "cs-empti", 0, 1,
"Bash", "", `{"command":"b"}`, "")
insertCSToolResultEvent(t, store, "cs-empti", 0, 1, 0, "", "FINDB event")
ctx := context.Background()
for _, mode := range []string{"substring", "regex"} {
got, err := store.SearchContent(ctx, db.ContentSearchFilter{
Pattern: "FINDA", Mode: mode,
Sources: []string{"tool_result"}, Limit: 50,
})
require.NoError(t, err, "SearchContent %s", mode)
require.Len(t, got.Matches, 1,
"%s: empty-ID result_content suppressed: %+v", mode, got.Matches)
assert.Equal(t, "tool_result", got.Matches[0].Location)
}
}
// TestPGContentSearchTrigramIndex verifies EnsureSchema installs the pg_trgm
// extension and the messages.content trigram index that keeps ILIKE content
// search off a sequential scan. Index creation is best-effort, so the check is
// skipped on an instance where pg_trgm cannot be installed.
func TestPGContentSearchTrigramIndex(t *testing.T) {
store := setupContentSearch(t)
ctx := context.Background()
var hasExt bool
require.NoError(t, store.DB().QueryRowContext(ctx,
`SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_trgm')`,
).Scan(&hasExt), "query pg_extension")
if !hasExt {
t.Skip("pg_trgm not installable on this instance; index is best-effort")
}
var hasIdx bool
require.NoError(t, store.DB().QueryRowContext(ctx,
`SELECT EXISTS (
SELECT 1 FROM pg_indexes
WHERE schemaname = $1 AND indexname = 'idx_messages_content_trgm'
)`, contentSearchSchema,
).Scan(&hasIdx), "query pg_indexes")
assert.True(t, hasIdx,
"idx_messages_content_trgm missing after EnsureSchema")
// fastupdate=off must be set so the pending list cannot grow
// unbounded under continuous ingest. The schema bootstrap applies
// this on every boot (including stores upgraded from an earlier
// schema that created the index with the default fastupdate=on).
var fastupdateOff bool
require.NoError(t, store.DB().QueryRowContext(ctx,
`SELECT EXISTS (
SELECT 1
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = $1
AND c.relname = 'idx_messages_content_trgm'
AND 'fastupdate=off' = ANY(c.reloptions)
)`, contentSearchSchema,
).Scan(&fastupdateOff), "query pg_class.reloptions")
assert.True(t, fastupdateOff,
"idx_messages_content_trgm must have fastupdate=off")
}
// TestPGContentSearchTrigramIndexUpgrade verifies the upgrade path: a store
// whose trigram index was created by an earlier schema with the default
// fastupdate=on gets fastupdate=off reapplied on the next EnsureSchema run.
func TestPGContentSearchTrigramIndexUpgrade(t *testing.T) {
store := setupContentSearch(t)
ctx := context.Background()
hasFastupdateOff := func() bool {
var off bool
require.NoError(t, store.DB().QueryRowContext(ctx,
`SELECT EXISTS (
SELECT 1
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = $1
AND c.relname = 'idx_messages_content_trgm'
AND 'fastupdate=off' = ANY(c.reloptions)
)`, contentSearchSchema,
).Scan(&off), "query pg_class.reloptions")
return off
}
var hasIdx bool
require.NoError(t, store.DB().QueryRowContext(ctx,
`SELECT EXISTS (
SELECT 1 FROM pg_indexes
WHERE schemaname = $1 AND indexname = 'idx_messages_content_trgm'
)`, contentSearchSchema,
).Scan(&hasIdx), "query pg_indexes")
if !hasIdx {
t.Skip("trigram index not created on this instance; index is best-effort")
}
// Simulate an index left behind by the pre-#606 schema, which created
// it with the PostgreSQL default fastupdate=on.
_, err := store.DB().ExecContext(ctx,
`ALTER INDEX idx_messages_content_trgm SET (fastupdate = on)`)
require.NoError(t, err, "reset index to fastupdate=on")
require.False(t, hasFastupdateOff(),
"precondition: index should report fastupdate=on")
require.NoError(t, EnsureSchema(ctx, store.DB(), contentSearchSchema),
"rerun EnsureSchema")
assert.True(t, hasFastupdateOff(),
"EnsureSchema must reapply fastupdate=off to a pre-existing index")
}
// TestPGSearchContentRegex verifies regex mode.
func TestPGSearchContentRegex(t *testing.T) {
store := setupContentSearch(t)
insertCSSession(t, store, "cs-re1", "proj", "claude",
"2026-05-01T10:00:00Z", "2026-05-01T10:30:00Z")
insertCSMessage(t, store, "cs-re1", 0, "user",
"key AKIA"+"7QHWN2DKR4FYPLJM here", "2026-05-01T10:00:00Z", false)
insertCSMessage(t, store, "cs-re1", 1, "user",
"no secrets in this line", "2026-05-01T10:00:01Z", false)
ctx := context.Background()
got, err := store.SearchContent(ctx, db.ContentSearchFilter{
Pattern: `AKIA[0-9A-Z]{16}`, Mode: "regex",
Sources: []string{"messages"}, Limit: 50,
})
require.NoError(t, err, "SearchContent regex")
require.Len(t, got.Matches, 1)
assert.Equal(t, 0, got.Matches[0].Ordinal)
}
// TestPGSearchContentRegexInvalid verifies that an invalid pattern returns a
// SearchInputError.
func TestPGSearchContentRegexInvalid(t *testing.T) {
store := setupContentSearch(t)
ctx := context.Background()
_, err := store.SearchContent(ctx, db.ContentSearchFilter{
Pattern: `(unclosed`, Mode: "regex",
Sources: []string{"messages"},
})
require.Error(t, err, "expected error for invalid regex")
var inputErr *db.SearchInputError
assert.True(t, errors.As(err, &inputErr),
"expected *SearchInputError, got %T: %v", err, err)
}
// TestPGSearchContentFTSMatchesNonContiguousTerms verifies that PG's fts
// fallback preserves SQLite's implicit-AND term semantics.
func TestPGSearchContentFTSMatchesNonContiguousTerms(t *testing.T) {
store := setupContentSearch(t)
insertCSSession(t, store, "cs-fts-both", "proj", "claude",
"2026-05-01T10:00:00Z", "2026-05-01T10:30:00Z")
insertCSMessage(t, store, "cs-fts-both", 0, "user",
"the quick brown fox jumps", "2026-05-01T10:00:00Z", false)
insertCSSession(t, store, "cs-fts-one", "proj", "claude",
"2026-05-01T11:00:00Z", "2026-05-01T11:30:00Z")
insertCSMessage(t, store, "cs-fts-one", 0, "user",
"the quick answer only", "2026-05-01T11:00:00Z", false)
ctx := context.Background()
got, err := store.SearchContent(ctx, db.ContentSearchFilter{
Pattern: "quick fox", Mode: "fts",
Sources: []string{"messages"}, Limit: 50,
})
require.NoError(t, err, "SearchContent fts")
require.Len(t, got.Matches, 1)
assert.Equal(t, "cs-fts-both", got.Matches[0].SessionID)
assert.Equal(t, "message", got.Matches[0].Location)
}
// TestPGSearchContentUnknownSource verifies that an unknown source name
// returns a SearchInputError.
func TestPGSearchContentUnknownSource(t *testing.T) {
store := setupContentSearch(t)
ctx := context.Background()
_, err := store.SearchContent(ctx, db.ContentSearchFilter{
Pattern: "x", Mode: "substring",
Sources: []string{"messages", "bogus"},
})
require.Error(t, err, "expected error for unknown source name")
var inputErr *db.SearchInputError
assert.True(t, errors.As(err, &inputErr),
"expected *SearchInputError, got %T: %v", err, err)
}
// TestPGSearchContentSnippetPresent verifies that the snippet field is
// populated with content surrounding the match.
func TestPGSearchContentSnippetPresent(t *testing.T) {
store := setupContentSearch(t)
insertCSSession(t, store, "cs-snip", "proj", "claude",
"2026-05-01T10:00:00Z", "2026-05-01T10:30:00Z")
insertCSMessage(t, store, "cs-snip", 0, "user",
"the secret key is SNIPNEEDLE and nothing more", "2026-05-01T10:00:00Z", false)
ctx := context.Background()
got, err := store.SearchContent(ctx, db.ContentSearchFilter{
Pattern: "SNIPNEEDLE", Mode: "substring",
Sources: []string{"messages"}, Limit: 50,
})
require.NoError(t, err, "SearchContent")
require.Len(t, got.Matches, 1)
assert.NotEmpty(t, got.Matches[0].Snippet)
}
// insertCSChildSession inserts a child session (relationship_type=subagent)
// linked to parentID. message_count and user_message_count > 1 avoid one-shot
// exclusion when the filter requires it.
func insertCSChildSession(
t *testing.T, store *Store,
id, project, agent, parentID, startedAt, endedAt string,
) {
t.Helper()
_, err := store.DB().Exec(`
INSERT INTO sessions
(id, machine, project, agent, first_message,
parent_session_id, relationship_type,
started_at, ended_at, message_count, user_message_count)
VALUES ($1, 'test-machine', $2, $3, 'child-seed',
$4, 'subagent',
$5::timestamptz, $6::timestamptz, 10, 5)
ON CONFLICT (id) DO NOTHING`,
id, project, agent, parentID, startedAt, endedAt,
)
require.NoError(t, err, "insert child session %s", id)
}
// TestPGSearchContentIncludeChildren verifies that IncludeChildren=true
// surfaces tool_input and tool_result matches from child (subagent) sessions,
// while IncludeChildren=false excludes them. The scoped CTE wraps only the
// sessions table, so id is unambiguous in the tool-table JOINs.
func TestPGSearchContentIncludeChildren(t *testing.T) {
store := setupContentSearch(t)
// Parent session (non-child, passes root filter).
insertCSSession(t, store, "ic-parent", "proj", "claude",
"2026-05-01T10:00:00Z", "2026-05-01T10:30:00Z")
insertCSMessage(t, store, "ic-parent", 0, "assistant",
"parent running tool", "2026-05-01T10:00:00Z", false)
// Child session linked to the parent.
insertCSChildSession(t, store, "ic-child", "proj", "claude",
"ic-parent", "2026-05-01T10:05:00Z", "2026-05-01T10:25:00Z")
// Message needed so the JOIN in tool branches can resolve the timestamp.
insertCSMessage(t, store, "ic-child", 0, "assistant",
"child running tool", "2026-05-01T10:05:00Z", false)
insertCSToolCall(t, store, "ic-child", 0, 0,
"Bash", "child-tu1",
`{"command":"CHILDNEEDLE"}`, "CHILDNEEDLE in result")
// Also add a tool_result_events row for the child to cover that branch.
insertCSChildSession(t, store, "ic-child2", "proj", "claude",
"ic-parent", "2026-05-01T10:10:00Z", "2026-05-01T10:20:00Z")
insertCSMessage(t, store, "ic-child2", 0, "assistant",
"child2 running tool", "2026-05-01T10:10:00Z", false)
insertCSToolCall(t, store, "ic-child2", 0, 0,
"Bash", "child2-tu1", `{"command":"ls"}`, "")
insertCSToolResultEvent(t, store, "ic-child2", 0, 0, 0,
"child2-tu1", "CHILDNEEDLE in event output")
ctx := context.Background()
// --- IncludeChildren=true: child tool_input match must be found ---
withChildren, err := store.SearchContent(ctx, db.ContentSearchFilter{
Pattern: "CHILDNEEDLE",
Mode: "substring",
Sources: []string{"tool_input", "tool_result"},
Limit: 50,
IncludeChildren: true,
IncludeOneShot: true,
})
require.NoError(t, err, "SearchContent IncludeChildren=true")
var foundToolInput, foundToolResult bool
for _, m := range withChildren.Matches {
if m.SessionID == "ic-child" && m.Location == "tool_input" {
foundToolInput = true
}
if m.SessionID == "ic-child2" && m.Location == "tool_result" {
foundToolResult = true
}
}
assert.True(t, foundToolInput,
"IncludeChildren=true: child tool_input match not found; matches=%+v",
withChildren.Matches)
assert.True(t, foundToolResult,
"IncludeChildren=true: child tool_result event match not found; matches=%+v",
withChildren.Matches)
// --- IncludeChildren=false: child matches must be absent ---
withoutChildren, err := store.SearchContent(ctx, db.ContentSearchFilter{
Pattern: "CHILDNEEDLE",
Mode: "substring",
Sources: []string{"tool_input", "tool_result"},
Limit: 50,
IncludeChildren: false,
IncludeOneShot: true,
})
require.NoError(t, err, "SearchContent IncludeChildren=false")
for _, m := range withoutChildren.Matches {
assert.NotContains(t, []string{"ic-child", "ic-child2"}, m.SessionID,
"IncludeChildren=false: child session %q appeared in results", m.SessionID)
}
}