返回 DeepSeek-Reasonix
compact_incremental_test.go
根目录 / internal / agent / compact_incremental_test.go
1 package agent
2
3 import (
4 "context"
5 "fmt"
6 "strings"
7 "testing"
8
9 "reasonix/internal/event"
10 "reasonix/internal/provider"
11 "reasonix/internal/tool"
12 )
13
14 // Automatic maintenance always folds the model-visible view (prior digest +
15 // new history). A second fold must re-read the previous digest, not the full
16 // multi-million-token canonical raw history.
17 func TestIncrementalFoldSummarizesPriorDigestPlusNewWork(t *testing.T) {
18 prov := &recordingProvider{reply: "merged digest"}
19 sess := &Session{Messages: []provider.Message{
20 {Role: provider.RoleSystem, Content: "sys"},
21 {Role: provider.RoleUser, Content: "task"},
22 {Role: provider.RoleAssistant, Content: strings.Repeat("old work ", 400)},
23 {Role: provider.RoleUser, Content: "continue"},
24 {Role: provider.RoleAssistant, Content: strings.Repeat("more work ", 400)},
25 {Role: provider.RoleUser, Content: "tail"},
26 {Role: provider.RoleAssistant, Content: "ok"},
27 }}
28 a := New(prov, tool.NewRegistry(), sess, Options{
29 ContextWindow: 50_000, CompactRatio: 0.5, RecentKeep: 2,
30 }, event.Discard)
31
32 if err := a.compact(context.Background(), CompactionTriggerManual, "", true); err != nil {
33 t.Fatalf("first compact: %v", err)
34 }
35 if !hasCompactionSummary(a.modelVisibleMessages()) {
36 t.Fatal("first fold did not install a summary")
37 }
38
39 // Grow past the trigger again with new work.
40 sess.Add(provider.Message{Role: provider.RoleUser, Content: "new phase"})
41 sess.Add(provider.Message{Role: provider.RoleAssistant, Content: strings.Repeat("new work ", 400)})
42 sess.Add(provider.Message{Role: provider.RoleUser, Content: "tail2"})
43 sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "done"})
44 prov.got = nil
45 if err := a.compact(context.Background(), CompactionTriggerManual, "", true); err != nil {
46 t.Fatalf("second compact: %v", err)
47 }
48 if len(prov.got) == 0 {
49 t.Fatal("second fold made no summarizer request")
50 }
51 // The second fold must see the prior digest in its input (incremental merge).
52 var joined strings.Builder
53 for _, req := range prov.got {
54 for _, m := range req.Messages {
55 joined.WriteString(m.Content)
56 }
57 }
58 joinedStr := joined.String()
59 if !strings.Contains(joinedStr, summaryTagOpen) && !strings.Contains(joinedStr, "merged digest") && !strings.Contains(joinedStr, "Summary of earlier") {
60 // The prior digest may be rendered as user content under the summary tag.
61 if !strings.Contains(joinedStr, "new work") {
62 t.Fatalf("second fold input missing new work:\n%.400s", joinedStr)
63 }
64 }
65 // Exactly one primary summary remains in the projection.
66 var summaries int
67 for _, m := range a.modelVisibleMessages() {
68 if isCompactionSummary(m) {
69 summaries++
70 }
71 }
72 if summaries != 1 {
73 t.Fatalf("projection summaries = %d, want exactly 1", summaries)
74 }
75 }
76
77 type recordingProvider struct {
78 reply string
79 got []provider.Request
80 }
81
82 func (p *recordingProvider) Name() string { return "recording" }
83
84 func (p *recordingProvider) Stream(_ context.Context, req provider.Request) (<-chan provider.Chunk, error) {
85 p.got = append(p.got, req)
86 ch := make(chan provider.Chunk, 2)
87 ch <- provider.Chunk{Type: provider.ChunkText, Text: p.reply}
88 ch <- provider.Chunk{Type: provider.ChunkDone}
89 close(ch)
90 return ch, nil
91 }
92
93 // A forced re-compact can fold back into the live projection body — e.g. a
94 // visible-compression projection (full-freeze body) followed by a manual
95 // re-compact whose RecentKeep floor reaches past a short canonical tail. Body
96 // messages past the new boundary have no canonical tail to splice from; they
97 // must stay verbatim in the new body instead of vanishing from the context.
98 func TestRefoldIntoBodyKeepsUnfoldedBodyTail(t *testing.T) {
99 prov := &recordingProvider{reply: "d"}
100 msgs := []provider.Message{
101 {Role: provider.RoleSystem, Content: "system"},
102 {Role: provider.RoleUser, Content: "first task"},
103 }
104 for i := range 10 {
105 msgs = append(msgs, provider.Message{Role: provider.RoleUser, Content: fmt.Sprintf("marker turn %d", i)})
106 }
107 msgs = append(msgs,
108 provider.Message{Role: provider.RoleAssistant, Content: strings.Repeat("tail wall ", 40)},
109 provider.Message{Role: provider.RoleUser, Content: "go"},
110 provider.Message{Role: provider.RoleAssistant, Content: "ok"},
111 )
112 sess := &Session{Messages: msgs}
113 a := New(prov, tool.NewRegistry(), sess, Options{
114 ContextWindow: 5_000, CompactRatio: 0.5, RecentKeep: 5,
115 }, event.Discard)
116
117 // Prior projection of the visible-compression shape: the body freezes the
118 // whole view through the kept user turns; only [tail wall, go, ok] splice.
119 canonical, version := a.sess.conversation.snapshotMessagesVersion()
120 covered := len(canonical) - 3
121 body := append([]provider.Message{canonical[0], canonical[1],
122 formatSummaryMessage(strings.Repeat("prior folded context ", 20))},
123 canonical[2:covered]...)
124 a.sess.compactionMu.Lock()
125 a.sess.compactionState = CompactionState{
126 SchemaVersion: compactionStateSchemaCurrent, TranscriptVersion: version, Generation: 1,
127 PromptCacheKey: a.currentPromptCacheKeyLocked(),
128 Projection: ContextProjection{
129 Messages: body, TranscriptVersion: version, ProjectionVersion: 1,
130 CoveredCount: covered, CoveredPrefixHash: coveredPrefixHash(canonical, covered),
131 },
132 }
133 a.sess.compactionMu.Unlock()
134
135 if err := a.compact(context.Background(), CompactionTriggerManual, "", true); err != nil {
136 t.Fatalf("re-compact into body: %v", err)
137 }
138 if len(prov.got) == 0 {
139 t.Fatal("re-compact made no summary request")
140 }
141 visible := a.modelVisibleMessages()
142 summaryInput := joinContents(prov.got[len(prov.got)-1].Messages)
143 for i := range 10 {
144 want := fmt.Sprintf("marker turn %d", i)
145 found := false
146 for _, m := range visible {
147 if m.Content == want {
148 found = true
149 break
150 }
151 }
152 if !found && !strings.Contains(summaryInput, want) {
153 t.Fatalf("%q reached neither the retained tail nor the summary input", want)
154 }
155 }
156 }
157
157 lines GO