package multiagent
import (
"context"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/database"
"cyberstrike-ai/internal/project"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/schema"
"go.uber.org/zap"
)
func TestTranscriptSnapshotsPreserveEarlierEvidence(t *testing.T) {
path := filepath.Join(t.TempDir(), "transcript.txt")
first := []adk.Message{schema.UserMessage("first target"), schema.AssistantMessage("first evidence", nil)}
before, err := writeSummarizationTranscriptSnapshot(path, first)
if err != nil {
t.Fatal(err)
}
again, err := writeSummarizationTranscriptSnapshot(path, first)
if err != nil || before != again {
t.Fatalf("snapshot is not idempotent: %s %s %v", before, again, err)
}
after, err := writeSummarizationTranscriptSnapshot(path, []adk.Message{schema.UserMessage("second target")})
if err != nil || before == after {
t.Fatalf("new history reused old snapshot: %v", err)
}
raw, err := os.ReadFile(before)
if err != nil || !strings.Contains(string(raw), "first evidence") {
t.Fatalf("earlier evidence lost: %s %v", raw, err)
}
alias, _ := os.ReadFile(path)
if !strings.Contains(string(alias), "second target") {
t.Fatal("latest alias not updated")
}
stat, _ := os.Stat(before)
if runtime.GOOS != "windows" && stat.Mode().Perm() != 0o600 {
t.Fatalf("snapshot permissions: %v", stat.Mode())
}
files, _ := filepath.Glob(filepath.Join(filepath.Dir(before), "transcript-*.txt"))
if len(files) != 2 {
t.Fatalf("retries duplicated snapshots: %v", files)
}
}
func checkpointTestProject(t *testing.T) (*database.DB, string, string) {
t.Helper()
db, err := database.NewDB(filepath.Join(t.TempDir(), "test.db"), zap.NewNop())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
proj, err := db.CreateProject(&database.Project{Name: "memory"})
if err != nil {
t.Fatal(err)
}
conv, err := db.CreateConversation("test", database.ConversationCreateMeta{ProjectID: proj.ID})
if err != nil {
t.Fatal(err)
}
return db, proj.ID, conv.ID
}
func TestContextCheckpointsSeparateAgentsAndPreserveConfirmedFacts(t *testing.T) {
db, pid, cid := checkpointTestProject(t)
cfg := config.ProjectConfig{Enabled: true}
db.UpsertProjectFact(&database.ProjectFact{ProjectID: pid, FactKey: "finding/original", Category: "finding", Confidence: "confirmed", Body: "original evidence", Summary: "confirmed result", Pinned: true})
main, err := persistSummarizationCheckpoint(db, pid, cid, "main", "first summary", "/old/snapshot", cfg)
if err != nil {
t.Fatal(err)
}
updated, err := persistSummarizationCheckpoint(db, pid, cid, "main", "new summary", "/new/snapshot", cfg)
if err != nil || updated != main {
t.Fatalf("unstable key: %s %s %v", main, updated, err)
}
sub, err := persistSummarizationCheckpoint(db, pid, cid, "sub/scanner", "sub findings", "/sub/snapshot", cfg)
if err != nil || sub == main {
t.Fatalf("subagent overwrites coordinator: %v", err)
}
fact, _ := db.GetProjectFactByKey(pid, main)
if fact.Confidence != "tentative" || fact.Pinned || fact.SourceConversationID != cid || !strings.Contains(fact.Body, "new summary") || !strings.Contains(fact.Body, "/new/snapshot") {
t.Fatalf("invalid checkpoint: %#v", fact)
}
existing, _ := db.GetProjectFactByKey(pid, "finding/original")
if existing.Confidence != "confirmed" || existing.Body != "original evidence" || !existing.Pinned {
t.Fatalf("confirmed fact altered: %#v", existing)
}
facts, err := db.ListProjectFacts(pid, database.ProjectFactListFilter{}, 100, 0)
if err != nil || len(facts) != 3 {
t.Fatalf("checkpoint updates grow fact list: %d %v", len(facts), err)
}
}
func TestContextCheckpointDisabledAndProjectMismatch(t *testing.T) {
db, pid, cid := checkpointTestProject(t)
off := false
for _, cfg := range []config.ProjectConfig{{Enabled: false}, {Enabled: true, ContextCheckpoints: &off}} {
key, err := persistSummarizationCheckpoint(db, pid, cid, "main", "summary", "", cfg)
if err != nil || key != "" {
t.Fatalf("disabled checkpoint wrote data: %s %v", key, err)
}
}
if key, err := persistSummarizationCheckpoint(db, "other-project", cid, "main", "summary", "", config.ProjectConfig{Enabled: true}); err == nil || key != "" {
t.Fatalf("wrong-project write: %s %v", key, err)
}
}
func TestFinalizeMemorySupportsMultipartSummaryAndReportsOnlySuccessfulWrites(t *testing.T) {
db, pid, cid := checkpointTestProject(t)
original := []adk.Message{schema.UserMessage("exact evidence from history")}
summary := &schema.Message{Role: schema.User, UserInputMultiContent: []schema.MessageInputPart{{Type: schema.ChatMessagePartTypeText, Text: "internalkeep important finding"}}}
out := finalizeSummarizationMemory(summary, original, filepath.Join(t.TempDir(), "transcript.txt"), db, pid, cid, "main", config.ProjectConfig{Enabled: true}, nil)
text := out.UserInputMultiContent[0].Text
if strings.Contains(text, "internal") || !strings.Contains(text, filepath.Join("snapshots", "transcript-")) || !strings.Contains(text, "get_project_fact(note/context-") {
t.Fatalf("continuation missing durable references: %s", text)
}
if !strings.Contains(summary.UserInputMultiContent[0].Text, "internal") {
t.Fatal("input summary was mutated")
}
bad := finalizeSummarizationMemory(schema.UserMessage("still usable summary"), original, "", db, "wrong-project", cid, "main", config.ProjectConfig{Enabled: true}, nil)
if strings.Contains(bad.Content, "已保存") || !strings.Contains(bad.Content, "still usable summary") {
t.Fatalf("failed write claimed success or lost summary: %s", bad.Content)
}
}
func TestSummarizationMiddlewaresAutomaticallySaveCheckpoint(t *testing.T) {
for _, native := range []bool{false, true} {
t.Run(fmt.Sprintf("native=%v", native), func(t *testing.T) {
db, pid, cid := checkpointTestProject(t)
_, err := db.UpsertProjectFact(&database.ProjectFact{ProjectID: pid, FactKey: "note/context-other", SourceConversationID: "different-conversation", Summary: "FOREIGN_PROGRESS", Body: "FOREIGN_COOKIE", Pinned: true})
if err != nil {
t.Fatal(err)
}
cfg := &config.Config{Project: config.ProjectConfig{Enabled: true}}
cfg.Database.Path = filepath.Join(t.TempDir(), "runtime.db")
cfg.OpenAI.Model = "gpt-4o"
cfg.OpenAI.MaxTotalTokens = 5000
emit := false
mwCfg := &config.MultiAgentEinoMiddlewareConfig{SummarizationEmitInternalEvents: &emit, SummarizationOutputReserveTokens: 1024}
index, err := project.BuildFactIndexBlock(db, pid, cfg.Project)
if err != nil {
t.Fatal(err)
}
originals := []adk.Message{
schema.SystemMessage("system root\n" + index),
schema.UserMessage("precise original evidence\n" + strings.Repeat("old scan output ", 12000)),
schema.AssistantMessage("found endpoint", nil),
schema.UserMessage("continue from confirmed endpoint"),
}
const summary = "do not persist scratchpadImportant endpoint /api/check; next validate response"
var after []adk.Message
ctx := context.Background()
if native {
m := &capturingAgenticChatModel{output: agenticAssistantTextMessage(summary)}
mw, err := newEinoAgenticSummarizationMiddleware(ctx, m, cfg, mwCfg, cid, db, pid, nil)
if err != nil {
t.Fatal(err)
}
_, state, err := mw.BeforeModelRewriteState(ctx, &adk.TypedChatModelAgentState[*schema.AgenticMessage]{Messages: EinoMessagesToAgentic(originals)}, nil)
if err != nil {
t.Fatal(err)
}
after = AgenticMessagesToEino(state.Messages)
} else {
m := &capturingClassicChatModel{output: &schema.Message{Role: schema.Assistant, Content: summary, ResponseMeta: &schema.ResponseMeta{FinishReason: "stop"}}}
mw, err := newEinoSummarizationMiddleware(ctx, m, cfg, mwCfg, cid, db, pid, nil)
if err != nil {
t.Fatal(err)
}
_, state, err := mw.BeforeModelRewriteState(ctx, &adk.ChatModelAgentState{Messages: originals}, nil)
if err != nil {
t.Fatal(err)
}
after = state.Messages
}
facts, err := db.ListProjectFacts(pid, database.ProjectFactListFilter{}, 100, 0)
if err != nil || len(facts) != 2 {
t.Fatalf("automatic checkpoint missing: %v %#v", err, facts)
}
var checkpoint *database.ProjectFact
for _, f := range facts {
if f.SourceConversationID == cid {
checkpoint = f
}
}
if checkpoint == nil || !strings.Contains(checkpoint.Body, "/api/check") || strings.Contains(checkpoint.Body, "scratchpad") {
t.Fatalf("wrong working memory: %#v", checkpoint)
}
joined := joinClassicMessageContent(after)
if !strings.Contains(joined, checkpoint.FactKey) || strings.Contains(joined, "(暂无事实)") || strings.Contains(joined, "FOREIGN_PROGRESS") || strings.Contains(joined, "note/context-other") {
t.Fatalf("live index did not refresh after save: %s", joined)
}
snapshots, err := filepath.Glob(filepath.Join(filepath.Dir(cfg.Database.Path), "conversation_artifacts", cid, "summarization", "snapshots", "*.txt"))
if err != nil || len(snapshots) != 1 {
t.Fatalf("one compaction should reuse one original transcript across preflight/finalize/callback: %v %v", snapshots, err)
}
})
}
}
func TestCheckpointIndexLabelStaysShortWithLargeSummarySetting(t *testing.T) {
label := checkpointSummaryLabel("main", "## findings\n- Important endpoint /api/check\n- "+strings.Repeat("raw output ", 1000), 24000)
if len([]rune(label)) > 200 || !strings.Contains(label, "/api/check") {
t.Fatalf("checkpoint reinjects long history or loses key hint: %s", label)
}
}
func TestSuccessfulEmptyFactIndexRemovesOldBlockButRetainsLedger(t *testing.T) {
db, pid, cid := checkpointTestProject(t)
if _, err := db.UpsertProjectFact(&database.ProjectFact{ProjectID: pid, FactKey: "finding/test", Summary: "OLD_INDEX_CONTENT"}); err != nil {
t.Fatal(err)
}
index, err := project.BuildFactIndexBlock(db, pid, config.ProjectConfig{Enabled: true}, cid)
if err != nil {
t.Fatal(err)
}
ledger := buildOriginalUserIntentLedgerMessage([]adk.Message{schema.UserMessage("ONLY_AUTHORIZED_TARGET")}, 96000, 16000)
msgs := mergeMessageIntoLeadingSystem([]adk.Message{schema.SystemMessage("KEEP_CURRENT_SYSTEM\n" + index + "\nKEEP_SUFFIX")}, ledger)
out := refreshFactIndexInMessages(msgs, db, pid, config.ProjectConfig{Enabled: true, FactIndexMaxRunes: 30}, nil, cid)
if strings.Contains(out[0].Content, project.FactIndexSectionStartMarker) || strings.Contains(out[0].Content, "OLD_INDEX_CONTENT") || !strings.Contains(out[0].Content, "KEEP_CURRENT_SYSTEM") || !strings.Contains(out[0].Content, "KEEP_SUFFIX") {
t.Fatalf("empty build retained index or removed other context: %s", out[0].Content)
}
got := buildOriginalUserIntentLedgerMessage(out, 96000, 16000)
if got == nil || !strings.Contains(got.Content, "ONLY_AUTHORIZED_TARGET") {
t.Fatal("ledger lost on index removal")
}
// A failed build is different: retain the valid prior block.
failed := refreshFactIndexInMessages(msgs, db, "missing-project", config.ProjectConfig{Enabled: true, FactIndexMaxRunes: 30}, nil, cid)
if failed[0].Content != msgs[0].Content {
t.Fatal("database error discarded old context")
}
if !strings.Contains(msgs[0].Content, "OLD_INDEX_CONTENT") {
t.Fatal("input message mutated")
}
}
func TestCheckpointLabelUsesExistingEvidenceCluesWithoutChangingSummary(t *testing.T) {
const summary = "## 1. 授权范围与约束\n- authorized.example\n- COOKIE_SECRET\n- user method\n- user todo\n## 7. 当前进度、策略决策与下一步\n- progress\n## 8. 原始证据检索线索(追加)\n- authorized.example /login request Cookie clue\n- response 403 exact error\n"
label := checkpointSummaryLabel("main", summary, 10000)
if !strings.Contains(label, "/login") || !strings.Contains(label, "403") || strings.Contains(label, "COOKIE_SECRET") || strings.Contains(label, "user method") || len([]rune(label)) > 200 {
t.Fatalf("unhelpful or excessive index label: %s", label)
}
db, pid, cid := checkpointTestProject(t)
key, err := persistSummarizationCheckpoint(db, pid, cid, "main", summary, "", config.ProjectConfig{Enabled: true})
if err != nil {
t.Fatal(err)
}
fact, err := db.GetProjectFactByKey(pid, key)
if err != nil {
t.Fatal(err)
}
if !strings.HasSuffix(fact.Body, summary) {
t.Fatal("index label changed original summary body")
}
fallback := checkpointSummaryLabel("main", "## 1. 授权范围\n- authorized.example\n## 8. 原始证据检索线索(追加)\n- 无", 1000)
if !strings.Contains(fallback, "authorized.example") {
t.Fatalf("empty-clue legacy fallback lost: %s", fallback)
}
}