package agent import ( "context" "encoding/json" "errors" "path/filepath" "reasonix/internal/state/sessionstore" "strings" "testing" "reasonix/internal/base/testenv" "reasonix/internal/contract/event" "reasonix/internal/contract/provider" "reasonix/internal/contract/tool" ) func TestProjectionValidRejectsEditedPrefix(t *testing.T) { msgs := []provider.Message{ {Role: provider.RoleSystem, Content: "sys"}, {Role: provider.RoleUser, Content: "task-v1"}, {Role: provider.RoleAssistant, Content: "done"}, {Role: provider.RoleUser, Content: "next"}, } st := sessionstore.CompactionState{ TranscriptVersion: 2, PromptCacheKey: "ws|sess|model", Projection: sessionstore.ContextProjection{ Messages: []provider.Message{ {Role: provider.RoleSystem, Content: "sys"}, {Role: provider.RoleUser, Content: "summary"}, }, TranscriptVersion: 2, CoveredCount: 3, CoveredPrefixHash: sessionstore.CoveredPrefixHash(msgs, 3), }, } if !projectionValid(st, msgs, "ws|sess|model", nil) { t.Fatal("expected valid projection for matching prefix") } // Append-only growth still valid. grown := append(append([]provider.Message(nil), msgs...), provider.Message{Role: provider.RoleAssistant, Content: "more"}) if !projectionValid(st, grown, "ws|sess|model", nil) { t.Fatal("append-only growth should keep projection valid") } // Prefix edit invalidates. edited := append([]provider.Message(nil), msgs...) edited[1].Content = "task-EDITED" if projectionValid(st, edited, "ws|sess|model", nil) { t.Fatal("edited covered prefix must invalidate projection") } } func TestProjectionValidRejectsCacheKeyMismatch(t *testing.T) { msgs := []provider.Message{ {Role: provider.RoleSystem, Content: "sys"}, {Role: provider.RoleUser, Content: "task"}, } hash := sessionstore.CoveredPrefixHash(msgs, 2) st := sessionstore.CompactionState{ TranscriptVersion: 1, PromptCacheKey: "ws|sess|model-a", Projection: sessionstore.ContextProjection{ Messages: []provider.Message{{Role: provider.RoleSystem, Content: "sys"}}, CoveredCount: 2, CoveredPrefixHash: hash, TranscriptVersion: 1, }, } if projectionValid(st, msgs, "ws|sess|model-b", nil) { t.Fatal("model/lineage key mismatch must invalidate projection") } if !projectionValid(st, msgs, "ws|sess|model-a", nil) { t.Fatal("matching key should be valid") } // Fail closed: blank stored key is rejected when current key is known. st.PromptCacheKey = "" if projectionValid(st, msgs, "ws|sess|model-a", nil) { t.Fatal("missing sidecar cache key must invalidate when lineage is known") } // Missing prefix hash is always rejected. st.PromptCacheKey = "ws|sess|model-a" st.Projection.CoveredPrefixHash = "" if projectionValid(st, msgs, "ws|sess|model-a", nil) { t.Fatal("missing CoveredPrefixHash must invalidate projection") } } func TestCoveredPrefixHashIncludesProviderVisibleFields(t *testing.T) { base := []provider.Message{{ Role: provider.RoleAssistant, Content: "answer", ReasoningContent: "think", ReasoningID: "rid-1", ReasoningStatus: "completed", ReasoningSignature: "sig-1", Images: []string{"data:image/png;base64,AAA"}, ToolCalls: []provider.ToolCall{{ ID: "c1", Name: "f", Arguments: `{}`, ThoughtSignature: "ts-1", }}, ResponsesItems: []json.RawMessage{json.RawMessage(`{"type":"web_search_call"}`)}, }} h1 := sessionstore.CoveredPrefixHash(base, 1) if h1 == "" { t.Fatal("empty fingerprint") } // Each provider-visible field change must move the hash. cases := []struct { name string mut func([]provider.Message) }{ {"images", func(m []provider.Message) { m[0].Images = []string{"data:image/png;base64,BBB"} }}, {"reasoning_id", func(m []provider.Message) { m[0].ReasoningID = "rid-2" }}, {"reasoning_status", func(m []provider.Message) { m[0].ReasoningStatus = "in_progress" }}, {"reasoning_signature", func(m []provider.Message) { m[0].ReasoningSignature = "sig-2" }}, {"thought_signature", func(m []provider.Message) { m[0].ToolCalls[0].ThoughtSignature = "ts-2" }}, {"responses_items", func(m []provider.Message) { m[0].ResponsesItems = []json.RawMessage{json.RawMessage(`{"type":"other"}`)} }}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { mutated := []provider.Message{base[0]} mutated[0].ToolCalls = append([]provider.ToolCall(nil), base[0].ToolCalls...) mutated[0].Images = append([]string(nil), base[0].Images...) mutated[0].ResponsesItems = append([]json.RawMessage(nil), base[0].ResponsesItems...) tc.mut(mutated) if sessionstore.CoveredPrefixHash(mutated, 1) == h1 { t.Fatalf("%s change did not alter sessionstore.CoveredPrefixHash", tc.name) } }) } } func TestLoadProjectionSidecarRebindsMatchingContentAcrossLineage(t *testing.T) { dir := testenv.TempDir(t) path := filepath.Join(dir, "s.jsonl") msgs := []provider.Message{ {Role: provider.RoleSystem, Content: "sys"}, {Role: provider.RoleUser, Content: "task"}, } hash := sessionstore.CoveredPrefixHash(msgs, 2) if err := sessionstore.SaveCompactionState(path, sessionstore.CompactionState{ SchemaVersion: sessionstore.CompactionStateSchemaV1, PromptCacheKey: "ws|s|other-model", TranscriptVersion: 1, Projection: sessionstore.ContextProjection{ Messages: []provider.Message{{Role: provider.RoleSystem, Content: "sys summary"}}, CoveredCount: 2, CoveredPrefixHash: hash, }, }); err != nil { t.Fatal(err) } sess := sessionstore.NewSession("sys") sess.Add(provider.Message{Role: provider.RoleUser, Content: "task"}) a := New(nil, nil, sess, Options{ SessionPath: path, WorkspaceID: "ws", ModelRef: "this-model", }, event.Discard) // New() already called LoadProjectionSidecar; the projection body matches // the canonical covered prefix, so it must be rebound to the current key // instead of being dropped (upgrade / model-switch path). if len(a.sess.win.compactionState.Projection.Messages) == 0 { t.Fatal("matching projection body was dropped on lineage change") } wantKey := promptCacheKey("ws", sessionstore.BranchID(path), "this-model") if a.sess.win.compactionState.PromptCacheKey != wantKey { t.Fatalf("PromptCacheKey = %q, want %q", a.sess.win.compactionState.PromptCacheKey, wantKey) } if a.sess.win.checkpointState != "restored" { t.Fatalf("checkpointState = %q, want restored", a.sess.win.checkpointState) } // The rebind must be persisted so the next launch does not re-downgrade. disk, ok, err := sessionstore.LoadCompactionState(path) if err != nil || !ok { t.Fatalf("sidecar should remain on disk: ok=%v err=%v", ok, err) } if disk.PromptCacheKey != wantKey { t.Fatalf("persisted PromptCacheKey = %q, want %q", disk.PromptCacheKey, wantKey) } } func TestLoadProjectionSidecarDropsForeignCacheKey(t *testing.T) { dir := testenv.TempDir(t) path := filepath.Join(dir, "s.jsonl") msgs := []provider.Message{{Role: provider.RoleSystem, Content: "sys"}} // Content validation must fail despite a model-only key change: lineage // rebinding cannot resurrect a projection whose canonical prefix differs. foreign := []provider.Message{{Role: provider.RoleSystem, Content: "sys-old"}} if err := sessionstore.SaveCompactionState(path, sessionstore.CompactionState{ SchemaVersion: sessionstore.CompactionStateSchemaV1, PromptCacheKey: "ws|s|other-model", Projection: sessionstore.ContextProjection{ Messages: msgs, CoveredCount: 1, CoveredPrefixHash: sessionstore.CoveredPrefixHash(foreign, 1), }, }); err != nil { t.Fatal(err) } a := New(nil, nil, sessionstore.NewSession("sys"), Options{ SessionPath: path, WorkspaceID: "ws", ModelRef: "this-model", }, event.Discard) // New() already called LoadProjectionSidecar; mismatched content must drop // the projection body and keep the sidecar file for the other model. if len(a.sess.win.compactionState.Projection.Messages) != 0 { t.Fatalf("foreign projection loaded: %+v", a.sess.win.compactionState.Projection) } if _, ok, err := sessionstore.LoadCompactionState(path); err != nil || !ok { t.Fatalf("sidecar should remain on disk: ok=%v err=%v", ok, err) } } func TestForceThresholdNoopReturnsCompactionRequired(t *testing.T) { // Huge tool result is entirely in the recent tail → no fold region, but // estimate exceeds force; preflight must refuse (not mid-turn). huge := strings.Repeat("word ", 5000) sess := &sessionstore.Session{Messages: []provider.Message{ {Role: provider.RoleSystem, Content: "sys"}, {Role: provider.RoleUser, Content: "task"}, {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "1", Name: "read", Arguments: "{}"}}}, {Role: provider.RoleTool, ToolCallID: "1", Name: "read", Content: huge}, }} a := New(&fakeProvider{reply: "unused"}, tool.NewRegistry(), sess, Options{ ContextWindow: 200, CompactRatio: 0.5, }, event.Discard) _, err := a.window().contextManager().Prepare(context.Background(), ContextPreparePolicy{Trigger: CompactionTriggerPressure}) if err == nil { t.Fatal("expected ErrCompactionRequired when force threshold has no fold region") } if !errors.Is(err, ErrCompactionRequired) { t.Fatalf("err = %v, want ErrCompactionRequired", err) } } func TestCompactInstallsCoveredPrefixHash(t *testing.T) { fp := &fakeProvider{reply: "digest"} sess := sessionstore.NewSession("sys") for range 8 { sess.Add(provider.Message{Role: provider.RoleUser, Content: strings.Repeat("u", 80)}) sess.Add(provider.Message{Role: provider.RoleAssistant, Content: strings.Repeat("a", 600)}) } dir := testenv.TempDir(t) path := filepath.Join(dir, "s.jsonl") a := New(fp, tool.NewRegistry(), sess, Options{ ContextWindow: 2000, ArchiveDir: dir, SessionPath: path, WorkspaceID: "ws", ModelRef: "m", }, event.Discard) if _, err := a.CompactNow(context.Background(), CompactRequest{}); err != nil { t.Fatal(err) } st := a.sess.win.compactionState if st.Projection.CoveredPrefixHash == "" { t.Fatal("CoveredPrefixHash not set") } if st.PromptCacheKey != promptCacheKey("ws", sessionstore.BranchID(path), "m") { t.Fatalf("PromptCacheKey = %q", st.PromptCacheKey) } msgs, _ := sess.SnapshotMessagesVersion() if !projectionValid(st, msgs, st.PromptCacheKey, nil) { t.Fatal("fresh projection should validate") } } func TestLoadProjectionSidecarSaysWhyAFoldWasNotReused(t *testing.T) { cases := []struct { name string key string covers []provider.Message want ProjectionDrop }{ {"history rewritten under the same lineage", "", []provider.Message{{Role: provider.RoleSystem, Content: "sys-old"}}, ProjectionDropHistory}, {"history rewritten under another lineage", "ws|s|other-model", []provider.Message{{Role: provider.RoleSystem, Content: "sys-old"}}, ProjectionDropLineage}, {"still valid", "", []provider.Message{{Role: provider.RoleSystem, Content: "sys"}}, ProjectionDropNone}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { path := filepath.Join(testenv.TempDir(t), "s.jsonl") key := tc.key if key == "" { key = promptCacheKey("ws", sessionstore.BranchID(path), "this-model") } if err := sessionstore.SaveCompactionState(path, sessionstore.CompactionState{ SchemaVersion: sessionstore.CompactionStateSchemaV1, PromptCacheKey: key, Projection: sessionstore.ContextProjection{ Messages: []provider.Message{{Role: provider.RoleSystem, Content: "summary"}}, CoveredCount: 1, CoveredPrefixHash: sessionstore.CoveredPrefixHash(tc.covers, 1), }, }); err != nil { t.Fatal(err) } a := New(nil, nil, sessionstore.NewSession("sys"), Options{SessionPath: path, WorkspaceID: "ws", ModelRef: "this-model"}, event.Discard) if got := a.ProjectionDropped(); got != tc.want { t.Fatalf("ProjectionDropped = %q, want %q", got, tc.want) } }) } } func TestProjectionDroppedIsClearedByANewBinding(t *testing.T) { path := filepath.Join(testenv.TempDir(t), "s.jsonl") if err := sessionstore.SaveCompactionState(path, sessionstore.CompactionState{ SchemaVersion: sessionstore.CompactionStateSchemaV1, PromptCacheKey: promptCacheKey("ws", sessionstore.BranchID(path), "m"), Projection: sessionstore.ContextProjection{ Messages: []provider.Message{{Role: provider.RoleSystem, Content: "summary"}}, CoveredCount: 1, CoveredPrefixHash: sessionstore.CoveredPrefixHash([]provider.Message{{Role: provider.RoleSystem, Content: "other"}}, 1), }, }); err != nil { t.Fatal(err) } a := New(nil, nil, sessionstore.NewSession("sys"), Options{SessionPath: path, WorkspaceID: "ws", ModelRef: "m"}, event.Discard) if a.ProjectionDropped() != ProjectionDropHistory { t.Fatalf("setup: ProjectionDropped = %q", a.ProjectionDropped()) } a.BindSessionPath(filepath.Join(testenv.TempDir(t), "fresh.jsonl"), true) if got := a.ProjectionDropped(); got != ProjectionDropNone { t.Fatalf("a session with no sidecar still reports %q", got) } }