项目文件夹

文件
2026-07-13 13:00:08 +08:00

314 行
12 KiB
Go

package history
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"reasonix/internal/agent"
"reasonix/internal/provider"
)
func TestSearchRanksSavedSessionHistory(t *testing.T) {
sessionDir := t.TempDir()
archiveDir := t.TempDir()
writeSession(t, filepath.Join(sessionDir, "first.jsonl"), []provider.Message{
{Role: provider.RoleUser, Content: "We need a cache-first implementation."},
{Role: provider.RoleAssistant, Content: "Decision: keep the prefix stable and avoid CGO SQLite for Reasonix history retrieval."},
})
writeSession(t, filepath.Join(sessionDir, "second.jsonl"), []provider.Message{
{Role: provider.RoleUser, Content: "Talk about dashboard colors."},
{Role: provider.RoleAssistant, Content: "No database decision here."},
})
searcher := NewSearcher(Options{SessionDir: sessionDir, ArchiveDir: archiveDir})
hits, err := searcher.Search(context.Background(), SearchRequest{Query: "SQLite CGO cache", Limit: 5})
if err != nil {
t.Fatalf("Search() error = %v", err)
}
if len(hits) == 0 {
t.Fatal("Search() returned no hits")
}
if got := filepath.Base(hits[0].SessionPath); got != "first.jsonl" {
t.Fatalf("top hit path = %q, want first.jsonl", got)
}
if hits[0].Kind != KindAssistantText {
t.Fatalf("top hit kind = %q, want %q", hits[0].Kind, KindAssistantText)
}
}
func TestSearchGlobalIncludesArchives(t *testing.T) {
sessionDir := t.TempDir()
archiveDir := t.TempDir()
writeSession(t, filepath.Join(archiveDir, "archive.jsonl"), []provider.Message{
{Role: provider.RoleUser, Content: "Old decision: Obelisk retrieval query runtime stays code-driven."},
})
searcher := NewSearcher(Options{SessionDir: sessionDir, ArchiveDir: archiveDir})
projectHits, err := searcher.Search(context.Background(), SearchRequest{Query: "Obelisk runtime", Scope: "project"})
if err != nil {
t.Fatalf("project Search() error = %v", err)
}
if len(projectHits) != 0 {
t.Fatalf("project Search() hits = %d, want 0", len(projectHits))
}
globalHits, err := searcher.Search(context.Background(), SearchRequest{Query: "Obelisk runtime", Scope: "global"})
if err != nil {
t.Fatalf("global Search() error = %v", err)
}
if len(globalHits) != 1 {
t.Fatalf("global Search() hits = %d, want 1", len(globalHits))
}
if globalHits[0].Source != "archive" {
t.Fatalf("global hit source = %q, want archive", globalHits[0].Source)
}
}
func TestSearchGlobalIncludesGlobalSessionDir(t *testing.T) {
projectDir := t.TempDir()
globalDir := t.TempDir()
writeSession(t, filepath.Join(projectDir, "project.jsonl"), []provider.Message{
{Role: provider.RoleUser, Content: "Project-only decision about local UI spacing."},
})
globalPath := filepath.Join(globalDir, "global.jsonl")
writeSession(t, globalPath, []provider.Message{
{Role: provider.RoleUser, Content: "Global-only decision about synthesis cache reuse."},
})
searcher := NewSearcher(Options{SessionDir: projectDir, GlobalSessionDir: globalDir})
projectHits, err := searcher.Search(context.Background(), SearchRequest{Query: "synthesis cache reuse", Scope: "project"})
if err != nil {
t.Fatalf("project Search() error = %v", err)
}
if len(projectHits) != 0 {
t.Fatalf("project Search() hits = %d, want 0", len(projectHits))
}
globalHits, err := searcher.Search(context.Background(), SearchRequest{Query: "synthesis cache reuse", Scope: "global"})
if err != nil {
t.Fatalf("global Search() error = %v", err)
}
if len(globalHits) != 1 {
t.Fatalf("global Search() hits = %d, want 1", len(globalHits))
}
if globalHits[0].Source != "global" || globalHits[0].SessionPath != globalPath {
t.Fatalf("global hit = %+v, want source=global path=%s", globalHits[0], globalPath)
}
if _, err := searcher.Around(context.Background(), AroundRequest{SessionPath: globalPath, MessageIndex: 0}); err != nil {
t.Fatalf("Around() for global session path failed: %v", err)
}
}
func TestSearchIndexesToolInputsAndErrors(t *testing.T) {
sessionDir := t.TempDir()
writeSession(t, filepath.Join(sessionDir, "tools.jsonl"), []provider.Message{
{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "1", Name: "bash", Arguments: `{"cmd":"go test ./internal/history"}`}}},
{Role: provider.RoleTool, ToolCallID: "1", Name: "bash", Content: "error: command exited: exit status 1\nFAIL"},
})
searcher := NewSearcher(Options{SessionDir: sessionDir})
hits, err := searcher.Search(context.Background(), SearchRequest{Query: "go test fail", ToolName: "bash", Limit: 5})
if err != nil {
t.Fatalf("Search() error = %v", err)
}
if len(hits) < 2 {
t.Fatalf("Search() hits = %d, want at least 2", len(hits))
}
kinds := map[Kind]bool{}
for _, hit := range hits {
kinds[hit.Kind] = true
}
if !kinds[KindToolInput] || !kinds[KindToolError] {
t.Fatalf("hits kinds = %#v, want tool input and tool error", kinds)
}
}
func TestSearchDropsCommonWordNoise(t *testing.T) {
sessionDir := t.TempDir()
writeSession(t, filepath.Join(sessionDir, "rare.jsonl"), []provider.Message{
{Role: provider.RoleUser, Content: "rareterm common common common"},
})
for i := 0; i < 12; i++ {
writeSession(t, filepath.Join(sessionDir, "common-"+string(rune('a'+i))+".jsonl"), []provider.Message{
{Role: provider.RoleUser, Content: "common"},
})
}
searcher := NewSearcher(Options{SessionDir: sessionDir})
hits, err := searcher.Search(context.Background(), SearchRequest{Query: "rareterm common", Limit: 20})
if err != nil {
t.Fatalf("Search() error = %v", err)
}
if len(hits) != 1 {
t.Fatalf("Search() hits = %d, want only rare hit: %+v", len(hits), hits)
}
if got := filepath.Base(hits[0].SessionPath); got != "rare.jsonl" {
t.Fatalf("hit path = %q, want rare.jsonl", got)
}
}
func TestSearchSkipsCleanupPending(t *testing.T) {
sessionDir := t.TempDir()
visiblePath := filepath.Join(sessionDir, "visible.jsonl")
pendingPath := filepath.Join(sessionDir, "pending.jsonl")
writeSession(t, visiblePath, []provider.Message{
{Role: provider.RoleUser, Content: "ordinary alpha visible decision"},
})
writeSession(t, pendingPath, []provider.Message{
{Role: provider.RoleUser, Content: "hidden alpha cleanup pending secret"},
})
if err := agent.MarkCleanupPending(pendingPath, "delete"); err != nil {
t.Fatal(err)
}
searcher := NewSearcher(Options{SessionDir: sessionDir})
hits, err := searcher.Search(context.Background(), SearchRequest{Query: "alpha", Limit: 5})
if err != nil {
t.Fatalf("Search() error = %v", err)
}
if len(hits) != 1 || hits[0].SessionPath != visiblePath {
t.Fatalf("Search() hits = %+v, want only visible path %s", hits, visiblePath)
}
hits, err = searcher.Search(context.Background(), SearchRequest{Query: "cleanup pending secret", Limit: 5})
if err != nil {
t.Fatalf("Search() pending-only query error = %v", err)
}
if len(hits) != 0 {
t.Fatalf("Search() pending-only hits = %+v, want none", hits)
}
}
func TestAroundRejectsCleanupPending(t *testing.T) {
sessionDir := t.TempDir()
path := filepath.Join(sessionDir, "pending.jsonl")
writeSession(t, path, []provider.Message{{Role: provider.RoleUser, Content: "pending secret"}})
if err := agent.MarkCleanupPending(path, "delete"); err != nil {
t.Fatal(err)
}
searcher := NewSearcher(Options{SessionDir: sessionDir})
if _, err := searcher.Around(context.Background(), AroundRequest{SessionPath: path, MessageIndex: 0}); err == nil {
t.Fatal("Around() cleanup-pending error = nil, want rejection")
}
}
func TestHistorySkipsSubagentsOwnedByCleanupPendingParent(t *testing.T) {
sessionDir := t.TempDir()
parentPath := filepath.Join(sessionDir, "parent.jsonl")
writeSession(t, parentPath, []provider.Message{{Role: provider.RoleUser, Content: "parent prompt"}})
visibleParentPath := filepath.Join(sessionDir, "visible-parent.jsonl")
writeSession(t, visibleParentPath, []provider.Message{{Role: provider.RoleUser, Content: "visible parent prompt"}})
pendingSubagentPath := writeSubagentSession(t, sessionDir, "sa_pending", agent.BranchID(parentPath), "subagent hidden orchid result")
visibleSubagentPath := writeSubagentSession(t, sessionDir, "sa_visible", agent.BranchID(visibleParentPath), "subagent visible orchid result")
if err := agent.MarkCleanupPending(parentPath, "delete"); err != nil {
t.Fatal(err)
}
searcher := NewSearcher(Options{SessionDir: sessionDir})
hits, err := searcher.Search(context.Background(), SearchRequest{Query: "orchid result", Limit: 5})
if err != nil {
t.Fatalf("Search() error = %v", err)
}
if len(hits) != 1 || hits[0].SessionPath != visibleSubagentPath {
t.Fatalf("Search() hits = %+v, want only visible subagent %s", hits, visibleSubagentPath)
}
if _, err := searcher.Around(context.Background(), AroundRequest{SessionPath: pendingSubagentPath, MessageIndex: 0}); err == nil {
t.Fatal("Around() cleanup-pending parent subagent error = nil, want rejection")
}
if _, err := searcher.Around(context.Background(), AroundRequest{SessionPath: visibleSubagentPath, MessageIndex: 0}); err != nil {
t.Fatalf("Around() visible subagent error = %v", err)
}
}
func TestAroundRequiresPathUnderHistoryRoots(t *testing.T) {
sessionDir := t.TempDir()
outside := t.TempDir()
path := filepath.Join(outside, "outside.jsonl")
writeSession(t, path, []provider.Message{{Role: provider.RoleUser, Content: "secret"}})
searcher := NewSearcher(Options{SessionDir: sessionDir})
if _, err := searcher.Around(context.Background(), AroundRequest{SessionPath: path, MessageIndex: 0}); err == nil {
t.Fatal("Around() error = nil, want path confinement error")
}
}
func TestAroundRendersNearbyMessages(t *testing.T) {
sessionDir := t.TempDir()
path := filepath.Join(sessionDir, "nearby.jsonl")
writeSession(t, path, []provider.Message{
{Role: provider.RoleUser, Content: "first"},
{Role: provider.RoleAssistant, Content: "second"},
{Role: provider.RoleUser, Content: "third"},
})
searcher := NewSearcher(Options{SessionDir: sessionDir})
msgs, err := searcher.Around(context.Background(), AroundRequest{SessionPath: path, MessageIndex: 1, Before: 1, After: 1})
if err != nil {
t.Fatalf("Around() error = %v", err)
}
if len(msgs) != 3 {
t.Fatalf("Around() returned %d messages, want 3", len(msgs))
}
joined := msgs[0].Text + "\n" + msgs[1].Text + "\n" + msgs[2].Text
for _, want := range []string{"[0 user]", "[1 assistant]", "[2 user]"} {
if !strings.Contains(joined, want) {
t.Fatalf("Around() output missing %q:\n%s", want, joined)
}
}
}
func TestAroundClampsLargeAfterWithoutOverflow(t *testing.T) {
sessionDir := t.TempDir()
path := filepath.Join(sessionDir, "nearby.jsonl")
writeSession(t, path, []provider.Message{
{Role: provider.RoleUser, Content: "first"},
{Role: provider.RoleAssistant, Content: "second"},
})
searcher := NewSearcher(Options{SessionDir: sessionDir})
msgs, err := searcher.Around(context.Background(), AroundRequest{SessionPath: path, MessageIndex: 0, Before: 0, After: maxAround * 10})
if err != nil {
t.Fatalf("Around() error = %v", err)
}
if len(msgs) != 2 {
t.Fatalf("Around() returned %d messages, want 2", len(msgs))
}
if msgs[0].Index != 0 || msgs[1].Index != 1 {
t.Fatalf("Around() message indexes = [%d, %d], want [0, 1]", msgs[0].Index, msgs[1].Index)
}
}
func writeSession(t *testing.T, path string, msgs []provider.Message) {
t.Helper()
sess := agent.NewSession("")
for _, msg := range msgs {
sess.Add(msg)
}
if err := sess.Save(path); err != nil {
t.Fatalf("Save(%s) error = %v", path, err)
}
}
func writeSubagentSession(t *testing.T, sessionDir, ref, parentSession, content string) string {
t.Helper()
dir := filepath.Join(sessionDir, "subagents")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
path := filepath.Join(dir, ref+".jsonl")
writeSession(t, path, []provider.Message{{Role: provider.RoleUser, Content: content}})
meta := agent.SubagentMeta{Ref: ref, ParentSession: parentSession}
b, err := json.Marshal(meta)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, ref+".meta.json"), b, 0o644); err != nil {
t.Fatal(err)
}
return path
}