返回 DeepSeek-Reasonix
execution_projection_test.go
根目录 / internal / session / execution_projection_test.go
1 package session
2
3 import (
4 "context"
5 "encoding/json"
6 "path/filepath"
7 "testing"
8
9 bolt "go.etcd.io/bbolt"
10 "reasonix/internal/provider"
11 )
12
13 func TestRejectedToolEvidenceSurvivesWireResetAndColdRecovery(t *testing.T) {
14 dir := filepath.Join(t.TempDir(), "sessions-v4", "replay")
15 s, err := CreateWithOptions(dir, "replay", OpenOptions{ExternalHistory: true})
16 if err != nil {
17 t.Fatal(err)
18 }
19 t.Cleanup(func() { _ = s.Close(context.Background()) })
20 messages := []provider.Message{
21 {ID: "proposal", Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "call", Name: "use_capability", Arguments: `{"body":"["text"]"}`}}},
22 {ID: "receipt", Role: provider.RoleTool, Name: "use_capability", ToolCallID: "call", ToolRunState: provider.ToolRunNotStarted, Content: "validation failed; not executed"},
23 }
24 appendReplayEvent(t, s, "proposal", "message/complete", map[string]any{"message": messages[0]})
25 appendReplayEvent(t, s, "receipt", "message/complete", map[string]any{"message": messages[1]})
26 appendReplayEvent(t, s, "wire-reset", "model/context-replace", map[string]any{"messages": provider.ModelMessages(messages), "reason": "interrupted-turn-cleanup"})
27 assertReplayEvidence(t, s)
28 if err := s.Close(t.Context()); err != nil {
29 t.Fatal(err)
30 }
31 for _, stale := range []bool{false, true} {
32 if stale {
33 invalidateReplayCheckpointVersion(t, dir)
34 }
35 var stats RecoveryOpenStats
36 s, err = OpenWithOptions(dir, "replay", OpenOptions{ExternalHistory: true, ObserveRecovery: func(got RecoveryOpenStats) { stats = got }})
37 if err != nil {
38 t.Fatal(err)
39 }
40 if stats.UsedCheckpoint == stale {
41 t.Fatalf("checkpoint used=%v, stale=%v", stats.UsedCheckpoint, stale)
42 }
43 assertReplayEvidence(t, s)
44 if err := s.Close(t.Context()); err != nil {
45 t.Fatal(err)
46 }
47 }
48 }
49
50 func appendReplayEvent(t *testing.T, s *Session, op, kind string, body any) {
51 t.Helper()
52 payload, err := json.Marshal(body)
53 if err != nil {
54 t.Fatal(err)
55 }
56 if _, err := s.Append(t.Context(), Batch{OperationID: op, Events: []Event{{Kind: kind, Payload: payload}}}); err != nil {
57 t.Fatal(err)
58 }
59 }
60
61 func assertReplayEvidence(t *testing.T, s *Session) {
62 t.Helper()
63 view := s.ExecutionSnapshot().Projection
64 if len(view.Messages) != 0 || view.ModelMessages[1].ToolRunState != provider.ToolRunNotStarted {
65 t.Fatal("execution evidence lost or UI history materialized")
66 }
67 if err := provider.ValidateModelTranscript(provider.RepairRejectedArguments(view.ModelMessages)); err != nil {
68 t.Fatal(err)
69 }
70 view.ModelMessages[0].ToolCalls[0].Arguments = "mutated"
71 delete(view.RejectedToolResults, "receipt")
72 canonical := s.Snapshot().Projection
73 if canonical.Messages[0].ToolCalls[0].Arguments != `{"body":"["text"]"}` || canonical.ModelMessages[1].ToolRunState != "" {
74 t.Fatal("request repair mutated stored history or wire projection")
75 }
76 if s.ExecutionSnapshot().Projection.ModelMessages[1].ToolRunState != provider.ToolRunNotStarted {
77 t.Fatal("snapshot mutation leaked into execution evidence")
78 }
79 }
80
81 func invalidateReplayCheckpointVersion(t *testing.T, dir string) {
82 t.Helper()
83 db, err := bolt.Open(filepath.Join(recoveryCacheDir(dir), recoveryDBName), 0o600, nil)
84 if err != nil {
85 t.Fatal(err)
86 }
87 defer db.Close()
88 if err := db.Update(func(tx *bolt.Tx) error {
89 bucket := tx.Bucket(recoveryCheckpointBucket)
90 for _, key := range [][]byte{recoveryCurrentKey, recoveryPreviousKey} {
91 raw := bucket.Get(key)
92 if len(raw) == 0 {
93 continue
94 }
95 var checkpoint recoveryCheckpoint
96 if err := decodeRecoveryValue(raw, &checkpoint); err != nil {
97 return err
98 }
99 checkpoint.ProjectionVersion = recoveryProjectionVersion - 1
100 checkpoint.Projection.RejectedToolResults = nil
101 raw, err := encodeRecoveryValue(checkpoint)
102 if err != nil {
103 return err
104 }
105 if err := bucket.Put(key, raw); err != nil {
106 return err
107 }
108 }
109 return nil
110 }); err != nil {
111 t.Fatal(err)
112 }
113 }
114
115 func TestRejectedToolEvidenceNeverTransfersAcrossResultIdentity(t *testing.T) {
116 p := Projection{}
117 result := provider.Message{ID: "one", Role: provider.RoleTool, ToolCallID: "reused", Name: "write", ToolRunState: provider.ToolRunNotStarted}
118 noteRejectedToolResult(&p, result)
119 for _, next := range []provider.Message{
120 {ID: "two", Role: provider.RoleTool, ToolCallID: "reused", Name: "write"},
121 {ID: "one", Role: provider.RoleTool, ToolCallID: "reused", Name: "other"},
122 {ID: "one", Role: provider.RoleTool, ToolCallID: "reused", Name: "write", ToolRunState: provider.ToolRunCompleted},
123 } {
124 p.ModelMessages = []provider.Message{next}
125 restoreRejectedToolResults(&p)
126 if p.ModelMessages[0].ToolRunState != next.ToolRunState {
127 t.Fatal("refusal transferred to another result")
128 }
129 }
130 result.ToolRunState = provider.ToolRunUnknown
131 noteRejectedToolResult(&p, result)
132 if len(p.RejectedToolResults) != 0 {
133 t.Fatal("updated state retained obsolete evidence")
134 }
135 }
136
137 func TestRejectedToolEvidenceFollowsCanonicalRemoval(t *testing.T) {
138 for _, kind := range []string{"message/retract", "history/replace", "message/upsert"} {
139 t.Run(kind, func(t *testing.T) {
140 p := Projection{}
141 m := provider.Message{ID: "receipt", Role: provider.RoleTool, ToolCallID: "call", Name: "read", ToolRunState: provider.ToolRunNotStarted}
142 noteRejectedToolResult(&p, m)
143 body := map[string]any{"messageIds": []string{m.ID}}
144 switch kind {
145 case "history/replace":
146 body = map[string]any{"messages": []provider.Message{}}
147 case "message/upsert":
148 m.ToolRunState = provider.ToolRunCompleted
149 body = map[string]any{"message": m}
150 }
151 payload, _ := json.Marshal(body)
152 applyTranscriptMetadata(&p, Commit{}, Event{Kind: kind, Payload: payload})
153 if len(p.RejectedToolResults) != 0 {
154 t.Fatal("canonical removal left refusal authority behind")
155 }
156 })
157 }
158 }
159
159 lines GO