返回 DeepSeek-Reasonix
projection_valid_test.go
根目录 / internal / agent / projection_valid_test.go
1 package agent
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "path/filepath"
8 "strings"
9 "testing"
10
11 "reasonix/internal/event"
12 "reasonix/internal/provider"
13 "reasonix/internal/tool"
14 )
15
16 func TestProjectionValidRejectsEditedPrefix(t *testing.T) {
17 msgs := []provider.Message{
18 {Role: provider.RoleSystem, Content: "sys"},
19 {Role: provider.RoleUser, Content: "task-v1"},
20 {Role: provider.RoleAssistant, Content: "done"},
21 {Role: provider.RoleUser, Content: "next"},
22 }
23 st := CompactionState{
24 TranscriptVersion: 2,
25 PromptCacheKey: "ws|sess|model",
26 Projection: ContextProjection{
27 Messages: []provider.Message{
28 {Role: provider.RoleSystem, Content: "sys"},
29 {Role: provider.RoleUser, Content: "summary"},
30 },
31 TranscriptVersion: 2,
32 CoveredCount: 3,
33 CoveredPrefixHash: coveredPrefixHash(msgs, 3),
34 },
35 }
36 if !projectionValid(st, msgs, "ws|sess|model") {
37 t.Fatal("expected valid projection for matching prefix")
38 }
39 // Append-only growth still valid.
40 grown := append(append([]provider.Message(nil), msgs...), provider.Message{Role: provider.RoleAssistant, Content: "more"})
41 if !projectionValid(st, grown, "ws|sess|model") {
42 t.Fatal("append-only growth should keep projection valid")
43 }
44 // Prefix edit invalidates.
45 edited := append([]provider.Message(nil), msgs...)
46 edited[1].Content = "task-EDITED"
47 if projectionValid(st, edited, "ws|sess|model") {
48 t.Fatal("edited covered prefix must invalidate projection")
49 }
50 }
51
52 func TestProjectionSurvivesDynamicSystemRefresh(t *testing.T) {
53 canonical := []provider.Message{
54 {Role: provider.RoleSystem, Content: "system-v1"},
55 {Role: provider.RoleUser, Content: "task"},
56 {Role: provider.RoleAssistant, Content: "done"},
57 {Role: provider.RoleUser, Content: "next"},
58 }
59 const key = "ws|session|model"
60 st := CompactionState{
61 TranscriptVersion: 4,
62 PromptCacheKey: key,
63 Projection: ContextProjection{
64 Messages: []provider.Message{
65 {Role: provider.RoleSystem, Content: "system-v1"},
66 formatSummaryMessage("earlier task completed"),
67 },
68 TranscriptVersion: 4,
69 CoveredCount: 3,
70 CoveredPrefixHash: coveredPrefixHash(canonical, 3),
71 },
72 }
73
74 if !projectionValid(st, canonical, key) {
75 t.Fatal("matching projection should be valid")
76 }
77
78 refreshed := append([]provider.Message(nil), canonical...)
79 refreshed[0].Content = "system-v2"
80 if !projectionValid(st, refreshed, key) {
81 t.Fatal("dynamic system-only refresh invalidated the projection")
82 }
83 visible := modelVisibleFromProjection(st.Projection, refreshed)
84 if len(visible) == 0 || visible[0].Content != "system-v2" {
85 t.Fatalf("visible system = %+v, want refreshed system-v2", visible)
86 }
87 if len(visible) < 2 || !isCompactionSummary(visible[1]) {
88 t.Fatalf("projection summary was not retained: %+v", visible)
89 }
90
91 edited := append([]provider.Message(nil), refreshed...)
92 edited[1].Content = "different task"
93 if projectionValid(st, edited, key) {
94 t.Fatal("covered user edit was mistaken for a system-only refresh")
95 }
96 }
97
98 func TestLoadProjectionSidecarRestoresAfterDynamicSystemRefresh(t *testing.T) {
99 dir := t.TempDir()
100 path := filepath.Join(dir, "s.jsonl")
101 original := []provider.Message{
102 {Role: provider.RoleSystem, Content: "system-v1"},
103 {Role: provider.RoleUser, Content: "task"},
104 {Role: provider.RoleAssistant, Content: "done"},
105 {Role: provider.RoleUser, Content: "next"},
106 }
107 key := promptCacheKey("ws", BranchID(path), "m")
108 if err := SaveCompactionState(path, CompactionState{
109 PromptCacheKey: key,
110 Projection: ContextProjection{
111 Messages: []provider.Message{
112 {Role: provider.RoleSystem, Content: "system-v1"},
113 formatSummaryMessage("earlier task completed"),
114 },
115 CoveredCount: 3,
116 CoveredPrefixHash: coveredPrefixHash(original, 3),
117 },
118 }); err != nil {
119 t.Fatal(err)
120 }
121
122 sess := NewSession("system-v2")
123 sess.Add(provider.Message{Role: provider.RoleUser, Content: "task"})
124 sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "done"})
125 sess.Add(provider.Message{Role: provider.RoleUser, Content: "next"})
126 a := New(nil, nil, sess, Options{
127 SessionPath: path,
128 WorkspaceID: "ws",
129 ModelRef: "m",
130 }, event.Discard)
131
132 if a.sess.checkpointState != "restored" {
133 t.Fatalf("checkpointState = %q, want restored", a.sess.checkpointState)
134 }
135 visible := a.modelVisibleMessages()
136 if len(visible) != 3 || visible[0].Content != "system-v2" || !isCompactionSummary(visible[1]) || visible[2].Content != "next" {
137 t.Fatalf("restored visible projection = %+v", visible)
138 }
139 }
140
141 func TestProjectionValidRejectsCacheKeyMismatch(t *testing.T) {
142 msgs := []provider.Message{
143 {Role: provider.RoleSystem, Content: "sys"},
144 {Role: provider.RoleUser, Content: "task"},
145 }
146 hash := coveredPrefixHash(msgs, 2)
147 st := CompactionState{
148 TranscriptVersion: 1,
149 PromptCacheKey: "ws|sess|model-a",
150 Projection: ContextProjection{
151 Messages: []provider.Message{{Role: provider.RoleSystem, Content: "sys"}},
152 CoveredCount: 2,
153 CoveredPrefixHash: hash,
154 TranscriptVersion: 1,
155 },
156 }
157 if projectionValid(st, msgs, "ws|sess|model-b") {
158 t.Fatal("model/lineage key mismatch must invalidate projection")
159 }
160 if !projectionValid(st, msgs, "ws|sess|model-a") {
161 t.Fatal("matching key should be valid")
162 }
163 // Fail closed: blank stored key is rejected when current key is known.
164 st.PromptCacheKey = ""
165 if projectionValid(st, msgs, "ws|sess|model-a") {
166 t.Fatal("missing sidecar cache key must invalidate when lineage is known")
167 }
168 // Missing prefix hash is always rejected.
169 st.PromptCacheKey = "ws|sess|model-a"
170 st.Projection.CoveredPrefixHash = ""
171 if projectionValid(st, msgs, "ws|sess|model-a") {
172 t.Fatal("missing CoveredPrefixHash must invalidate projection")
173 }
174 }
175
176 func TestCoveredPrefixHashIncludesProviderVisibleFields(t *testing.T) {
177 base := []provider.Message{{
178 Role: provider.RoleAssistant,
179 Content: "answer",
180 ReasoningContent: "think",
181 ReasoningID: "rid-1",
182 ReasoningStatus: "completed",
183 ReasoningSignature: "sig-1",
184 Images: []string{"data:image/png;base64,AAA"},
185 ToolCalls: []provider.ToolCall{{
186 ID: "c1", Name: "f", Arguments: `{}`, ThoughtSignature: "ts-1",
187 }},
188 ResponsesItems: []json.RawMessage{json.RawMessage(`{"type":"web_search_call"}`)},
189 }}
190 h1 := coveredPrefixHash(base, 1)
191 if h1 == "" {
192 t.Fatal("empty fingerprint")
193 }
194 // Each provider-visible field change must move the hash.
195 cases := []struct {
196 name string
197 mut func([]provider.Message)
198 }{
199 {"images", func(m []provider.Message) { m[0].Images = []string{"data:image/png;base64,BBB"} }},
200 {"reasoning_id", func(m []provider.Message) { m[0].ReasoningID = "rid-2" }},
201 {"reasoning_status", func(m []provider.Message) { m[0].ReasoningStatus = "in_progress" }},
202 {"reasoning_signature", func(m []provider.Message) { m[0].ReasoningSignature = "sig-2" }},
203 {"thought_signature", func(m []provider.Message) { m[0].ToolCalls[0].ThoughtSignature = "ts-2" }},
204 {"responses_items", func(m []provider.Message) {
205 m[0].ResponsesItems = []json.RawMessage{json.RawMessage(`{"type":"other"}`)}
206 }},
207 }
208 for _, tc := range cases {
209 t.Run(tc.name, func(t *testing.T) {
210 mutated := []provider.Message{base[0]}
211 mutated[0].ToolCalls = append([]provider.ToolCall(nil), base[0].ToolCalls...)
212 mutated[0].Images = append([]string(nil), base[0].Images...)
213 mutated[0].ResponsesItems = append([]json.RawMessage(nil), base[0].ResponsesItems...)
214 tc.mut(mutated)
215 if coveredPrefixHash(mutated, 1) == h1 {
216 t.Fatalf("%s change did not alter coveredPrefixHash", tc.name)
217 }
218 })
219 }
220 }
221
222 func TestLoadProjectionSidecarRebindsMatchingContentAcrossLineage(t *testing.T) {
223 dir := t.TempDir()
224 path := filepath.Join(dir, "s.jsonl")
225 msgs := []provider.Message{
226 {Role: provider.RoleSystem, Content: "sys"},
227 {Role: provider.RoleUser, Content: "task"},
228 }
229 hash := coveredPrefixHash(msgs, 2)
230 if err := SaveCompactionState(path, CompactionState{
231 SchemaVersion: compactionStateSchemaV1,
232 PromptCacheKey: "ws|s|other-model",
233 TranscriptVersion: 1,
234 Projection: ContextProjection{
235 Messages: []provider.Message{{Role: provider.RoleSystem, Content: "sys summary"}},
236 CoveredCount: 2,
237 CoveredPrefixHash: hash,
238 },
239 }); err != nil {
240 t.Fatal(err)
241 }
242 sess := NewSession("sys")
243 sess.Add(provider.Message{Role: provider.RoleUser, Content: "task"})
244 a := New(nil, nil, sess, Options{
245 SessionPath: path,
246 WorkspaceID: "ws",
247 ModelRef: "this-model",
248 }, event.Discard)
249 // New() already called LoadProjectionSidecar; the projection body matches
250 // the canonical covered prefix, so it must be rebound to the current key
251 // instead of being dropped (upgrade / model-switch path).
252 if len(a.sess.compactionState.Projection.Messages) == 0 {
253 t.Fatal("matching projection body was dropped on lineage change")
254 }
255 wantKey := promptCacheKey("ws", BranchID(path), "this-model")
256 if a.sess.compactionState.PromptCacheKey != wantKey {
257 t.Fatalf("PromptCacheKey = %q, want %q", a.sess.compactionState.PromptCacheKey, wantKey)
258 }
259 if a.sess.checkpointState != "restored" {
260 t.Fatalf("checkpointState = %q, want restored", a.sess.checkpointState)
261 }
262 // The rebind must be persisted so the next launch does not re-downgrade.
263 disk, ok, err := LoadCompactionState(path)
264 if err != nil || !ok {
265 t.Fatalf("sidecar should remain on disk: ok=%v err=%v", ok, err)
266 }
267 if disk.PromptCacheKey != wantKey {
268 t.Fatalf("persisted PromptCacheKey = %q, want %q", disk.PromptCacheKey, wantKey)
269 }
270 }
271
272 func TestLoadProjectionSidecarDropsForeignCacheKey(t *testing.T) {
273 dir := t.TempDir()
274 path := filepath.Join(dir, "s.jsonl")
275 msgs := []provider.Message{{Role: provider.RoleSystem, Content: "sys"}}
276 // Content validation must fail despite a model-only key change: lineage
277 // rebinding cannot resurrect a projection whose canonical prefix differs.
278 foreign := []provider.Message{{Role: provider.RoleSystem, Content: "sys-old"}}
279 if err := SaveCompactionState(path, CompactionState{
280 SchemaVersion: compactionStateSchemaV1,
281 PromptCacheKey: "ws|s|other-model",
282 Projection: ContextProjection{
283 Messages: msgs,
284 CoveredCount: 1,
285 CoveredPrefixHash: coveredPrefixHash(foreign, 1),
286 },
287 }); err != nil {
288 t.Fatal(err)
289 }
290 a := New(nil, nil, NewSession("sys"), Options{
291 SessionPath: path,
292 WorkspaceID: "ws",
293 ModelRef: "this-model",
294 }, event.Discard)
295 // New() already called LoadProjectionSidecar; mismatched content must drop
296 // the projection body and keep the sidecar file for the other model.
297 if len(a.sess.compactionState.Projection.Messages) != 0 {
298 t.Fatalf("foreign projection loaded: %+v", a.sess.compactionState.Projection)
299 }
300 if _, ok, err := LoadCompactionState(path); err != nil || !ok {
301 t.Fatalf("sidecar should remain on disk: ok=%v err=%v", ok, err)
302 }
303 }
304
305 func TestForceThresholdNoopReturnsCompactionRequired(t *testing.T) {
306 // Huge tool result is entirely in the recent tail → no fold region, but
307 // estimate exceeds force; preflight must refuse (not mid-turn).
308 huge := strings.Repeat("word ", 5000)
309 sess := &Session{Messages: []provider.Message{
310 {Role: provider.RoleSystem, Content: "sys"},
311 {Role: provider.RoleUser, Content: "task"},
312 {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "1", Name: "read", Arguments: "{}"}}},
313 {Role: provider.RoleTool, ToolCallID: "1", Name: "read", Content: huge},
314 }}
315 a := New(&fakeProvider{reply: "unused"}, tool.NewRegistry(), sess, Options{
316 ContextWindow: 200,
317 CompactRatio: 0.5,
318 CompactForceRatio: 0.6,
319 RecentKeep: 2,
320 }, event.Discard)
321
322 _, err := a.contextManager().Prepare(context.Background(), ContextPreparePolicy{Trigger: CompactionTriggerPressure})
323 if err == nil {
324 t.Fatal("expected ErrCompactionRequired when force threshold has no fold region")
325 }
326 if !errors.Is(err, ErrCompactionRequired) {
327 t.Fatalf("err = %v, want ErrCompactionRequired", err)
328 }
329 }
330
331 func TestSummarizeOnceDoesNotRetry(t *testing.T) {
332 fp := &retryUsageProvider{
333 failOnce: errors.New("transient"),
334 reply: "digest body",
335 usage1: &provider.Usage{PromptTokens: 10, CompletionTokens: 2, TotalTokens: 12, RequestCount: 1},
336 usage2: &provider.Usage{PromptTokens: 11, CompletionTokens: 3, TotalTokens: 14, RequestCount: 1},
337 }
338 a := New(fp, tool.NewRegistry(), NewSession("sys"), Options{}, event.Discard)
339 _, _, err := a.summarizeOnce(context.Background(), []provider.Message{
340 {Role: provider.RoleUser, Content: "fold me"},
341 }, "")
342 if err == nil {
343 t.Fatal("expected first-attempt failure to surface without retry")
344 }
345 if fp.calls != 1 {
346 t.Fatalf("provider calls = %d, want exactly 1", fp.calls)
347 }
348 }
349
350 // retryUsageProvider fails the first Stream, then returns reply + usage2.
351 type retryUsageProvider struct {
352 calls int
353 failOnce error
354 reply string
355 usage1 *provider.Usage
356 usage2 *provider.Usage
357 }
358
359 func (p *retryUsageProvider) Name() string { return "retry-usage" }
360 func (p *retryUsageProvider) Stream(_ context.Context, _ provider.Request) (<-chan provider.Chunk, error) {
361 p.calls++
362 ch := make(chan provider.Chunk, 4)
363 if p.calls == 1 && p.failOnce != nil {
364 if p.usage1 != nil {
365 ch <- provider.Chunk{Type: provider.ChunkUsage, Usage: p.usage1}
366 }
367 ch <- provider.Chunk{Type: provider.ChunkError, Err: p.failOnce}
368 close(ch)
369 return ch, nil
370 }
371 ch <- provider.Chunk{Type: provider.ChunkText, Text: p.reply}
372 if p.usage2 != nil {
373 ch <- provider.Chunk{Type: provider.ChunkUsage, Usage: p.usage2}
374 }
375 ch <- provider.Chunk{Type: provider.ChunkDone}
376 close(ch)
377 return ch, nil
378 }
379
380 func TestCompactInstallsCoveredPrefixHash(t *testing.T) {
381 fp := &fakeProvider{reply: "digest"}
382 sess := NewSession("sys")
383 for range 8 {
384 sess.Add(provider.Message{Role: provider.RoleUser, Content: strings.Repeat("u", 80)})
385 sess.Add(provider.Message{Role: provider.RoleAssistant, Content: strings.Repeat("a", 120)})
386 }
387 dir := t.TempDir()
388 path := filepath.Join(dir, "s.jsonl")
389 a := New(fp, tool.NewRegistry(), sess, Options{
390 ContextWindow: 2000,
391 RecentKeep: 2,
392 ArchiveDir: dir,
393 SessionPath: path,
394 WorkspaceID: "ws",
395 ModelRef: "m",
396 }, event.Discard)
397 if err := a.CompactNow(context.Background(), ""); err != nil {
398 t.Fatal(err)
399 }
400 st := a.sess.compactionState
401 if st.Projection.CoveredPrefixHash == "" {
402 t.Fatal("CoveredPrefixHash not set")
403 }
404 if st.PromptCacheKey != promptCacheKey("ws", BranchID(path), "m") {
405 t.Fatalf("PromptCacheKey = %q", st.PromptCacheKey)
406 }
407 msgs, _ := sess.snapshotMessagesVersion()
408 if !projectionValid(st, msgs, st.PromptCacheKey) {
409 t.Fatal("fresh projection should validate")
410 }
411 }
412
412 lines GO