返回 DeepSeek-Reasonix
guardian_test.go
根目录 / internal / guardian / guardian_test.go
1 package guardian
2
3 import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "os"
8 "path/filepath"
9 "reflect"
10 "slices"
11 "strings"
12 "sync"
13 "testing"
14
15 "reasonix/internal/agent"
16 "reasonix/internal/event"
17 "reasonix/internal/provider"
18 "reasonix/internal/tool"
19 )
20
21 type scriptedProvider struct {
22 mu sync.Mutex
23 responses []scriptedResponse
24 requests []provider.Request
25 defaultUsage *provider.Usage
26 }
27
28 type scriptedResponse struct {
29 text string
30 reasoning string
31 usage *provider.Usage
32 err error
33 }
34
35 type reasoningScriptedProvider struct{ *scriptedProvider }
36
37 func (*reasoningScriptedProvider) RequiresToolCallReasoning() bool { return true }
38
39 func (p *scriptedProvider) Name() string { return "guardian-test" }
40
41 func (p *scriptedProvider) Stream(ctx context.Context, req provider.Request) (<-chan provider.Chunk, error) {
42 p.mu.Lock()
43 p.requests = append(p.requests, req)
44 resp := scriptedResponse{text: `{"risk_level":"low","user_authorization":"high","outcome":"allow","rationale":"ok"}`}
45 if len(p.responses) > 0 {
46 resp = p.responses[0]
47 p.responses = p.responses[1:]
48 }
49 p.mu.Unlock()
50 if resp.usage == nil && p.defaultUsage != nil {
51 usage := *p.defaultUsage
52 resp.usage = &usage
53 }
54
55 ch := make(chan provider.Chunk, 4)
56 if resp.err != nil {
57 close(ch)
58 return nil, resp.err
59 }
60 if resp.reasoning != "" {
61 ch <- provider.Chunk{Type: provider.ChunkReasoning, Text: resp.reasoning}
62 }
63 if resp.text != "" {
64 ch <- provider.Chunk{Type: provider.ChunkText, Text: resp.text}
65 }
66 if resp.usage != nil {
67 ch <- provider.Chunk{Type: provider.ChunkUsage, Usage: resp.usage}
68 }
69 ch <- provider.Chunk{Type: provider.ChunkDone}
70 close(ch)
71 return ch, nil
72 }
73
74 func (p *scriptedProvider) requestsSnapshot() []provider.Request {
75 p.mu.Lock()
76 defer p.mu.Unlock()
77 return append([]provider.Request(nil), p.requests...)
78 }
79
80 type captureSink struct {
81 mu sync.Mutex
82 events []event.Event
83 }
84
85 func (s *captureSink) Emit(e event.Event) {
86 s.mu.Lock()
87 defer s.mu.Unlock()
88 s.events = append(s.events, e)
89 }
90
91 func (s *captureSink) guardianEvents() []event.Event {
92 s.mu.Lock()
93 defer s.mu.Unlock()
94 var out []event.Event
95 for _, e := range s.events {
96 if e.Kind == event.GuardianAssessment {
97 out = append(out, e)
98 }
99 }
100 return out
101 }
102
103 func TestParseAssessmentEnforcesCriticalDeny(t *testing.T) {
104 a, err := ParseAssessment(`{"risk_level":"critical","user_authorization":"high","outcome":"allow","rationale":"delete prod secrets"}`)
105 if err != nil {
106 t.Fatalf("ParseAssessment error: %v", err)
107 }
108 if a.Outcome != "deny" {
109 t.Fatalf("critical outcome = %q, want deny", a.Outcome)
110 }
111 }
112
113 func TestParseAssessmentRejectsUnknownEnum(t *testing.T) {
114 if _, err := ParseAssessment(`{"risk_level":"spicy","user_authorization":"high","outcome":"allow","rationale":"x"}`); err == nil {
115 t.Fatal("ParseAssessment accepted unknown risk_level")
116 }
117 }
118
119 func TestGuardianReasoningOnlyStopRetriesInsteadOfReusingPriorVerdict(t *testing.T) {
120 base := &scriptedProvider{responses: []scriptedResponse{
121 {text: `{"risk_level":"low","user_authorization":"high","outcome":"allow","rationale":"first action is safe"}`},
122 {reasoning: "The second action is dangerous and must be denied.", usage: &provider.Usage{FinishReason: "stop"}},
123 {text: `{"risk_level":"high","user_authorization":"low","outcome":"deny","rationale":"current action is unsafe"}`},
124 }}
125 prov := &reasoningScriptedProvider{scriptedProvider: base}
126 gs := NewSession(prov, tool.NewRegistry(), PolicyPrompt(), "guardian-test", 0, nil, &captureSink{})
127 parent := agent.NewSession("sys")
128 parent.Add(provider.Message{Role: provider.RoleUser, Content: "review two different actions"})
129
130 if allow, _, err := gs.ReviewVerdict(context.Background(), "read_file", json.RawMessage(`{"file_path":"a.txt"}`), parent); err != nil || !allow {
131 t.Fatalf("first ReviewVerdict = allow %v err %v, want allow nil", allow, err)
132 }
133 allow, reason, err := gs.ReviewVerdict(context.Background(), "bash", json.RawMessage(`{"command":"dangerous command"}`), parent)
134 if err != nil || allow {
135 t.Fatalf("second ReviewVerdict = allow %v reason %q err %v, want deny with authentic verdict", allow, reason, err)
136 }
137 if !strings.Contains(reason, "current action is unsafe") {
138 t.Fatalf("second denial reason = %q, want current verdict rationale", reason)
139 }
140 if got := len(base.requestsSnapshot()); got != 3 {
141 t.Fatalf("provider requests = %d, want 3 (allow, reasoning-only, visible retry)", got)
142 }
143 }
144
145 func TestGuardianRepeatedReasoningOnlyStopsFailClosedWithoutReusingPriorAllow(t *testing.T) {
146 base := &scriptedProvider{responses: []scriptedResponse{
147 {text: `{"risk_level":"low","user_authorization":"high","outcome":"allow","rationale":"first action is safe"}`},
148 {reasoning: "dangerous review 1", usage: &provider.Usage{FinishReason: "stop"}},
149 {reasoning: "dangerous review 2", usage: &provider.Usage{FinishReason: "stop"}},
150 {reasoning: "dangerous review 3", usage: &provider.Usage{FinishReason: "stop"}},
151 }}
152 prov := &reasoningScriptedProvider{scriptedProvider: base}
153 gs := NewSession(prov, tool.NewRegistry(), PolicyPrompt(), "guardian-test", 0, nil, &captureSink{})
154 parent := agent.NewSession("sys")
155 parent.Add(provider.Message{Role: provider.RoleUser, Content: "review two different actions"})
156
157 if allow, _, err := gs.ReviewVerdict(context.Background(), "read_file", json.RawMessage(`{"file_path":"a.txt"}`), parent); err != nil || !allow {
158 t.Fatalf("first ReviewVerdict = allow %v err %v, want allow nil", allow, err)
159 }
160 before := gs.sess.Snapshot()
161 allow, _, err := gs.ReviewVerdict(context.Background(), "bash", json.RawMessage(`{"command":"dangerous command"}`), parent)
162 if allow || err == nil {
163 t.Fatalf("second ReviewVerdict = allow %v err %v, want fail-closed error", allow, err)
164 }
165 if got := len(base.requestsSnapshot()); got != 4 {
166 t.Fatalf("provider requests = %d, want 4 (prior allow plus three bounded empty finals)", got)
167 }
168 if after := gs.sess.Snapshot(); !reflect.DeepEqual(after, before) {
169 t.Fatalf("failed reasoning-only review did not roll back session:\nbefore=%+v\nafter=%+v", before, after)
170 }
171
172 // A fresh review after the bounded failure must start from the prior clean
173 // verdict, not from any of the discarded retry turns. The scripted
174 // provider's default response is a visible allow verdict.
175 if allow, _, err := gs.ReviewVerdict(context.Background(), "read_file", json.RawMessage(`{"file_path":"c.txt"}`), parent); err != nil || !allow {
176 t.Fatalf("review after rollback = allow %v err %v, want allow nil", allow, err)
177 }
178 reqs := base.requestsSnapshot()
179 if got := len(reqs); got != 5 {
180 t.Fatalf("provider requests after recovery = %d, want 5", got)
181 }
182 last := reqs[len(reqs)-1]
183 for i := 1; i < len(last.Messages); i++ {
184 if last.Messages[i].Role == provider.RoleUser && last.Messages[i-1].Role == provider.RoleUser {
185 t.Fatalf("request after reasoning-only rollback carries consecutive user messages at index %d", i)
186 }
187 }
188 }
189
190 func TestGuardianRollbackAfterRewriteDropsReasoningOnlyRetryTail(t *testing.T) {
191 gs := NewSession(&scriptedProvider{}, tool.NewRegistry(), PolicyPrompt(), "guardian-test", 0, nil, &captureSink{})
192 before := gs.sess.Snapshot()
193 rewriteBefore := gs.sess.RewriteVersion()
194
195 compactedWithEvidence := []provider.Message{
196 {Role: provider.RoleSystem, Content: PolicyPrompt()},
197 {Role: provider.RoleUser, Content: "<compaction-summary>\ncompacted reviews\n</compaction-summary>"},
198 {Role: provider.RoleAssistant, Content: `{"risk_level":"low","user_authorization":"high","outcome":"allow","rationale":"preserved verdict"}`},
199 {Role: provider.RoleUser, Content: "review the current action"},
200 {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "read-1", Name: "read_file", Arguments: `{"path":"policy.md"}`}}},
201 {Role: provider.RoleTool, ToolCallID: "read-1", Name: "read_file", Content: "preserved read-only evidence"},
202 }
203 gs.sess.Replace(compactedWithEvidence)
204 gs.sess.IncrementRewrite()
205 gs.sess.Add(provider.Message{Role: provider.RoleAssistant, ReasoningContent: "first hidden-only verdict"})
206 gs.sess.Add(provider.Message{Role: provider.RoleUser, Content: "provide a visible verdict"})
207 gs.sess.Add(provider.Message{Role: provider.RoleAssistant, ReasoningContent: "second hidden-only verdict"})
208 gs.sess.Add(provider.Message{Role: provider.RoleUser, Content: "do not call tools; provide a visible verdict"})
209 gs.sess.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "unpaired-1", Name: "read_file", Arguments: `{"path":"more.md"}`}}})
210
211 gs.rollbackReview(before, rewriteBefore)
212
213 got := gs.sess.Snapshot()
214 if !reflect.DeepEqual(got, compactedWithEvidence) {
215 t.Fatalf("rewrite-aware rollback changed compacted or completed tool evidence:\n got=%+v\nwant=%+v", got, compactedWithEvidence)
216 }
217 if normalized := provider.NormalizeMessages(got); !reflect.DeepEqual(normalized, got) {
218 t.Fatalf("preserved guardian history is not provider-coherent:\n got=%+v\nnormalized=%+v", got, normalized)
219 }
220 }
221
222 func TestTranscriptRenderKeepsFirstAndLastUserAnchors(t *testing.T) {
223 entries := []TranscriptEntry{{Kind: "user", Text: "first task"}}
224 for range maxRecentEntries + 5 {
225 entries = append(entries, TranscriptEntry{Kind: "assistant", Text: "assistant detail"})
226 }
227 entries = append(entries, TranscriptEntry{Kind: "user", Text: "latest instruction"})
228 rendered := FormatTranscript(entries)
229 if !strings.Contains(rendered, "first task") || !strings.Contains(rendered, "latest instruction") {
230 t.Fatalf("rendered transcript lost user anchors:\n%s", rendered)
231 }
232 if !strings.Contains(rendered, "Some conversation entries were omitted.") {
233 t.Fatalf("rendered transcript should mention omissions:\n%s", rendered)
234 }
235 }
236
237 func TestGuardianSaveLoadRestoresCursorForDeltaTranscript(t *testing.T) {
238 prov := &scriptedProvider{responses: []scriptedResponse{
239 {text: `{"risk_level":"low","user_authorization":"high","outcome":"allow","rationale":"first ok"}`},
240 {text: `{"risk_level":"low","user_authorization":"high","outcome":"allow","rationale":"second ok"}`},
241 }}
242 sink := &captureSink{}
243 gs := NewSession(prov, tool.NewRegistry(), PolicyPrompt(), "guardian-test", 0, nil, sink)
244 parent := agent.NewSession("sys")
245 parent.Add(provider.Message{Role: provider.RoleUser, Content: "first user request"})
246
247 if allow, _, err := gs.Review(context.Background(), "write_file", json.RawMessage(`{"file_path":"a.txt"}`), parent); err != nil || !allow {
248 t.Fatalf("first Review = allow %v err %v, want allow nil", allow, err)
249 }
250 path := filepath.Join(t.TempDir(), "session.guardian.jsonl")
251 if err := gs.Save(path); err != nil {
252 t.Fatalf("Save error: %v", err)
253 }
254 if data, err := os.ReadFile(cursorPathForGuardianPath(path)); err != nil || !strings.Contains(string(data), `"EntryCount":1`) {
255 t.Fatalf("cursor sidecar = %q err %v, want EntryCount 1", data, err)
256 }
257
258 loaded := NewSession(prov, tool.NewRegistry(), PolicyPrompt(), "guardian-test", 0, nil, sink)
259 if err := loaded.Load(path); err != nil {
260 t.Fatalf("Load error: %v", err)
261 }
262 if loaded.cursor.EntryCount != 1 {
263 t.Fatalf("loaded cursor = %+v, want EntryCount 1", loaded.cursor)
264 }
265 parent.Add(provider.Message{Role: provider.RoleUser, Content: "second user request"})
266 if allow, _, err := loaded.Review(context.Background(), "write_file", json.RawMessage(`{"file_path":"b.txt"}`), parent); err != nil || !allow {
267 t.Fatalf("second Review = allow %v err %v, want allow nil", allow, err)
268 }
269
270 reqs := prov.requestsSnapshot()
271 if len(reqs) < 2 {
272 t.Fatalf("requests = %d, want >= 2", len(reqs))
273 }
274 var delta string
275 for _, req := range reqs {
276 for _, m := range req.Messages {
277 if strings.Contains(m.Content, "TRANSCRIPT DELTA") && strings.Contains(m.Content, "second user request") {
278 delta = m.Content
279 break
280 }
281 }
282 if delta != "" {
283 break
284 }
285 }
286 if delta == "" {
287 t.Fatalf("second request did not include a delta transcript")
288 }
289 if !strings.Contains(delta, "second user request") {
290 t.Fatalf("delta transcript missing new parent entry:\n%s", delta)
291 }
292 if strings.Contains(delta, "first user request") {
293 t.Fatalf("delta transcript repeated old parent entry:\n%s", delta)
294 }
295 }
296
297 func TestGuardianUsageDoesNotLeakAcrossReviews(t *testing.T) {
298 prov := &scriptedProvider{responses: []scriptedResponse{
299 {
300 text: `{"risk_level":"low","user_authorization":"high","outcome":"allow","rationale":"first ok"}`,
301 usage: &provider.Usage{PromptTokens: 10, CompletionTokens: 2, TotalTokens: 12},
302 },
303 {text: `{"risk_level":"low","user_authorization":"high","outcome":"allow","rationale":"second ok"}`},
304 }}
305 sink := &captureSink{}
306 gs := NewSession(prov, tool.NewRegistry(), PolicyPrompt(), "guardian-test", 0, nil, sink)
307 parent := agent.NewSession("sys")
308 parent.Add(provider.Message{Role: provider.RoleUser, Content: "do it"})
309
310 if allow, _, err := gs.Review(context.Background(), "write_file", json.RawMessage(`{"file_path":"a.txt"}`), parent); err != nil || !allow {
311 t.Fatalf("first Review = allow %v err %v, want allow nil", allow, err)
312 }
313 if allow, _, err := gs.Review(context.Background(), "write_file", json.RawMessage(`{"file_path":"b.txt"}`), parent); err != nil || !allow {
314 t.Fatalf("second Review = allow %v err %v, want allow nil", allow, err)
315 }
316
317 events := sink.guardianEvents()
318 if len(events) != 2 {
319 t.Fatalf("guardian events = %d, want 2", len(events))
320 }
321 if events[0].Guardian.Usage == nil || events[0].Guardian.Usage.TotalTokens != 12 {
322 t.Fatalf("first usage = %+v, want total 12", events[0].Guardian.Usage)
323 }
324 if events[1].Guardian.Usage != nil {
325 t.Fatalf("second usage leaked from first review: %+v", events[1].Guardian.Usage)
326 }
327 }
328
329 func TestGuardianUsageAggregatesEveryModelCall(t *testing.T) {
330 prov := &scriptedProvider{responses: []scriptedResponse{
331 {text: "", usage: &provider.Usage{PromptTokens: 3, CompletionTokens: 1, TotalTokens: 4}},
332 {text: `{"risk_level":"low","user_authorization":"high","outcome":"allow","rationale":"ok"}`, usage: &provider.Usage{PromptTokens: 5, CompletionTokens: 2, TotalTokens: 7, RequestCount: 2}},
333 }}
334 sink := &captureSink{}
335 gs := NewSession(prov, tool.NewRegistry(), PolicyPrompt(), "guardian-test", 0, nil, sink)
336 parent := agent.NewSession("sys")
337 parent.Add(provider.Message{Role: provider.RoleUser, Content: "do it"})
338
339 if allow, _, err := gs.Review(context.Background(), "write_file", json.RawMessage(`{"file_path":"a.txt"}`), parent); err != nil || !allow {
340 t.Fatalf("Review = allow %v err %v, want allow nil", allow, err)
341 }
342 events := sink.guardianEvents()
343 if len(events) != 1 || events[0].Guardian.Usage == nil {
344 t.Fatalf("guardian events = %+v, want one usage-bearing event", events)
345 }
346 usage := events[0].Guardian.Usage
347 if usage.PromptTokens != 8 || usage.CompletionTokens != 3 || usage.TotalTokens != 11 || usage.RequestCount != 3 {
348 t.Fatalf("aggregated usage = %+v, want prompt=8 completion=3 total=11 requests=3", usage)
349 }
350 }
351
352 // TestGuardianReviewTurnsAlternateRoles pins the review request shape: the
353 // transcript evidence and action request ride in one combined user message per
354 // review, so the guardian session alternates user/assistant strictly and
355 // providers that reject consecutive same-role messages can run the guardian.
356 func TestGuardianReviewTurnsAlternateRoles(t *testing.T) {
357 prov := &scriptedProvider{responses: []scriptedResponse{
358 {text: `{"risk_level":"low","user_authorization":"high","outcome":"allow","rationale":"first ok"}`},
359 {text: `{"risk_level":"low","user_authorization":"high","outcome":"allow","rationale":"second ok"}`},
360 }}
361 gs := NewSession(prov, tool.NewRegistry(), PolicyPrompt(), "guardian-test", 0, nil, &captureSink{})
362 parent := agent.NewSession("sys")
363 parent.Add(provider.Message{Role: provider.RoleUser, Content: "do the thing"})
364
365 for i := range 2 {
366 if allow, _, err := gs.Review(context.Background(), "write_file", json.RawMessage(`{"file_path":"a.txt"}`), parent); err != nil || !allow {
367 t.Fatalf("review %d = allow %v err %v, want allow nil", i+1, allow, err)
368 }
369 }
370
371 reqs := prov.requestsSnapshot()
372 if len(reqs) < 2 {
373 t.Fatalf("requests = %d, want >= 2", len(reqs))
374 }
375 for r, req := range reqs {
376 for i := 1; i < len(req.Messages); i++ {
377 if req.Messages[i].Role == provider.RoleUser && req.Messages[i-1].Role == provider.RoleUser {
378 t.Fatalf("request %d carries consecutive user messages at index %d", r, i)
379 }
380 }
381 }
382
383 // The combined message must still carry the evidence boundary and the action.
384 msgs := reqs[len(reqs)-1].Messages
385 var review string
386 for _, v := range slices.Backward(msgs) {
387 if v.Role == provider.RoleUser {
388 review = v.Content
389 break
390 }
391 }
392 for _, want := range []string{"untrusted evidence", "The agent has requested the following action", "write_file"} {
393 if !strings.Contains(review, want) {
394 t.Fatalf("combined review message missing %q:\n%s", want, review)
395 }
396 }
397 }
398
399 // TestGuardianFailedReviewRollsBackSession pins the error-path rollback:
400 // agent.Run appends the combined review user message before the provider is
401 // reached, so a failed review must not leave it dangling — the next review
402 // would otherwise append another user message and strict-alternation providers
403 // would reject every request from then on.
404 func TestGuardianFailedReviewRollsBackSession(t *testing.T) {
405 prov := &scriptedProvider{responses: []scriptedResponse{
406 {err: fmt.Errorf("provider unavailable")},
407 {text: `{"risk_level":"low","user_authorization":"high","outcome":"allow","rationale":"ok"}`},
408 }}
409 gs := NewSession(prov, tool.NewRegistry(), PolicyPrompt(), "guardian-test", 0, nil, &captureSink{})
410 parent := agent.NewSession("sys")
411 parent.Add(provider.Message{Role: provider.RoleUser, Content: "do the thing"})
412
413 allow, _, err := gs.Review(context.Background(), "write_file", json.RawMessage(`{"file_path":"a.txt"}`), parent)
414 if err == nil && allow {
415 t.Fatal("first review should fail closed")
416 }
417 if n := gs.sess.Len(); n != 1 {
418 t.Fatalf("guardian session messages = %d after failed review, want rollback to system only", n)
419 }
420
421 if allow, _, err := gs.Review(context.Background(), "write_file", json.RawMessage(`{"file_path":"a.txt"}`), parent); err != nil || !allow {
422 t.Fatalf("second review = allow %v err %v, want allow nil", allow, err)
423 }
424 reqs := prov.requestsSnapshot()
425 last := reqs[len(reqs)-1]
426 for i := 1; i < len(last.Messages); i++ {
427 if last.Messages[i].Role == provider.RoleUser && last.Messages[i-1].Role == provider.RoleUser {
428 t.Fatalf("request after failed review carries consecutive user messages at index %d", i)
429 }
430 }
431 }
432
433 // TestGuardianLoadResetsLegacyConsecutiveUserSessions pins the load-time
434 // normalization: sessions saved by the old multi-message review shape carry
435 // consecutive user messages that would poison strict-alternation providers, so
436 // Load starts fresh instead of adopting them.
437 func TestGuardianLoadResetsLegacyConsecutiveUserSessions(t *testing.T) {
438 legacy := agent.NewSession(PolicyPrompt())
439 legacy.Add(provider.Message{Role: provider.RoleUser, Content: "transcript evidence"})
440 legacy.Add(provider.Message{Role: provider.RoleUser, Content: "action request"})
441 legacy.Add(provider.Message{Role: provider.RoleAssistant, Content: `{"risk_level":"low","user_authorization":"high","outcome":"allow","rationale":"ok"}`})
442 path := filepath.Join(t.TempDir(), "session.guardian.jsonl")
443 if err := legacy.Save(path); err != nil {
444 t.Fatalf("Save legacy session: %v", err)
445 }
446
447 gs := NewSession(&scriptedProvider{}, tool.NewRegistry(), PolicyPrompt(), "guardian-test", 0, nil, &captureSink{})
448 if err := gs.Load(path); err != nil {
449 t.Fatalf("Load: %v", err)
450 }
451 if n := gs.sess.Len(); n != 1 {
452 t.Fatalf("loaded legacy session messages = %d, want reset to system only", n)
453 }
454 if gs.cursor.EntryCount != 0 {
455 t.Fatalf("cursor = %+v, want zeroed after reset", gs.cursor)
456 }
457 }
458
459 // TestGuardianSessionAlternatesAfterCompaction reproduces the compaction seam:
460 // every compactEvery-th review runs CompactNow, and generic compaction inserts
461 // its digest as a RoleUser message that can land directly before a review's
462 // user turn — consecutive user roles again. The post-review normalization must
463 // keep the session strictly alternating across that fold.
464 func TestGuardianSessionAlternatesAfterCompaction(t *testing.T) {
465 prov := &scriptedProvider{defaultUsage: &provider.Usage{TotalTokens: 1}} // default allow verdict, also serves the summarizer
466 sink := &captureSink{}
467 gs := NewSession(prov, tool.NewRegistry(), PolicyPrompt(), "guardian-test", 0, nil, sink)
468 parent := agent.NewSession("sys")
469
470 filler := strings.Repeat("parent transcript filler. ", 160)
471 for i := range compactEvery {
472 parent.Add(provider.Message{Role: provider.RoleUser, Content: fmt.Sprintf("turn %d: %s", i, filler)})
473 if allow, _, err := gs.Review(context.Background(), "write_file", json.RawMessage(`{"file_path":"a.txt"}`), parent); err != nil || !allow {
474 t.Fatalf("review %d = allow %v err %v, want allow nil", i+1, allow, err)
475 }
476 }
477
478 msgs := gs.sess.Snapshot()
479 events := sink.guardianEvents()
480 if len(events) != compactEvery {
481 t.Fatalf("guardian events = %d, want %d", len(events), compactEvery)
482 }
483 usage := events[len(events)-1].Guardian.Usage
484 if usage == nil || usage.TotalTokens != 2 || usage.RequestCount != 2 {
485 t.Fatalf("compacting review usage = %+v, want total=2 requests=2", usage)
486 }
487 for i := 1; i < len(msgs); i++ {
488 if msgs[i].Role == msgs[i-1].Role {
489 t.Fatalf("guardian session has consecutive %s messages at indexes %d/%d of %d", msgs[i].Role, i-1, i, len(msgs))
490 }
491 }
492
493 // Projection compaction leaves the canonical transcript untouched. The next
494 // review must nevertheless send the compacted view, with the digest and tail
495 // coalesced so strict-alternation providers do not receive adjacent users.
496 parent.Add(provider.Message{Role: provider.RoleUser, Content: "post-compaction review"})
497 if allow, _, err := gs.Review(context.Background(), "write_file", json.RawMessage(`{"file_path":"a.txt"}`), parent); err != nil || !allow {
498 t.Fatalf("post-compaction review = allow %v err %v, want allow nil", allow, err)
499 }
500 reqs := prov.requestsSnapshot()
501 if len(reqs) <= compactEvery {
502 t.Fatalf("provider requests = %d, want a request after projection compaction", len(reqs))
503 }
504 last := reqs[len(reqs)-1]
505 hasDigest := false
506 for i, m := range last.Messages {
507 if agent.IsCompactionSummary(m) {
508 hasDigest = true
509 }
510 if i > 0 && m.Role == provider.RoleUser && last.Messages[i-1].Role == provider.RoleUser {
511 t.Fatalf("post-compaction request carries consecutive user messages at index %d", i)
512 }
513 }
514 if !hasDigest {
515 t.Fatal("post-compaction request did not use the digest projection")
516 }
517 }
518
518 lines GO