recursive-llm-ts 4.9.0 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/bin/rlm-go +0 -0
- package/dist/bridge-interface.d.ts +149 -0
- package/go/cmd/rlm/main.go +39 -6
- package/go/go.mod +13 -3
- package/go/go.sum +53 -2
- package/go/rlm/compression.go +59 -0
- package/go/rlm/context_overflow.go +21 -36
- package/go/rlm/context_savings_test.go +387 -0
- package/go/rlm/json_extraction.go +140 -0
- package/go/rlm/lcm_agentic_map.go +317 -0
- package/go/rlm/lcm_context_loop.go +309 -0
- package/go/rlm/lcm_delegation.go +257 -0
- package/go/rlm/lcm_episodes.go +313 -0
- package/go/rlm/lcm_episodes_test.go +384 -0
- package/go/rlm/lcm_files.go +424 -0
- package/go/rlm/lcm_map.go +348 -0
- package/go/rlm/lcm_store.go +615 -0
- package/go/rlm/lcm_summarizer.go +239 -0
- package/go/rlm/lcm_test.go +1407 -0
- package/go/rlm/rlm.go +124 -1
- package/go/rlm/store_backend.go +121 -0
- package/go/rlm/store_backend_test.go +428 -0
- package/go/rlm/store_sqlite.go +575 -0
- package/go/rlm/structured.go +6 -83
- package/go/rlm/token_tracking_test.go +25 -11
- package/go/rlm/tokenizer.go +216 -0
- package/go/rlm/tokenizer_test.go +305 -0
- package/go/rlm/types.go +23 -1
- package/go/rlm.test +0 -0
- package/package.json +1 -1
|
@@ -0,0 +1,1407 @@
|
|
|
1
|
+
package rlm
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/json"
|
|
5
|
+
"fmt"
|
|
6
|
+
"os"
|
|
7
|
+
"path/filepath"
|
|
8
|
+
"strings"
|
|
9
|
+
"testing"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
// ─── LCM Store Tests ────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
func TestLCMStore_NewStore(t *testing.T) {
|
|
15
|
+
store := NewLCMStore("test-session")
|
|
16
|
+
if store == nil {
|
|
17
|
+
t.Fatal("NewLCMStore returned nil")
|
|
18
|
+
}
|
|
19
|
+
if store.sessionID != "test-session" {
|
|
20
|
+
t.Errorf("sessionID = %s, want test-session", store.sessionID)
|
|
21
|
+
}
|
|
22
|
+
if store.MessageCount() != 0 {
|
|
23
|
+
t.Errorf("MessageCount = %d, want 0", store.MessageCount())
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
func TestLCMStore_PersistMessage(t *testing.T) {
|
|
28
|
+
store := NewLCMStore("test")
|
|
29
|
+
|
|
30
|
+
msg := store.PersistMessage(RoleUser, "Hello, world!", nil)
|
|
31
|
+
if msg == nil {
|
|
32
|
+
t.Fatal("PersistMessage returned nil")
|
|
33
|
+
}
|
|
34
|
+
if msg.Role != RoleUser {
|
|
35
|
+
t.Errorf("Role = %s, want user", msg.Role)
|
|
36
|
+
}
|
|
37
|
+
if msg.Content != "Hello, world!" {
|
|
38
|
+
t.Errorf("Content = %s, want 'Hello, world!'", msg.Content)
|
|
39
|
+
}
|
|
40
|
+
if msg.Tokens <= 0 {
|
|
41
|
+
t.Errorf("Tokens = %d, want > 0", msg.Tokens)
|
|
42
|
+
}
|
|
43
|
+
if store.MessageCount() != 1 {
|
|
44
|
+
t.Errorf("MessageCount = %d, want 1", store.MessageCount())
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
func TestLCMStore_PersistMessage_WithFileIDs(t *testing.T) {
|
|
49
|
+
store := NewLCMStore("test")
|
|
50
|
+
fileIDs := []string{"file_1", "file_2"}
|
|
51
|
+
msg := store.PersistMessage(RoleUser, "Check these files", fileIDs)
|
|
52
|
+
if len(msg.FileIDs) != 2 {
|
|
53
|
+
t.Errorf("FileIDs length = %d, want 2", len(msg.FileIDs))
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
func TestLCMStore_GetMessage(t *testing.T) {
|
|
58
|
+
store := NewLCMStore("test")
|
|
59
|
+
msg := store.PersistMessage(RoleUser, "test content", nil)
|
|
60
|
+
|
|
61
|
+
retrieved, ok := store.GetMessage(msg.ID)
|
|
62
|
+
if !ok {
|
|
63
|
+
t.Fatal("GetMessage returned false")
|
|
64
|
+
}
|
|
65
|
+
if retrieved.Content != "test content" {
|
|
66
|
+
t.Errorf("Content = %s, want 'test content'", retrieved.Content)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
_, ok = store.GetMessage("nonexistent")
|
|
70
|
+
if ok {
|
|
71
|
+
t.Error("GetMessage returned true for nonexistent ID")
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
func TestLCMStore_GetAllMessages(t *testing.T) {
|
|
76
|
+
store := NewLCMStore("test")
|
|
77
|
+
store.PersistMessage(RoleUser, "first", nil)
|
|
78
|
+
store.PersistMessage(RoleAssistant, "second", nil)
|
|
79
|
+
store.PersistMessage(RoleUser, "third", nil)
|
|
80
|
+
|
|
81
|
+
msgs := store.GetAllMessages()
|
|
82
|
+
if len(msgs) != 3 {
|
|
83
|
+
t.Fatalf("GetAllMessages returned %d, want 3", len(msgs))
|
|
84
|
+
}
|
|
85
|
+
if msgs[0].Content != "first" {
|
|
86
|
+
t.Errorf("First message = %s, want 'first'", msgs[0].Content)
|
|
87
|
+
}
|
|
88
|
+
if msgs[2].Content != "third" {
|
|
89
|
+
t.Errorf("Third message = %s, want 'third'", msgs[2].Content)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
func TestLCMStore_ImmutableStore(t *testing.T) {
|
|
94
|
+
// Verify that messages cannot be modified after persistence
|
|
95
|
+
store := NewLCMStore("test")
|
|
96
|
+
msg := store.PersistMessage(RoleUser, "original", nil)
|
|
97
|
+
originalID := msg.ID
|
|
98
|
+
|
|
99
|
+
// Persist more messages
|
|
100
|
+
store.PersistMessage(RoleAssistant, "reply", nil)
|
|
101
|
+
|
|
102
|
+
// Retrieve the original - should still be intact
|
|
103
|
+
retrieved, ok := store.GetMessage(originalID)
|
|
104
|
+
if !ok {
|
|
105
|
+
t.Fatal("Original message not found")
|
|
106
|
+
}
|
|
107
|
+
if retrieved.Content != "original" {
|
|
108
|
+
t.Errorf("Content changed from 'original' to '%s'", retrieved.Content)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ─── Summary DAG Tests ──────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
func TestLCMStore_CreateLeafSummary(t *testing.T) {
|
|
115
|
+
store := NewLCMStore("test")
|
|
116
|
+
msg1 := store.PersistMessage(RoleUser, "Hello", nil)
|
|
117
|
+
msg2 := store.PersistMessage(RoleAssistant, "Hi there", nil)
|
|
118
|
+
|
|
119
|
+
summary := store.CreateLeafSummary(
|
|
120
|
+
[]string{msg1.ID, msg2.ID},
|
|
121
|
+
"User greeted, assistant replied",
|
|
122
|
+
1,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
if summary == nil {
|
|
126
|
+
t.Fatal("CreateLeafSummary returned nil")
|
|
127
|
+
}
|
|
128
|
+
if summary.Kind != SummaryLeaf {
|
|
129
|
+
t.Errorf("Kind = %s, want leaf", summary.Kind)
|
|
130
|
+
}
|
|
131
|
+
if summary.Level != 1 {
|
|
132
|
+
t.Errorf("Level = %d, want 1", summary.Level)
|
|
133
|
+
}
|
|
134
|
+
if len(summary.MessageIDs) != 2 {
|
|
135
|
+
t.Errorf("MessageIDs length = %d, want 2", len(summary.MessageIDs))
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
func TestLCMStore_CreateLeafSummary_FileIDPropagation(t *testing.T) {
|
|
140
|
+
store := NewLCMStore("test")
|
|
141
|
+
msg1 := store.PersistMessage(RoleUser, "File A", []string{"file_a"})
|
|
142
|
+
msg2 := store.PersistMessage(RoleUser, "File B", []string{"file_b"})
|
|
143
|
+
|
|
144
|
+
summary := store.CreateLeafSummary(
|
|
145
|
+
[]string{msg1.ID, msg2.ID},
|
|
146
|
+
"Summary of files",
|
|
147
|
+
1,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
if len(summary.FileIDs) != 2 {
|
|
151
|
+
t.Errorf("FileIDs length = %d, want 2", len(summary.FileIDs))
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
func TestLCMStore_CreateCondensedSummary(t *testing.T) {
|
|
156
|
+
store := NewLCMStore("test")
|
|
157
|
+
msg1 := store.PersistMessage(RoleUser, "Hello", []string{"f1"})
|
|
158
|
+
msg2 := store.PersistMessage(RoleAssistant, "Hi", nil)
|
|
159
|
+
msg3 := store.PersistMessage(RoleUser, "More", []string{"f2"})
|
|
160
|
+
|
|
161
|
+
leaf1 := store.CreateLeafSummary([]string{msg1.ID, msg2.ID}, "Greeting exchange", 1)
|
|
162
|
+
leaf2 := store.CreateLeafSummary([]string{msg3.ID}, "Continuation", 1)
|
|
163
|
+
|
|
164
|
+
condensed := store.CreateCondensedSummary(
|
|
165
|
+
[]string{leaf1.ID, leaf2.ID},
|
|
166
|
+
"Full conversation summary",
|
|
167
|
+
1,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
if condensed.Kind != SummaryCondensed {
|
|
171
|
+
t.Errorf("Kind = %s, want condensed", condensed.Kind)
|
|
172
|
+
}
|
|
173
|
+
if len(condensed.ChildIDs) != 2 {
|
|
174
|
+
t.Errorf("ChildIDs length = %d, want 2", len(condensed.ChildIDs))
|
|
175
|
+
}
|
|
176
|
+
// File IDs should propagate from children
|
|
177
|
+
if len(condensed.FileIDs) != 2 {
|
|
178
|
+
t.Errorf("FileIDs length = %d, want 2 (propagated from children)", len(condensed.FileIDs))
|
|
179
|
+
}
|
|
180
|
+
// Parent pointers should be set on children
|
|
181
|
+
updatedLeaf1, _ := store.GetSummary(leaf1.ID)
|
|
182
|
+
if updatedLeaf1.ParentID != condensed.ID {
|
|
183
|
+
t.Errorf("Leaf1 ParentID = %s, want %s", updatedLeaf1.ParentID, condensed.ID)
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
func TestLCMStore_ExpandSummary_Leaf(t *testing.T) {
|
|
188
|
+
store := NewLCMStore("test")
|
|
189
|
+
msg1 := store.PersistMessage(RoleUser, "Message 1", nil)
|
|
190
|
+
msg2 := store.PersistMessage(RoleAssistant, "Message 2", nil)
|
|
191
|
+
|
|
192
|
+
leaf := store.CreateLeafSummary([]string{msg1.ID, msg2.ID}, "Summary", 1)
|
|
193
|
+
|
|
194
|
+
expanded, err := store.ExpandSummary(leaf.ID)
|
|
195
|
+
if err != nil {
|
|
196
|
+
t.Fatalf("ExpandSummary error: %v", err)
|
|
197
|
+
}
|
|
198
|
+
if len(expanded) != 2 {
|
|
199
|
+
t.Fatalf("Expanded length = %d, want 2", len(expanded))
|
|
200
|
+
}
|
|
201
|
+
if expanded[0].Content != "Message 1" {
|
|
202
|
+
t.Errorf("First expanded = %s, want 'Message 1'", expanded[0].Content)
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
func TestLCMStore_ExpandSummary_Condensed(t *testing.T) {
|
|
207
|
+
store := NewLCMStore("test")
|
|
208
|
+
msg1 := store.PersistMessage(RoleUser, "A", nil)
|
|
209
|
+
msg2 := store.PersistMessage(RoleAssistant, "B", nil)
|
|
210
|
+
msg3 := store.PersistMessage(RoleUser, "C", nil)
|
|
211
|
+
|
|
212
|
+
leaf1 := store.CreateLeafSummary([]string{msg1.ID, msg2.ID}, "Summary AB", 1)
|
|
213
|
+
leaf2 := store.CreateLeafSummary([]string{msg3.ID}, "Summary C", 1)
|
|
214
|
+
condensed := store.CreateCondensedSummary([]string{leaf1.ID, leaf2.ID}, "All", 2)
|
|
215
|
+
|
|
216
|
+
expanded, err := store.ExpandSummary(condensed.ID)
|
|
217
|
+
if err != nil {
|
|
218
|
+
t.Fatalf("ExpandSummary error: %v", err)
|
|
219
|
+
}
|
|
220
|
+
if len(expanded) != 3 {
|
|
221
|
+
t.Fatalf("Expanded length = %d, want 3 (recursive)", len(expanded))
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
func TestLCMStore_ExpandSummary_NotFound(t *testing.T) {
|
|
226
|
+
store := NewLCMStore("test")
|
|
227
|
+
_, err := store.ExpandSummary("nonexistent")
|
|
228
|
+
if err == nil {
|
|
229
|
+
t.Error("Expected error for nonexistent summary")
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ─── Active Context Tests ───────────────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
func TestLCMStore_ActiveContext(t *testing.T) {
|
|
236
|
+
store := NewLCMStore("test")
|
|
237
|
+
store.PersistMessage(RoleSystem, "You are helpful", nil)
|
|
238
|
+
store.PersistMessage(RoleUser, "Hello", nil)
|
|
239
|
+
|
|
240
|
+
active := store.GetActiveContext()
|
|
241
|
+
if len(active) != 2 {
|
|
242
|
+
t.Fatalf("Active context length = %d, want 2", len(active))
|
|
243
|
+
}
|
|
244
|
+
if !active[0].IsMessage() {
|
|
245
|
+
t.Error("First item should be a message")
|
|
246
|
+
}
|
|
247
|
+
if active[0].Message.Role != RoleSystem {
|
|
248
|
+
t.Errorf("First item role = %s, want system", active[0].Message.Role)
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
func TestLCMStore_ActiveContextTokens(t *testing.T) {
|
|
253
|
+
store := NewLCMStore("test")
|
|
254
|
+
store.PersistMessage(RoleUser, "Hello world this is a test message", nil)
|
|
255
|
+
store.PersistMessage(RoleAssistant, "Yes it is indeed a test", nil)
|
|
256
|
+
|
|
257
|
+
tokens := store.ActiveContextTokens()
|
|
258
|
+
if tokens <= 0 {
|
|
259
|
+
t.Errorf("ActiveContextTokens = %d, want > 0", tokens)
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
func TestLCMStore_CompactOldestBlock(t *testing.T) {
|
|
264
|
+
store := NewLCMStore("test")
|
|
265
|
+
store.PersistMessage(RoleSystem, "System prompt", nil)
|
|
266
|
+
msg1 := store.PersistMessage(RoleUser, "First question", nil)
|
|
267
|
+
msg2 := store.PersistMessage(RoleAssistant, "First answer", nil)
|
|
268
|
+
store.PersistMessage(RoleUser, "Second question", nil)
|
|
269
|
+
|
|
270
|
+
// Create summary for msg1 and msg2
|
|
271
|
+
summary := store.CreateLeafSummary(
|
|
272
|
+
[]string{msg1.ID, msg2.ID},
|
|
273
|
+
"Q&A about first topic",
|
|
274
|
+
1,
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
removed := store.CompactOldestBlock(summary)
|
|
278
|
+
if len(removed) != 2 {
|
|
279
|
+
t.Errorf("Removed %d messages, want 2", len(removed))
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
active := store.GetActiveContext()
|
|
283
|
+
// Should now be: system prompt, summary, second question
|
|
284
|
+
if len(active) != 3 {
|
|
285
|
+
t.Fatalf("Active context length = %d, want 3", len(active))
|
|
286
|
+
}
|
|
287
|
+
if active[0].IsMessage() && active[0].Message.Role != RoleSystem {
|
|
288
|
+
t.Error("First item should be system prompt")
|
|
289
|
+
}
|
|
290
|
+
if active[1].IsMessage() {
|
|
291
|
+
t.Error("Second item should be summary, not message")
|
|
292
|
+
}
|
|
293
|
+
if active[1].Summary == nil || active[1].Summary.ID != summary.ID {
|
|
294
|
+
t.Error("Second item should be the compaction summary")
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
func TestLCMStore_BuildMessages(t *testing.T) {
|
|
299
|
+
store := NewLCMStore("test")
|
|
300
|
+
store.PersistMessage(RoleSystem, "System prompt", nil)
|
|
301
|
+
msg1 := store.PersistMessage(RoleUser, "Hello", nil)
|
|
302
|
+
msg2 := store.PersistMessage(RoleAssistant, "Hi", nil)
|
|
303
|
+
|
|
304
|
+
// Compact first two non-system messages
|
|
305
|
+
summary := store.CreateLeafSummary([]string{msg1.ID, msg2.ID}, "Greeting exchange", 1)
|
|
306
|
+
store.CompactOldestBlock(summary)
|
|
307
|
+
|
|
308
|
+
store.PersistMessage(RoleUser, "New question", nil)
|
|
309
|
+
|
|
310
|
+
msgs := store.BuildMessages()
|
|
311
|
+
if len(msgs) != 3 {
|
|
312
|
+
t.Fatalf("BuildMessages returned %d, want 3", len(msgs))
|
|
313
|
+
}
|
|
314
|
+
if msgs[0].Role != "system" {
|
|
315
|
+
t.Errorf("First role = %s, want system", msgs[0].Role)
|
|
316
|
+
}
|
|
317
|
+
// Second should be the summary
|
|
318
|
+
if !strings.Contains(msgs[1].Content, "Summary") {
|
|
319
|
+
t.Error("Second message should contain summary annotation")
|
|
320
|
+
}
|
|
321
|
+
if msgs[2].Content != "New question" {
|
|
322
|
+
t.Errorf("Third message = %s, want 'New question'", msgs[2].Content)
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ─── LCM Grep Tests ────────────────────────────────────────────────────────
|
|
327
|
+
|
|
328
|
+
func TestLCMStore_Grep(t *testing.T) {
|
|
329
|
+
store := NewLCMStore("test")
|
|
330
|
+
store.PersistMessage(RoleUser, "What is the weather today?", nil)
|
|
331
|
+
store.PersistMessage(RoleAssistant, "The weather is sunny with a high of 75°F", nil)
|
|
332
|
+
store.PersistMessage(RoleUser, "What about tomorrow?", nil)
|
|
333
|
+
store.PersistMessage(RoleAssistant, "Tomorrow will be rainy", nil)
|
|
334
|
+
|
|
335
|
+
results, err := store.Grep("weather", 10)
|
|
336
|
+
if err != nil {
|
|
337
|
+
t.Fatalf("Grep error: %v", err)
|
|
338
|
+
}
|
|
339
|
+
if len(results) != 2 {
|
|
340
|
+
t.Errorf("Grep returned %d results, want 2", len(results))
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
func TestLCMStore_Grep_WithSummary(t *testing.T) {
|
|
345
|
+
store := NewLCMStore("test")
|
|
346
|
+
msg1 := store.PersistMessage(RoleUser, "Error: disk full", nil)
|
|
347
|
+
store.PersistMessage(RoleAssistant, "Try cleaning up temp files", nil)
|
|
348
|
+
|
|
349
|
+
// Create summary covering msg1
|
|
350
|
+
store.CreateLeafSummary([]string{msg1.ID}, "Disk issue", 1)
|
|
351
|
+
|
|
352
|
+
results, err := store.Grep("Error", 10)
|
|
353
|
+
if err != nil {
|
|
354
|
+
t.Fatalf("Grep error: %v", err)
|
|
355
|
+
}
|
|
356
|
+
if len(results) != 1 {
|
|
357
|
+
t.Fatalf("Grep returned %d results, want 1", len(results))
|
|
358
|
+
}
|
|
359
|
+
if results[0].SummaryID == "" {
|
|
360
|
+
t.Error("Expected SummaryID to be set for message covered by summary")
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
func TestLCMStore_Grep_InvalidRegex(t *testing.T) {
|
|
365
|
+
store := NewLCMStore("test")
|
|
366
|
+
_, err := store.Grep("[invalid", 10)
|
|
367
|
+
if err == nil {
|
|
368
|
+
t.Error("Expected error for invalid regex")
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
func TestLCMStore_Grep_Pagination(t *testing.T) {
|
|
373
|
+
store := NewLCMStore("test")
|
|
374
|
+
for i := 0; i < 20; i++ {
|
|
375
|
+
store.PersistMessage(RoleUser, fmt.Sprintf("test message %d", i), nil)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
results, err := store.Grep("test", 5)
|
|
379
|
+
if err != nil {
|
|
380
|
+
t.Fatalf("Grep error: %v", err)
|
|
381
|
+
}
|
|
382
|
+
if len(results) != 5 {
|
|
383
|
+
t.Errorf("Grep returned %d results, want 5 (paginated)", len(results))
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// ─── LCM Describe Tests ────────────────────────────────────────────────────
|
|
388
|
+
|
|
389
|
+
func TestLCMStore_Describe_Message(t *testing.T) {
|
|
390
|
+
store := NewLCMStore("test")
|
|
391
|
+
msg := store.PersistMessage(RoleUser, "Hello", []string{"file_1"})
|
|
392
|
+
|
|
393
|
+
desc, err := store.Describe(msg.ID)
|
|
394
|
+
if err != nil {
|
|
395
|
+
t.Fatalf("Describe error: %v", err)
|
|
396
|
+
}
|
|
397
|
+
if desc.Type != "message" {
|
|
398
|
+
t.Errorf("Type = %s, want message", desc.Type)
|
|
399
|
+
}
|
|
400
|
+
if desc.Role != "user" {
|
|
401
|
+
t.Errorf("Role = %s, want user", desc.Role)
|
|
402
|
+
}
|
|
403
|
+
if len(desc.FileIDs) != 1 {
|
|
404
|
+
t.Errorf("FileIDs length = %d, want 1", len(desc.FileIDs))
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
func TestLCMStore_Describe_Summary(t *testing.T) {
|
|
409
|
+
store := NewLCMStore("test")
|
|
410
|
+
msg := store.PersistMessage(RoleUser, "Hello", nil)
|
|
411
|
+
summary := store.CreateLeafSummary([]string{msg.ID}, "Greeting", 2)
|
|
412
|
+
|
|
413
|
+
desc, err := store.Describe(summary.ID)
|
|
414
|
+
if err != nil {
|
|
415
|
+
t.Fatalf("Describe error: %v", err)
|
|
416
|
+
}
|
|
417
|
+
if desc.Type != "summary" {
|
|
418
|
+
t.Errorf("Type = %s, want summary", desc.Type)
|
|
419
|
+
}
|
|
420
|
+
if desc.Kind != "leaf" {
|
|
421
|
+
t.Errorf("Kind = %s, want leaf", desc.Kind)
|
|
422
|
+
}
|
|
423
|
+
if desc.Level != 2 {
|
|
424
|
+
t.Errorf("Level = %d, want 2", desc.Level)
|
|
425
|
+
}
|
|
426
|
+
if desc.Content != "Greeting" {
|
|
427
|
+
t.Errorf("Content = %s, want 'Greeting'", desc.Content)
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
func TestLCMStore_Describe_NotFound(t *testing.T) {
|
|
432
|
+
store := NewLCMStore("test")
|
|
433
|
+
_, err := store.Describe("nonexistent")
|
|
434
|
+
if err == nil {
|
|
435
|
+
t.Error("Expected error for nonexistent ID")
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// ─── Store Stats Tests ──────────────────────────────────────────────────────
|
|
440
|
+
|
|
441
|
+
func TestLCMStore_Stats(t *testing.T) {
|
|
442
|
+
store := NewLCMStore("test")
|
|
443
|
+
store.PersistMessage(RoleUser, "Hello world", nil)
|
|
444
|
+
store.PersistMessage(RoleAssistant, "Hi there friend", nil)
|
|
445
|
+
|
|
446
|
+
stats := store.Stats()
|
|
447
|
+
if stats.TotalMessages != 2 {
|
|
448
|
+
t.Errorf("TotalMessages = %d, want 2", stats.TotalMessages)
|
|
449
|
+
}
|
|
450
|
+
if stats.TotalSummaries != 0 {
|
|
451
|
+
t.Errorf("TotalSummaries = %d, want 0", stats.TotalSummaries)
|
|
452
|
+
}
|
|
453
|
+
if stats.ActiveContextItems != 2 {
|
|
454
|
+
t.Errorf("ActiveContextItems = %d, want 2", stats.ActiveContextItems)
|
|
455
|
+
}
|
|
456
|
+
if stats.CompressionRatio == 0 {
|
|
457
|
+
t.Error("CompressionRatio should be > 0")
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
func TestLCMStore_Stats_WithCompaction(t *testing.T) {
|
|
462
|
+
store := NewLCMStore("test")
|
|
463
|
+
msg1 := store.PersistMessage(RoleUser, strings.Repeat("Hello world test message. ", 50), nil)
|
|
464
|
+
msg2 := store.PersistMessage(RoleAssistant, strings.Repeat("Reply content here. ", 50), nil)
|
|
465
|
+
|
|
466
|
+
summary := store.CreateLeafSummary([]string{msg1.ID, msg2.ID}, "Brief summary", 1)
|
|
467
|
+
store.CompactOldestBlock(summary)
|
|
468
|
+
|
|
469
|
+
stats := store.Stats()
|
|
470
|
+
if stats.TotalMessages != 2 {
|
|
471
|
+
t.Errorf("TotalMessages = %d, want 2 (immutable)", stats.TotalMessages)
|
|
472
|
+
}
|
|
473
|
+
if stats.TotalSummaries != 1 {
|
|
474
|
+
t.Errorf("TotalSummaries = %d, want 1", stats.TotalSummaries)
|
|
475
|
+
}
|
|
476
|
+
if stats.CompressionRatio >= 1.0 {
|
|
477
|
+
t.Errorf("CompressionRatio = %f, want < 1.0 (compression occurred)", stats.CompressionRatio)
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// ─── Three-Level Summarization Tests ────────────────────────────────────────
|
|
482
|
+
|
|
483
|
+
func TestLCMSummarizer_DeterministicTruncate(t *testing.T) {
|
|
484
|
+
obs := NewNoopObserver()
|
|
485
|
+
summarizer := NewLCMSummarizer("test", "", "", 30, nil, obs)
|
|
486
|
+
|
|
487
|
+
// Test deterministic truncation directly
|
|
488
|
+
longInput := strings.Repeat("This is a test sentence. ", 100)
|
|
489
|
+
result := summarizer.deterministicTruncate(longInput, 50)
|
|
490
|
+
|
|
491
|
+
if result.Level != 5 {
|
|
492
|
+
t.Errorf("Level = %d, want 5", result.Level)
|
|
493
|
+
}
|
|
494
|
+
if result.Tokens > 200 {
|
|
495
|
+
t.Errorf("Tokens = %d, want <= 200 (truncated to ~50 target)", result.Tokens)
|
|
496
|
+
}
|
|
497
|
+
if !strings.Contains(result.Content, "truncated") {
|
|
498
|
+
t.Error("Truncated content should contain truncation marker")
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
func TestLCMSummarizer_ShortInput(t *testing.T) {
|
|
503
|
+
obs := NewNoopObserver()
|
|
504
|
+
summarizer := NewLCMSummarizer("test", "", "", 30, nil, obs)
|
|
505
|
+
|
|
506
|
+
result, err := summarizer.Summarize("Short text", 1000)
|
|
507
|
+
if err != nil {
|
|
508
|
+
t.Fatalf("Summarize error: %v", err)
|
|
509
|
+
}
|
|
510
|
+
if result.Level != 0 {
|
|
511
|
+
t.Errorf("Level = %d, want 0 (no summarization needed)", result.Level)
|
|
512
|
+
}
|
|
513
|
+
if result.Content != "Short text" {
|
|
514
|
+
t.Errorf("Content changed for short input")
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
func TestLCMSummarizer_SummarizeMessages(t *testing.T) {
|
|
519
|
+
obs := NewNoopObserver()
|
|
520
|
+
summarizer := NewLCMSummarizer("test", "", "", 30, nil, obs)
|
|
521
|
+
|
|
522
|
+
msgs := []*StoreMessage{
|
|
523
|
+
{Role: RoleUser, Content: "Hello"},
|
|
524
|
+
{Role: RoleAssistant, Content: "Hi there"},
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// This will try LLM calls (which will fail without API key) and fall back to deterministic
|
|
528
|
+
result, err := summarizer.SummarizeMessages(msgs, 10)
|
|
529
|
+
if err != nil {
|
|
530
|
+
// Summarize should eventually succeed via Level 3 deterministic
|
|
531
|
+
t.Fatalf("SummarizeMessages error: %v", err)
|
|
532
|
+
}
|
|
533
|
+
if result.Content == "" {
|
|
534
|
+
t.Error("Result content should not be empty")
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// ─── Context Control Loop Tests ─────────────────────────────────────────────
|
|
539
|
+
|
|
540
|
+
func TestLCMEngine_ZeroCostContinuity(t *testing.T) {
|
|
541
|
+
store := NewLCMStore("test")
|
|
542
|
+
obs := NewNoopObserver()
|
|
543
|
+
summarizer := NewLCMSummarizer("test", "", "", 30, nil, obs)
|
|
544
|
+
|
|
545
|
+
config := LCMConfig{
|
|
546
|
+
Enabled: true,
|
|
547
|
+
SoftThreshold: 10000,
|
|
548
|
+
HardThreshold: 20000,
|
|
549
|
+
CompactionBlockSize: 5,
|
|
550
|
+
SummaryTargetTokens: 200,
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
engine := NewLCMEngine(config, store, summarizer, obs, 128000)
|
|
554
|
+
|
|
555
|
+
// Add a small message — should be below soft threshold
|
|
556
|
+
store.PersistMessage(RoleUser, "Hello", nil)
|
|
557
|
+
err := engine.OnNewItem()
|
|
558
|
+
if err != nil {
|
|
559
|
+
t.Fatalf("OnNewItem error: %v", err)
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// Active context should be unchanged (zero-cost continuity)
|
|
563
|
+
if store.ActiveContextTokens() > config.SoftThreshold {
|
|
564
|
+
t.Error("Small message should be below soft threshold")
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
func TestLCMEngine_Disabled(t *testing.T) {
|
|
569
|
+
store := NewLCMStore("test")
|
|
570
|
+
obs := NewNoopObserver()
|
|
571
|
+
summarizer := NewLCMSummarizer("test", "", "", 30, nil, obs)
|
|
572
|
+
|
|
573
|
+
config := LCMConfig{Enabled: false}
|
|
574
|
+
engine := NewLCMEngine(config, store, summarizer, obs, 128000)
|
|
575
|
+
|
|
576
|
+
store.PersistMessage(RoleUser, "Hello", nil)
|
|
577
|
+
err := engine.OnNewItem()
|
|
578
|
+
if err != nil {
|
|
579
|
+
t.Fatalf("OnNewItem error when disabled: %v", err)
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
func TestLCMEngine_DefaultThresholds(t *testing.T) {
|
|
584
|
+
store := NewLCMStore("test")
|
|
585
|
+
obs := NewNoopObserver()
|
|
586
|
+
summarizer := NewLCMSummarizer("test", "", "", 30, nil, obs)
|
|
587
|
+
|
|
588
|
+
config := LCMConfig{Enabled: true}
|
|
589
|
+
engine := NewLCMEngine(config, store, summarizer, obs, 100000)
|
|
590
|
+
|
|
591
|
+
if engine.config.SoftThreshold != 70000 {
|
|
592
|
+
t.Errorf("SoftThreshold = %d, want 70000 (70%% of 100000)", engine.config.SoftThreshold)
|
|
593
|
+
}
|
|
594
|
+
if engine.config.HardThreshold != 90000 {
|
|
595
|
+
t.Errorf("HardThreshold = %d, want 90000 (90%% of 100000)", engine.config.HardThreshold)
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
func TestLCMEngine_GetStore(t *testing.T) {
|
|
600
|
+
store := NewLCMStore("test")
|
|
601
|
+
obs := NewNoopObserver()
|
|
602
|
+
summarizer := NewLCMSummarizer("test", "", "", 30, nil, obs)
|
|
603
|
+
config := LCMConfig{Enabled: true}
|
|
604
|
+
|
|
605
|
+
engine := NewLCMEngine(config, store, summarizer, obs, 128000)
|
|
606
|
+
if engine.GetStore() != store {
|
|
607
|
+
t.Error("GetStore returned different store")
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// ─── LLM-Map Tests ──────────────────────────────────────────────────────────
|
|
612
|
+
|
|
613
|
+
func TestLLMMap_ReadJSONL(t *testing.T) {
|
|
614
|
+
// Create temp JSONL file
|
|
615
|
+
tmpDir := t.TempDir()
|
|
616
|
+
inputPath := filepath.Join(tmpDir, "input.jsonl")
|
|
617
|
+
|
|
618
|
+
lines := []string{
|
|
619
|
+
`{"name": "Alice", "age": 30}`,
|
|
620
|
+
`{"name": "Bob", "age": 25}`,
|
|
621
|
+
`{"name": "Charlie", "age": 35}`,
|
|
622
|
+
}
|
|
623
|
+
err := os.WriteFile(inputPath, []byte(strings.Join(lines, "\n")), 0644)
|
|
624
|
+
if err != nil {
|
|
625
|
+
t.Fatal(err)
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
items, err := readJSONLFile(inputPath)
|
|
629
|
+
if err != nil {
|
|
630
|
+
t.Fatalf("readJSONLFile error: %v", err)
|
|
631
|
+
}
|
|
632
|
+
if len(items) != 3 {
|
|
633
|
+
t.Errorf("Read %d items, want 3", len(items))
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
var first map[string]interface{}
|
|
637
|
+
_ = json.Unmarshal(items[0], &first)
|
|
638
|
+
if first["name"] != "Alice" {
|
|
639
|
+
t.Errorf("First name = %v, want Alice", first["name"])
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
func TestLLMMap_WriteJSONL(t *testing.T) {
|
|
644
|
+
tmpDir := t.TempDir()
|
|
645
|
+
outputPath := filepath.Join(tmpDir, "output.jsonl")
|
|
646
|
+
|
|
647
|
+
results := []MapItemResult{
|
|
648
|
+
{Index: 0, Status: MapItemCompleted, Output: json.RawMessage(`{"result": "ok"}`)},
|
|
649
|
+
{Index: 1, Status: MapItemFailed, Error: "timeout"},
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
err := writeJSONLOutput(outputPath, results)
|
|
653
|
+
if err != nil {
|
|
654
|
+
t.Fatalf("writeJSONLOutput error: %v", err)
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// Read back
|
|
658
|
+
data, err := os.ReadFile(outputPath)
|
|
659
|
+
if err != nil {
|
|
660
|
+
t.Fatal(err)
|
|
661
|
+
}
|
|
662
|
+
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
|
|
663
|
+
if len(lines) != 2 {
|
|
664
|
+
t.Errorf("Output has %d lines, want 2", len(lines))
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
func TestLLMMap_ExtractJSON(t *testing.T) {
|
|
669
|
+
tests := []struct {
|
|
670
|
+
input string
|
|
671
|
+
wantNil bool
|
|
672
|
+
}{
|
|
673
|
+
{`{"key": "value"}`, false},
|
|
674
|
+
{`Some text {"key": "value"} more text`, false},
|
|
675
|
+
{"```json\n{\"key\": \"value\"}\n```", false},
|
|
676
|
+
{`[1, 2, 3]`, false},
|
|
677
|
+
{`no json here`, true},
|
|
678
|
+
{``, true},
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
for _, tt := range tests {
|
|
682
|
+
result := extractJSON(tt.input)
|
|
683
|
+
if tt.wantNil && result != nil {
|
|
684
|
+
t.Errorf("extractJSON(%q) = %s, want nil", tt.input, string(result))
|
|
685
|
+
}
|
|
686
|
+
if !tt.wantNil && result == nil {
|
|
687
|
+
t.Errorf("extractJSON(%q) = nil, want non-nil", tt.input)
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
func TestLLMMap_ValidateMapOutput(t *testing.T) {
|
|
693
|
+
schema := &JSONSchema{
|
|
694
|
+
Type: "object",
|
|
695
|
+
Properties: map[string]*JSONSchema{
|
|
696
|
+
"name": {Type: "string"},
|
|
697
|
+
"age": {Type: "number"},
|
|
698
|
+
},
|
|
699
|
+
Required: []string{"name"},
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// Valid output
|
|
703
|
+
valid := json.RawMessage(`{"name": "Alice", "age": 30}`)
|
|
704
|
+
if err := validateMapOutput(valid, schema); err != "" {
|
|
705
|
+
t.Errorf("validateMapOutput valid = %s, want empty", err)
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// Invalid JSON
|
|
709
|
+
invalid := json.RawMessage(`not json`)
|
|
710
|
+
if err := validateMapOutput(invalid, schema); err == "" {
|
|
711
|
+
t.Error("validateMapOutput invalid JSON should return error")
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// ─── Large File Handling Tests ──────────────────────────────────────────────
|
|
716
|
+
|
|
717
|
+
func TestLCMFileHandler_SmallFile(t *testing.T) {
|
|
718
|
+
obs := NewNoopObserver()
|
|
719
|
+
handler := NewLCMFileHandler(DefaultLCMFileConfig(), "test", "", "", 30, nil, obs)
|
|
720
|
+
|
|
721
|
+
content := "Small file content"
|
|
722
|
+
result, ref, err := handler.ProcessFile("test.txt", content)
|
|
723
|
+
if err != nil {
|
|
724
|
+
t.Fatalf("ProcessFile error: %v", err)
|
|
725
|
+
}
|
|
726
|
+
if ref != nil {
|
|
727
|
+
t.Error("Small file should not create a file ref")
|
|
728
|
+
}
|
|
729
|
+
if result != content {
|
|
730
|
+
t.Errorf("Content should be unchanged for small files")
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
func TestLCMFileHandler_LargeFile(t *testing.T) {
|
|
735
|
+
obs := NewNoopObserver()
|
|
736
|
+
config := LCMFileConfig{TokenThreshold: 10} // Very low threshold for testing
|
|
737
|
+
handler := NewLCMFileHandler(config, "test", "", "", 30, nil, obs)
|
|
738
|
+
|
|
739
|
+
content := strings.Repeat("Line of content for testing. ", 100)
|
|
740
|
+
result, ref, err := handler.ProcessFile("test.txt", content)
|
|
741
|
+
if err != nil {
|
|
742
|
+
t.Fatalf("ProcessFile error: %v", err)
|
|
743
|
+
}
|
|
744
|
+
if ref == nil {
|
|
745
|
+
t.Fatal("Large file should create a file ref")
|
|
746
|
+
}
|
|
747
|
+
if ref.Path != "test.txt" {
|
|
748
|
+
t.Errorf("Path = %s, want test.txt", ref.Path)
|
|
749
|
+
}
|
|
750
|
+
if !strings.Contains(result, ref.ID) {
|
|
751
|
+
t.Error("Context reference should contain file ID")
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
func TestLCMFileHandler_GetFileRef(t *testing.T) {
|
|
756
|
+
obs := NewNoopObserver()
|
|
757
|
+
config := LCMFileConfig{TokenThreshold: 10}
|
|
758
|
+
handler := NewLCMFileHandler(config, "test", "", "", 30, nil, obs)
|
|
759
|
+
|
|
760
|
+
content := strings.Repeat("Content. ", 100)
|
|
761
|
+
_, ref, _ := handler.ProcessFile("test.txt", content)
|
|
762
|
+
|
|
763
|
+
retrieved, ok := handler.GetFileRef(ref.ID)
|
|
764
|
+
if !ok {
|
|
765
|
+
t.Fatal("GetFileRef returned false")
|
|
766
|
+
}
|
|
767
|
+
if retrieved.Path != "test.txt" {
|
|
768
|
+
t.Errorf("Path = %s, want test.txt", retrieved.Path)
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
func TestClassifyFileType(t *testing.T) {
|
|
773
|
+
tests := []struct {
|
|
774
|
+
ext string
|
|
775
|
+
want fileType
|
|
776
|
+
}{
|
|
777
|
+
{".json", fileTypeStructuredData},
|
|
778
|
+
{".csv", fileTypeStructuredData},
|
|
779
|
+
{".go", fileTypeCode},
|
|
780
|
+
{".py", fileTypeCode},
|
|
781
|
+
{".ts", fileTypeCode},
|
|
782
|
+
{".txt", fileTypeText},
|
|
783
|
+
{".md", fileTypeText},
|
|
784
|
+
{".unknown", fileTypeText},
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
for _, tt := range tests {
|
|
788
|
+
got := classifyFileType(tt.ext)
|
|
789
|
+
if got != tt.want {
|
|
790
|
+
t.Errorf("classifyFileType(%s) = %d, want %d", tt.ext, got, tt.want)
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
func TestDetectMIMEType(t *testing.T) {
|
|
796
|
+
tests := []struct {
|
|
797
|
+
path string
|
|
798
|
+
want string
|
|
799
|
+
}{
|
|
800
|
+
{"file.json", "application/json"},
|
|
801
|
+
{"file.go", "text/x-go"},
|
|
802
|
+
{"file.py", "text/x-python"},
|
|
803
|
+
{"file.unknown", "application/octet-stream"},
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
for _, tt := range tests {
|
|
807
|
+
got := detectMIMEType(tt.path)
|
|
808
|
+
if got != tt.want {
|
|
809
|
+
t.Errorf("detectMIMEType(%s) = %s, want %s", tt.path, got, tt.want)
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
func TestSummarizeJSON(t *testing.T) {
|
|
815
|
+
obs := NewNoopObserver()
|
|
816
|
+
handler := NewLCMFileHandler(DefaultLCMFileConfig(), "test", "", "", 30, nil, obs)
|
|
817
|
+
|
|
818
|
+
jsonContent := `{"users": [{"name": "Alice", "age": 30}, {"name": "Bob", "age": 25}], "count": 2}`
|
|
819
|
+
result := handler.summarizeJSON(jsonContent)
|
|
820
|
+
if !strings.Contains(result, "JSON file analysis") {
|
|
821
|
+
t.Error("JSON summary should contain analysis header")
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
func TestSummarizeCSV(t *testing.T) {
|
|
826
|
+
obs := NewNoopObserver()
|
|
827
|
+
handler := NewLCMFileHandler(DefaultLCMFileConfig(), "test", "", "", 30, nil, obs)
|
|
828
|
+
|
|
829
|
+
csvContent := "name,age,city\nAlice,30,NYC\nBob,25,LA"
|
|
830
|
+
result := handler.summarizeCSV(csvContent, ".csv")
|
|
831
|
+
if !strings.Contains(result, "3 rows") {
|
|
832
|
+
t.Error("CSV summary should contain row count")
|
|
833
|
+
}
|
|
834
|
+
if !strings.Contains(result, "Columns") {
|
|
835
|
+
t.Error("CSV summary should contain column names")
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
func TestExtractCodeStructure(t *testing.T) {
|
|
840
|
+
goCode := `package main
|
|
841
|
+
|
|
842
|
+
import "fmt"
|
|
843
|
+
|
|
844
|
+
func main() {
|
|
845
|
+
fmt.Println("Hello")
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
func helper(x int) int {
|
|
849
|
+
return x * 2
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
type Config struct {
|
|
853
|
+
Name string
|
|
854
|
+
}
|
|
855
|
+
`
|
|
856
|
+
result := extractCodeStructure(goCode, ".go")
|
|
857
|
+
if !strings.Contains(result, "func main") {
|
|
858
|
+
t.Error("Should extract func main definition")
|
|
859
|
+
}
|
|
860
|
+
if !strings.Contains(result, "func helper") {
|
|
861
|
+
t.Error("Should extract func helper definition")
|
|
862
|
+
}
|
|
863
|
+
if !strings.Contains(result, "type Config") {
|
|
864
|
+
t.Error("Should extract type Config definition")
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
func TestSummarizeJSONL(t *testing.T) {
|
|
869
|
+
obs := NewNoopObserver()
|
|
870
|
+
handler := NewLCMFileHandler(DefaultLCMFileConfig(), "test", "", "", 30, nil, obs)
|
|
871
|
+
|
|
872
|
+
jsonlContent := `{"id": 1, "name": "Alice"}
|
|
873
|
+
{"id": 2, "name": "Bob"}
|
|
874
|
+
{"id": 3, "name": "Charlie"}`
|
|
875
|
+
result := handler.summarizeJSONL(jsonlContent)
|
|
876
|
+
if !strings.Contains(result, "3 lines") {
|
|
877
|
+
t.Error("JSONL summary should contain line count")
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// ─── Config Parsing Tests ───────────────────────────────────────────────────
|
|
882
|
+
|
|
883
|
+
func TestConfigFromMap_LCM(t *testing.T) {
|
|
884
|
+
configMap := map[string]interface{}{
|
|
885
|
+
"lcm": map[string]interface{}{
|
|
886
|
+
"enabled": true,
|
|
887
|
+
"soft_threshold": 50000.0,
|
|
888
|
+
"hard_threshold": 80000.0,
|
|
889
|
+
"compaction_block_size": 8.0,
|
|
890
|
+
"summary_target_tokens": 300.0,
|
|
891
|
+
},
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
config := ConfigFromMap(configMap)
|
|
895
|
+
if config.LCM == nil {
|
|
896
|
+
t.Fatal("LCM config should not be nil")
|
|
897
|
+
}
|
|
898
|
+
if !config.LCM.Enabled {
|
|
899
|
+
t.Error("LCM should be enabled")
|
|
900
|
+
}
|
|
901
|
+
if config.LCM.SoftThreshold != 50000 {
|
|
902
|
+
t.Errorf("SoftThreshold = %d, want 50000", config.LCM.SoftThreshold)
|
|
903
|
+
}
|
|
904
|
+
if config.LCM.HardThreshold != 80000 {
|
|
905
|
+
t.Errorf("HardThreshold = %d, want 80000", config.LCM.HardThreshold)
|
|
906
|
+
}
|
|
907
|
+
if config.LCM.CompactionBlockSize != 8 {
|
|
908
|
+
t.Errorf("CompactionBlockSize = %d, want 8", config.LCM.CompactionBlockSize)
|
|
909
|
+
}
|
|
910
|
+
if config.LCM.SummaryTargetTokens != 300 {
|
|
911
|
+
t.Errorf("SummaryTargetTokens = %d, want 300", config.LCM.SummaryTargetTokens)
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
func TestConfigFromMap_LCM_NotPresent(t *testing.T) {
|
|
916
|
+
configMap := map[string]interface{}{
|
|
917
|
+
"api_key": "test-key",
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
config := ConfigFromMap(configMap)
|
|
921
|
+
if config.LCM != nil {
|
|
922
|
+
t.Error("LCM config should be nil when not present")
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
// ─── Integration: ActiveContextItem Tests ───────────────────────────────────
|
|
927
|
+
|
|
928
|
+
func TestActiveContextItem_GetTokens(t *testing.T) {
|
|
929
|
+
msg := &StoreMessage{Content: "test", Tokens: 10}
|
|
930
|
+
item := &ActiveContextItem{Message: msg}
|
|
931
|
+
if item.GetTokens() != 10 {
|
|
932
|
+
t.Errorf("GetTokens = %d, want 10", item.GetTokens())
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
sum := &SummaryNode{Content: "summary", Tokens: 5}
|
|
936
|
+
sumItem := &ActiveContextItem{Summary: sum}
|
|
937
|
+
if sumItem.GetTokens() != 5 {
|
|
938
|
+
t.Errorf("GetTokens = %d, want 5", sumItem.GetTokens())
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
empty := &ActiveContextItem{}
|
|
942
|
+
if empty.GetTokens() != 0 {
|
|
943
|
+
t.Errorf("GetTokens = %d, want 0", empty.GetTokens())
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
func TestActiveContextItem_GetContent(t *testing.T) {
|
|
948
|
+
msg := &StoreMessage{Content: "hello"}
|
|
949
|
+
item := &ActiveContextItem{Message: msg}
|
|
950
|
+
if item.GetContent() != "hello" {
|
|
951
|
+
t.Errorf("GetContent = %s, want 'hello'", item.GetContent())
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
sum := &SummaryNode{Content: "summary text"}
|
|
955
|
+
sumItem := &ActiveContextItem{Summary: sum}
|
|
956
|
+
if sumItem.GetContent() != "summary text" {
|
|
957
|
+
t.Errorf("GetContent = %s, want 'summary text'", sumItem.GetContent())
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
func TestActiveContextItem_GetFileIDs(t *testing.T) {
|
|
962
|
+
msg := &StoreMessage{FileIDs: []string{"f1", "f2"}}
|
|
963
|
+
item := &ActiveContextItem{Message: msg}
|
|
964
|
+
if len(item.GetFileIDs()) != 2 {
|
|
965
|
+
t.Errorf("GetFileIDs length = %d, want 2", len(item.GetFileIDs()))
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
sum := &SummaryNode{FileIDs: []string{"f3"}}
|
|
969
|
+
sumItem := &ActiveContextItem{Summary: sum}
|
|
970
|
+
if len(sumItem.GetFileIDs()) != 1 {
|
|
971
|
+
t.Errorf("GetFileIDs length = %d, want 1", len(sumItem.GetFileIDs()))
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
func TestIsDefinitionLine(t *testing.T) {
|
|
976
|
+
tests := []struct {
|
|
977
|
+
line string
|
|
978
|
+
ext string
|
|
979
|
+
want bool
|
|
980
|
+
}{
|
|
981
|
+
{"func main() {", ".go", true},
|
|
982
|
+
{"type Config struct {", ".go", true},
|
|
983
|
+
{"def hello():", ".py", true},
|
|
984
|
+
{"class MyClass:", ".py", true},
|
|
985
|
+
{"export function foo() {", ".ts", true},
|
|
986
|
+
{"interface Props {", ".ts", true},
|
|
987
|
+
{"fn main() {", ".rs", true},
|
|
988
|
+
{"// just a comment", ".go", false},
|
|
989
|
+
{"x := 5", ".go", false},
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
for _, tt := range tests {
|
|
993
|
+
got := isDefinitionLine(tt.line, tt.ext)
|
|
994
|
+
if got != tt.want {
|
|
995
|
+
t.Errorf("isDefinitionLine(%q, %q) = %v, want %v", tt.line, tt.ext, got, tt.want)
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
func TestIsImportLine(t *testing.T) {
|
|
1001
|
+
tests := []struct {
|
|
1002
|
+
line string
|
|
1003
|
+
ext string
|
|
1004
|
+
want bool
|
|
1005
|
+
}{
|
|
1006
|
+
{`import "fmt"`, ".go", true},
|
|
1007
|
+
{`import os`, ".py", true},
|
|
1008
|
+
{`from datetime import date`, ".py", true},
|
|
1009
|
+
{`import { useState } from 'react'`, ".ts", true},
|
|
1010
|
+
{`use std::io`, ".rs", true},
|
|
1011
|
+
{`// import comment`, ".go", false},
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
for _, tt := range tests {
|
|
1015
|
+
got := isImportLine(tt.line, tt.ext)
|
|
1016
|
+
if got != tt.want {
|
|
1017
|
+
t.Errorf("isImportLine(%q, %q) = %v, want %v", tt.line, tt.ext, got, tt.want)
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
// ─── Delegation Guard Tests ─────────────────────────────────────────────────
|
|
1023
|
+
|
|
1024
|
+
func TestDelegationGuard_RootAgentAlwaysAllowed(t *testing.T) {
|
|
1025
|
+
obs := NewNoopObserver()
|
|
1026
|
+
guard := NewDelegationGuard(obs)
|
|
1027
|
+
|
|
1028
|
+
err := guard.ValidateDelegation(0, DelegationRequest{
|
|
1029
|
+
Prompt: "Do all the work",
|
|
1030
|
+
// No scope or kept_work needed for root
|
|
1031
|
+
})
|
|
1032
|
+
if err != nil {
|
|
1033
|
+
t.Errorf("Root agent should always be allowed, got: %v", err)
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
func TestDelegationGuard_ReadOnlyExempt(t *testing.T) {
|
|
1038
|
+
obs := NewNoopObserver()
|
|
1039
|
+
guard := NewDelegationGuard(obs)
|
|
1040
|
+
|
|
1041
|
+
err := guard.ValidateDelegation(2, DelegationRequest{
|
|
1042
|
+
Prompt: "Read some files",
|
|
1043
|
+
ReadOnly: true,
|
|
1044
|
+
})
|
|
1045
|
+
if err != nil {
|
|
1046
|
+
t.Errorf("Read-only agents should be exempt, got: %v", err)
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
func TestDelegationGuard_ParallelExempt(t *testing.T) {
|
|
1051
|
+
obs := NewNoopObserver()
|
|
1052
|
+
guard := NewDelegationGuard(obs)
|
|
1053
|
+
|
|
1054
|
+
err := guard.ValidateDelegation(1, DelegationRequest{
|
|
1055
|
+
Prompt: "Process chunk A",
|
|
1056
|
+
Parallel: true,
|
|
1057
|
+
})
|
|
1058
|
+
if err != nil {
|
|
1059
|
+
t.Errorf("Parallel decomposition should be exempt, got: %v", err)
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
func TestDelegationGuard_MissingDelegatedScope(t *testing.T) {
|
|
1064
|
+
obs := NewNoopObserver()
|
|
1065
|
+
guard := NewDelegationGuard(obs)
|
|
1066
|
+
|
|
1067
|
+
err := guard.ValidateDelegation(1, DelegationRequest{
|
|
1068
|
+
Prompt: "Do something",
|
|
1069
|
+
KeptWork: "I'll aggregate",
|
|
1070
|
+
})
|
|
1071
|
+
if err == nil {
|
|
1072
|
+
t.Error("Should reject when delegated_scope is missing")
|
|
1073
|
+
}
|
|
1074
|
+
delegErr, ok := err.(*DelegationError)
|
|
1075
|
+
if !ok {
|
|
1076
|
+
t.Fatalf("Expected DelegationError, got %T", err)
|
|
1077
|
+
}
|
|
1078
|
+
if delegErr.Reason == "" {
|
|
1079
|
+
t.Error("DelegationError should have a reason")
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
func TestDelegationGuard_MissingKeptWork(t *testing.T) {
|
|
1084
|
+
obs := NewNoopObserver()
|
|
1085
|
+
guard := NewDelegationGuard(obs)
|
|
1086
|
+
|
|
1087
|
+
err := guard.ValidateDelegation(1, DelegationRequest{
|
|
1088
|
+
Prompt: "Do something",
|
|
1089
|
+
DelegatedScope: "Parse the files",
|
|
1090
|
+
})
|
|
1091
|
+
if err == nil {
|
|
1092
|
+
t.Error("Should reject when kept_work is missing")
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
func TestDelegationGuard_TotalDelegation(t *testing.T) {
|
|
1097
|
+
obs := NewNoopObserver()
|
|
1098
|
+
guard := NewDelegationGuard(obs)
|
|
1099
|
+
|
|
1100
|
+
err := guard.ValidateDelegation(1, DelegationRequest{
|
|
1101
|
+
Prompt: "Implement the entire feature including tests and documentation",
|
|
1102
|
+
DelegatedScope: "Implement the entire feature including tests and documentation",
|
|
1103
|
+
KeptWork: "none",
|
|
1104
|
+
})
|
|
1105
|
+
if err == nil {
|
|
1106
|
+
t.Error("Should reject total delegation (kept_work = 'none')")
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
func TestDelegationGuard_TotalDelegation_TrivialKeptWork(t *testing.T) {
|
|
1111
|
+
obs := NewNoopObserver()
|
|
1112
|
+
guard := NewDelegationGuard(obs)
|
|
1113
|
+
|
|
1114
|
+
trivialKeptWorks := []string{"nothing", "n/a", "waiting", "collect results", "return results"}
|
|
1115
|
+
for _, kw := range trivialKeptWorks {
|
|
1116
|
+
err := guard.ValidateDelegation(1, DelegationRequest{
|
|
1117
|
+
Prompt: "Do all the work",
|
|
1118
|
+
DelegatedScope: "Implement everything from scratch with full testing",
|
|
1119
|
+
KeptWork: kw,
|
|
1120
|
+
})
|
|
1121
|
+
if err == nil {
|
|
1122
|
+
t.Errorf("Should reject trivial kept_work %q", kw)
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
func TestDelegationGuard_ValidDelegation(t *testing.T) {
|
|
1128
|
+
obs := NewNoopObserver()
|
|
1129
|
+
guard := NewDelegationGuard(obs)
|
|
1130
|
+
|
|
1131
|
+
err := guard.ValidateDelegation(1, DelegationRequest{
|
|
1132
|
+
Prompt: "Parse the configuration files and extract database settings",
|
|
1133
|
+
DelegatedScope: "Parse configuration files in the config/ directory",
|
|
1134
|
+
KeptWork: "Validate extracted settings against the schema and apply migrations",
|
|
1135
|
+
})
|
|
1136
|
+
if err != nil {
|
|
1137
|
+
t.Errorf("Valid delegation should be allowed, got: %v", err)
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
func TestDelegationGuard_TotalDelegation_ShortKeptWork(t *testing.T) {
|
|
1142
|
+
obs := NewNoopObserver()
|
|
1143
|
+
guard := NewDelegationGuard(obs)
|
|
1144
|
+
|
|
1145
|
+
err := guard.ValidateDelegation(1, DelegationRequest{
|
|
1146
|
+
Prompt: "Implement the full authentication system with OAuth2, JWT tokens, refresh tokens, and rate limiting",
|
|
1147
|
+
DelegatedScope: "Implement the full authentication system with OAuth2, JWT tokens, refresh tokens, and rate limiting",
|
|
1148
|
+
KeptWork: "ok",
|
|
1149
|
+
})
|
|
1150
|
+
if err == nil {
|
|
1151
|
+
t.Error("Should reject when kept_work is suspiciously short compared to scope")
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
func TestIsTotalDelegation(t *testing.T) {
|
|
1156
|
+
tests := []struct {
|
|
1157
|
+
scope string
|
|
1158
|
+
kept string
|
|
1159
|
+
want bool
|
|
1160
|
+
}{
|
|
1161
|
+
{"big task", "none", true},
|
|
1162
|
+
{"big task", "nothing", true},
|
|
1163
|
+
{"big task", "n/a", true},
|
|
1164
|
+
{"big task", "waiting", true},
|
|
1165
|
+
{"big task", "I will validate the output and merge the results", false},
|
|
1166
|
+
{"parse files", "aggregate and report", false},
|
|
1167
|
+
{"long scope description here with many details", "ok", true},
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
for _, tt := range tests {
|
|
1171
|
+
got := isTotalDelegation(tt.scope, tt.kept)
|
|
1172
|
+
if got != tt.want {
|
|
1173
|
+
t.Errorf("isTotalDelegation(%q, %q) = %v, want %v", tt.scope, tt.kept, got, tt.want)
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
// ─── ExpandSummary Restriction Tests ────────────────────────────────────────
|
|
1179
|
+
|
|
1180
|
+
func TestExpandSummaryRestricted_RootAgentBlocked(t *testing.T) {
|
|
1181
|
+
store := NewLCMStore("test")
|
|
1182
|
+
msg := store.PersistMessage(RoleUser, "Hello", nil)
|
|
1183
|
+
summary := store.CreateLeafSummary([]string{msg.ID}, "Greeting", 1)
|
|
1184
|
+
|
|
1185
|
+
_, err := store.ExpandSummaryRestricted(summary.ID, 0)
|
|
1186
|
+
if err == nil {
|
|
1187
|
+
t.Error("Root agent (depth 0) should be blocked from expanding summaries")
|
|
1188
|
+
}
|
|
1189
|
+
expandErr, ok := err.(*ExpandRestrictionError)
|
|
1190
|
+
if !ok {
|
|
1191
|
+
t.Fatalf("Expected ExpandRestrictionError, got %T", err)
|
|
1192
|
+
}
|
|
1193
|
+
if expandErr.SummaryID != summary.ID {
|
|
1194
|
+
t.Errorf("SummaryID = %s, want %s", expandErr.SummaryID, summary.ID)
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
func TestExpandSummaryRestricted_SubAgentAllowed(t *testing.T) {
|
|
1199
|
+
store := NewLCMStore("test")
|
|
1200
|
+
msg := store.PersistMessage(RoleUser, "Hello", nil)
|
|
1201
|
+
summary := store.CreateLeafSummary([]string{msg.ID}, "Greeting", 1)
|
|
1202
|
+
|
|
1203
|
+
msgs, err := store.ExpandSummaryRestricted(summary.ID, 1)
|
|
1204
|
+
if err != nil {
|
|
1205
|
+
t.Fatalf("Sub-agent (depth 1) should be allowed, got: %v", err)
|
|
1206
|
+
}
|
|
1207
|
+
if len(msgs) != 1 {
|
|
1208
|
+
t.Errorf("Expected 1 expanded message, got %d", len(msgs))
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
func TestExpandSummaryRestricted_DeepSubAgentAllowed(t *testing.T) {
|
|
1213
|
+
store := NewLCMStore("test")
|
|
1214
|
+
msg := store.PersistMessage(RoleUser, "Hello", nil)
|
|
1215
|
+
summary := store.CreateLeafSummary([]string{msg.ID}, "Greeting", 1)
|
|
1216
|
+
|
|
1217
|
+
_, err := store.ExpandSummaryRestricted(summary.ID, 5)
|
|
1218
|
+
if err != nil {
|
|
1219
|
+
t.Errorf("Deep sub-agent (depth 5) should be allowed, got: %v", err)
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
// ─── LCM Completion Integration Tests ───────────────────────────────────────
|
|
1224
|
+
|
|
1225
|
+
func TestLCMEngine_CompletionIntegration_StorePopulated(t *testing.T) {
|
|
1226
|
+
// Test that the LCM engine populates the store when used in completion
|
|
1227
|
+
config := Config{
|
|
1228
|
+
APIKey: "test-key",
|
|
1229
|
+
MaxDepth: 3,
|
|
1230
|
+
MaxIterations: 5,
|
|
1231
|
+
LCM: &LCMConfig{
|
|
1232
|
+
Enabled: true,
|
|
1233
|
+
SoftThreshold: 100000,
|
|
1234
|
+
HardThreshold: 200000,
|
|
1235
|
+
CompactionBlockSize: 10,
|
|
1236
|
+
SummaryTargetTokens: 500,
|
|
1237
|
+
},
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
engine := New("test-model", config)
|
|
1241
|
+
defer engine.Shutdown()
|
|
1242
|
+
|
|
1243
|
+
// Verify LCM engine is set up
|
|
1244
|
+
if engine.GetLCMEngine() == nil {
|
|
1245
|
+
t.Fatal("LCM engine should be initialized when config.LCM.Enabled = true")
|
|
1246
|
+
}
|
|
1247
|
+
if !engine.GetLCMEngine().IsEnabled() {
|
|
1248
|
+
t.Error("LCM engine should be enabled")
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
store := engine.GetLCMEngine().GetStore()
|
|
1252
|
+
if store.MessageCount() != 0 {
|
|
1253
|
+
t.Errorf("Store should be empty initially, got %d messages", store.MessageCount())
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
func TestLCMEngine_NotInitializedWhenDisabled(t *testing.T) {
|
|
1258
|
+
config := Config{
|
|
1259
|
+
APIKey: "test-key",
|
|
1260
|
+
MaxDepth: 3,
|
|
1261
|
+
MaxIterations: 5,
|
|
1262
|
+
// No LCM config
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
engine := New("test-model", config)
|
|
1266
|
+
defer engine.Shutdown()
|
|
1267
|
+
|
|
1268
|
+
if engine.GetLCMEngine() != nil {
|
|
1269
|
+
t.Error("LCM engine should be nil when not configured")
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
func TestLCMEngine_NotInitializedWhenExplicitlyDisabled(t *testing.T) {
|
|
1274
|
+
config := Config{
|
|
1275
|
+
APIKey: "test-key",
|
|
1276
|
+
MaxDepth: 3,
|
|
1277
|
+
MaxIterations: 5,
|
|
1278
|
+
LCM: &LCMConfig{Enabled: false},
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
engine := New("test-model", config)
|
|
1282
|
+
defer engine.Shutdown()
|
|
1283
|
+
|
|
1284
|
+
if engine.GetLCMEngine() != nil {
|
|
1285
|
+
t.Error("LCM engine should be nil when explicitly disabled")
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
// ─── Agentic-Map Tests ──────────────────────────────────────────────────────
|
|
1290
|
+
|
|
1291
|
+
func TestAgenticMapper_DefaultConfig(t *testing.T) {
|
|
1292
|
+
config := AgenticMapConfig{
|
|
1293
|
+
InputPath: "/tmp/input.jsonl",
|
|
1294
|
+
OutputPath: "/tmp/output.jsonl",
|
|
1295
|
+
Prompt: "Process {{item}}",
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
// Verify defaults are applied
|
|
1299
|
+
if config.Concurrency <= 0 {
|
|
1300
|
+
config.Concurrency = 8
|
|
1301
|
+
}
|
|
1302
|
+
if config.MaxRetries <= 0 {
|
|
1303
|
+
config.MaxRetries = 2
|
|
1304
|
+
}
|
|
1305
|
+
if config.MaxDepth <= 0 {
|
|
1306
|
+
config.MaxDepth = 3
|
|
1307
|
+
}
|
|
1308
|
+
if config.MaxIter <= 0 {
|
|
1309
|
+
config.MaxIter = 15
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
if config.Concurrency != 8 {
|
|
1313
|
+
t.Errorf("Default concurrency = %d, want 8", config.Concurrency)
|
|
1314
|
+
}
|
|
1315
|
+
if config.MaxRetries != 2 {
|
|
1316
|
+
t.Errorf("Default MaxRetries = %d, want 2", config.MaxRetries)
|
|
1317
|
+
}
|
|
1318
|
+
if config.MaxDepth != 3 {
|
|
1319
|
+
t.Errorf("Default MaxDepth = %d, want 3", config.MaxDepth)
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
func TestAgenticMapper_WriteOutput(t *testing.T) {
|
|
1324
|
+
tmpDir := t.TempDir()
|
|
1325
|
+
outputPath := filepath.Join(tmpDir, "agentic_output.jsonl")
|
|
1326
|
+
|
|
1327
|
+
results := []AgenticItemResult{
|
|
1328
|
+
{Index: 0, Status: MapItemCompleted, Output: json.RawMessage(`{"result": "ok"}`), LLMCalls: 3, Iterations: 2},
|
|
1329
|
+
{Index: 1, Status: MapItemFailed, Error: "sub-agent timed out", LLMCalls: 5, Iterations: 5},
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
err := writeAgenticOutput(outputPath, results)
|
|
1333
|
+
if err != nil {
|
|
1334
|
+
t.Fatalf("writeAgenticOutput error: %v", err)
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
data, err := os.ReadFile(outputPath)
|
|
1338
|
+
if err != nil {
|
|
1339
|
+
t.Fatal(err)
|
|
1340
|
+
}
|
|
1341
|
+
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
|
|
1342
|
+
if len(lines) != 2 {
|
|
1343
|
+
t.Errorf("Output has %d lines, want 2", len(lines))
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
// Verify error record includes metadata
|
|
1347
|
+
var errRecord map[string]interface{}
|
|
1348
|
+
_ = json.Unmarshal([]byte(lines[1]), &errRecord)
|
|
1349
|
+
if errRecord["_error"] != "sub-agent timed out" {
|
|
1350
|
+
t.Errorf("Error record missing error, got: %v", errRecord)
|
|
1351
|
+
}
|
|
1352
|
+
if errRecord["_llm_calls"] != float64(5) {
|
|
1353
|
+
t.Errorf("Error record missing llm_calls, got: %v", errRecord["_llm_calls"])
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
// ─── Balanced Brace Extraction Tests ────────────────────────────────────────
|
|
1358
|
+
|
|
1359
|
+
func TestExtractBalancedBraces(t *testing.T) {
|
|
1360
|
+
tests := []struct {
|
|
1361
|
+
input string
|
|
1362
|
+
startChar byte
|
|
1363
|
+
want string
|
|
1364
|
+
}{
|
|
1365
|
+
{`{"key": "value"}`, '{', `{"key": "value"}`},
|
|
1366
|
+
{`{"key": "value"} extra`, '{', `{"key": "value"}`},
|
|
1367
|
+
{`{"nested": {"inner": true}}`, '{', `{"nested": {"inner": true}}`},
|
|
1368
|
+
{`[1, 2, 3]`, '[', `[1, 2, 3]`},
|
|
1369
|
+
{`[1, [2, 3], 4] trailing`, '[', `[1, [2, 3], 4]`},
|
|
1370
|
+
{`{"str": "has \" escaped"}`, '{', `{"str": "has \" escaped"}`},
|
|
1371
|
+
{`{unclosed`, '{', ``},
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
for _, tt := range tests {
|
|
1375
|
+
got := ExtractBalancedBraces(tt.input, tt.startChar)
|
|
1376
|
+
if got != tt.want {
|
|
1377
|
+
t.Errorf("ExtractBalancedBraces(%q, %q) = %q, want %q", tt.input, string(tt.startChar), got, tt.want)
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
// ─── DelegationError Tests ──────────────────────────────────────────────────
|
|
1383
|
+
|
|
1384
|
+
func TestDelegationError_ErrorMessage(t *testing.T) {
|
|
1385
|
+
err := &DelegationError{
|
|
1386
|
+
Reason: "test reason",
|
|
1387
|
+
Suggestion: "try something else",
|
|
1388
|
+
}
|
|
1389
|
+
msg := err.Error()
|
|
1390
|
+
if !strings.Contains(msg, "test reason") {
|
|
1391
|
+
t.Error("Error message should contain reason")
|
|
1392
|
+
}
|
|
1393
|
+
if !strings.Contains(msg, "try something else") {
|
|
1394
|
+
t.Error("Error message should contain suggestion")
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
func TestExpandRestrictionError_ErrorMessage(t *testing.T) {
|
|
1399
|
+
err := &ExpandRestrictionError{SummaryID: "sum_123"}
|
|
1400
|
+
msg := err.Error()
|
|
1401
|
+
if !strings.Contains(msg, "sum_123") {
|
|
1402
|
+
t.Error("Error message should contain summary ID")
|
|
1403
|
+
}
|
|
1404
|
+
if !strings.Contains(msg, "sub-agent") {
|
|
1405
|
+
t.Error("Error message should mention sub-agents")
|
|
1406
|
+
}
|
|
1407
|
+
}
|