返回 DeepSeek-Reasonix
projection_restart_test.go
根目录 / internal / agent / projection_restart_test.go
1 package agent
2
3 import (
4 "path/filepath"
5 "testing"
6
7 "reasonix/internal/event"
8 "reasonix/internal/provider"
9 "reasonix/internal/tool"
10 )
11
12 func restartProjectionState(path string, msgs []provider.Message, version uint64, covered int) CompactionState {
13 key := promptCacheKey("workspace", BranchID(path), "provider/model")
14 return CompactionState{
15 SchemaVersion: compactionStateSchemaCurrent,
16 TranscriptVersion: version,
17 PromptCacheKey: key,
18 Projection: ContextProjection{
19 Messages: []provider.Message{
20 {Role: provider.RoleSystem, Content: "system"},
21 {Role: provider.RoleUser, Content: "[compacted context]"},
22 },
23 TranscriptVersion: version,
24 ProjectionVersion: 1,
25 CoveredCount: covered,
26 CoveredPrefixHash: coveredPrefixHash(msgs, covered),
27 },
28 }
29 }
30
31 func reopenProjectionAgent(t *testing.T, path string) *Agent {
32 t.Helper()
33 loaded, err := LoadSession(path)
34 if err != nil {
35 t.Fatalf("LoadSession: %v", err)
36 }
37 if got := loaded.TranscriptVersion(); got != 0 {
38 t.Fatalf("loaded transcript version = %d, want process-local reset to 0", got)
39 }
40 return New(nil, tool.NewRegistry(), loaded, Options{
41 SessionPath: path,
42 WorkspaceID: "workspace",
43 ModelRef: "provider/model",
44 }, event.Discard)
45 }
46
47 func TestProjectionRestoresAfterRestartWithResetTranscriptVersion(t *testing.T) {
48 path := filepath.Join(t.TempDir(), "session.jsonl")
49 sess := NewSession("system")
50 sess.Add(provider.Message{Role: provider.RoleUser, Content: "task"})
51 sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "done"})
52 msgs, version := sess.snapshotMessagesVersion()
53 if version == 0 {
54 t.Fatal("fixture must compact at a non-zero transcript version")
55 }
56 if err := sess.SaveSnapshot(path); err != nil {
57 t.Fatalf("SaveSnapshot: %v", err)
58 }
59 if err := SaveCompactionState(path, restartProjectionState(path, msgs, version, len(msgs))); err != nil {
60 t.Fatalf("SaveCompactionState: %v", err)
61 }
62
63 reopened := reopenProjectionAgent(t, path)
64 if got := reopened.ContextMaintenanceSnapshot().CheckpointState; got != "restored" {
65 t.Fatalf("checkpoint state = %q, want restored", got)
66 }
67 if got := len(reopened.modelVisibleMessages()); got != 2 {
68 t.Fatalf("visible messages = %d, want compacted projection", got)
69 }
70 }
71
72 func TestProjectionRestoresAfterRestartNormalization(t *testing.T) {
73 path := filepath.Join(t.TempDir(), "session.jsonl")
74 sess := NewSession("system")
75 sess.Add(provider.Message{Role: provider.RoleUser, Content: "inspect"})
76 sess.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{
77 ID: "call-1", Arguments: `{"path":"main.go"}`,
78 }}})
79 sess.Add(provider.Message{
80 Role: provider.RoleTool, ToolCallID: "call-1", Name: "read_file", Content: "package main",
81 })
82 sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "first result"})
83 covered, version := sess.snapshotMessagesVersion()
84 if version == 0 {
85 t.Fatal("fixture must compact at a non-zero transcript version")
86 }
87 state := restartProjectionState(path, covered, version, len(covered))
88 state.Projection.CoveredPrefixHash = legacyCoveredPrefixHash(covered, len(covered))
89
90 // The covered prefix contains an empty tool-call name that LoadSession repairs
91 // from its result. Projection identity must normalize it on both sides of the
92 // restart even when the live transcript grew after compaction.
93 sess.Add(provider.Message{Role: provider.RoleUser, Content: "continue"})
94 sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "second result"})
95 if err := sess.SaveSnapshot(path); err != nil {
96 t.Fatalf("SaveSnapshot: %v", err)
97 }
98 if err := SaveCompactionState(path, state); err != nil {
99 t.Fatalf("SaveCompactionState: %v", err)
100 }
101
102 loaded, err := LoadSession(path)
103 if err != nil {
104 t.Fatalf("LoadSession: %v", err)
105 }
106 if got := loaded.Snapshot()[2].ToolCalls[0].Name; got != "read_file" {
107 t.Fatalf("load-time tool-call normalization = %q, want read_file", got)
108 }
109 // Desktop resume wraps the loaded session to refresh the system prompt.
110 // The exact pre-repair disk view must survive that wrapper for safe sidecar
111 // migration.
112 loaded = loaded.CloneWithMessages(loaded.Snapshot())
113 reopened := New(nil, tool.NewRegistry(), loaded, Options{
114 SessionPath: path,
115 WorkspaceID: "workspace",
116 ModelRef: "provider/model",
117 }, event.Discard)
118 if got := reopened.ContextMaintenanceSnapshot().CheckpointState; got != "restored" {
119 t.Fatalf("checkpoint state = %q, want restored", got)
120 }
121 if got := len(reopened.modelVisibleMessages()); got != 4 {
122 t.Fatalf("visible messages = %d, want projection plus two-message live tail", got)
123 }
124 persisted, ok, err := LoadCompactionState(path)
125 if err != nil || !ok {
126 t.Fatalf("LoadCompactionState after migration: ok=%v err=%v", ok, err)
127 }
128 if got, want := persisted.Projection.CoveredPrefixHash, coveredPrefixHash(loaded.Snapshot(), len(covered)); got != want {
129 t.Fatalf("migrated covered prefix hash = %q, want %q", got, want)
130 }
131 }
132
133 func TestLegacyProjectionHashMigrationRejectsRealPrefixChange(t *testing.T) {
134 preRepair := []provider.Message{
135 {Role: provider.RoleSystem, Content: "system-v1"},
136 {Role: provider.RoleUser, Content: "inspect"},
137 {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "call-1", Arguments: `{}`}}},
138 {Role: provider.RoleTool, ToolCallID: "call-1", Name: "read_file", Content: "result"},
139 }
140 current := NormalizeSession(preRepair)
141 current = append([]provider.Message(nil), current...)
142 current[0].Content = "system-v2"
143 state := CompactionState{Projection: ContextProjection{
144 Messages: []provider.Message{{Role: provider.RoleUser, Content: "summary"}},
145 CoveredCount: len(preRepair),
146 CoveredPrefixHash: legacyCoveredPrefixHash(preRepair, len(preRepair)),
147 }}
148
149 if migrateLegacyCoveredPrefixHash(&state, current, preRepair) {
150 t.Fatal("legacy hash migration accepted a changed provider-visible system prompt")
151 }
152 if projectionContentValid(state, current) {
153 t.Fatal("changed prefix unexpectedly validated the legacy projection")
154 }
155 }
156
156 lines GO