返回 DeepSeek-Reasonix
externalize_test.go
根目录 / internal / extension / sidecar / externalize_test.go
1 package sidecar
2
3 import (
4 "context"
5 "crypto/sha256"
6 "encoding/hex"
7 "encoding/json"
8 "fmt"
9 "strings"
10 "testing"
11 "time"
12
13 "reasonix/internal/extension/protocol"
14 "reasonix/internal/pluginpkg"
15 )
16
17 // bigInputPayload builds an input.receive payload whose text is size bytes.
18 func bigInputPayload(size int) json.RawMessage {
19 payload, _ := json.Marshal(map[string]string{"text": strings.Repeat("x", size)})
20 return payload
21 }
22
23 // TestInterceptExternalizesLargePayload proves the outbound content-ref rule:
24 // a >64 KiB payload leaves the host as a null placeholder plus envelope, the
25 // extension pages the real bytes back through host/content/read, and its
26 // >64 KiB inline replacement comes home verified.
27 func TestInterceptExternalizesLargePayload(t *testing.T) {
28 client := startFakeClient(t, func(rt *pluginpkg.RuntimeSpec) {
29 rt.Env[fakeEnvMode] = "content_roundtrip"
30 }, nil)
31 payload := bigInputPayload(protocol.ExternalizeFieldBytes + 32<<10)
32 result, err := client.Intercept(context.Background(), protocol.EventInputReceive, payload, 15*time.Second)
33 if err != nil {
34 t.Fatalf("Intercept: %v", err)
35 }
36 if result.Decision != protocol.DecisionReplace {
37 t.Fatalf("decision = %q, want replace", result.Decision)
38 }
39 var replacement struct {
40 Text string `json:"text"`
41 }
42 if err := json.Unmarshal(result.Replacement, &replacement); err != nil {
43 t.Fatalf("replacement decode: %v", err)
44 }
45 sum := sha256.Sum256(payload)
46 want := fmt.Sprintf("read %d bytes sha256:%s", len(payload), hex.EncodeToString(sum[:]))
47 if !strings.Contains(replacement.Text, want) {
48 t.Fatalf("replacement text does not prove the extension read the content ref: want substring %q", want)
49 }
50 if len(result.Replacement) <= protocol.ExternalizeFieldBytes {
51 t.Fatalf("replacement = %d bytes, want above the %d byte threshold", len(result.Replacement), protocol.ExternalizeFieldBytes)
52 }
53 }
54
55 // TestInterceptSmallPayloadStaysInline proves the passthrough: below the
56 // threshold the payload travels inline and no envelope is produced.
57 func TestInterceptSmallPayloadStaysInline(t *testing.T) {
58 client := startFakeClient(t, func(rt *pluginpkg.RuntimeSpec) {
59 rt.Env[fakeEnvMode] = "content_roundtrip"
60 }, nil)
61 payload := json.RawMessage(`{"text":"small"}`)
62 result, err := client.Intercept(context.Background(), protocol.EventInputReceive, payload, 5*time.Second)
63 if err != nil {
64 t.Fatalf("Intercept: %v", err)
65 }
66 var replacement struct {
67 Text string `json:"text"`
68 }
69 if err := json.Unmarshal(result.Replacement, &replacement); err != nil {
70 t.Fatalf("replacement decode: %v", err)
71 }
72 // The fake reports the byte count it read; an inline payload arrives
73 // directly, so the count matches the payload's exact length.
74 want := fmt.Sprintf("read %d bytes", len(payload))
75 if !strings.Contains(replacement.Text, want) {
76 t.Fatalf("small payload did not arrive inline: replacement text lacks %q", want)
77 }
78 }
79
80 // TestInterceptResolvesExternalizedReplacement proves the inbound content-ref
81 // rule: an extension may answer with replacement:null plus an envelope naming
82 // a host-held content ref, and the host pages it back out of the connection's
83 // store (host/content/read chunking) before the payload is handed over.
84 func TestInterceptResolvesExternalizedReplacement(t *testing.T) {
85 client := startFakeClient(t, func(rt *pluginpkg.RuntimeSpec) {
86 rt.Env[fakeEnvMode] = "content_echo_ref"
87 }, nil)
88 payload := bigInputPayload(protocol.ExternalizeFieldBytes + 64<<10)
89 result, err := client.Intercept(context.Background(), protocol.EventInputReceive, payload, 15*time.Second)
90 if err != nil {
91 t.Fatalf("Intercept: %v", err)
92 }
93 if result.Decision != protocol.DecisionReplace {
94 t.Fatalf("decision = %q, want replace", result.Decision)
95 }
96 if string(result.Replacement) != string(payload) {
97 t.Fatalf("resolved replacement = %d bytes, want the original %d byte payload", len(result.Replacement), len(payload))
98 }
99 }
100
101 // TestInterceptRejectsPayloadBeyondObjectCap proves the hard ceiling: a
102 // payload beyond protocol.ContentRefObjectBytes cannot be externalized and
103 // fails with the frozen frame_too_large reason before touching the wire.
104 func TestInterceptRejectsPayloadBeyondObjectCap(t *testing.T) {
105 client := startFakeClient(t, nil, nil)
106 payload := bigInputPayload(protocol.ContentRefObjectBytes + 1)
107 _, err := client.Intercept(context.Background(), protocol.EventInputReceive, payload, 5*time.Second)
108 if err == nil {
109 t.Fatal("Intercept accepted a payload beyond ContentRefObjectBytes")
110 }
111 if reason := protocolReason(t, err); reason != protocol.ErrFrameTooLarge {
112 t.Fatalf("reason = %q, want %q", reason, protocol.ErrFrameTooLarge)
113 }
114 }
115
116 // TestNotifyEventExternalizesLargePayload covers the event-notification share
117 // of the content-ref rule at the envelope level (the wire path is the same
118 // helper Intercept exercises end to end).
119 func TestNotifyEventExternalizesLargePayload(t *testing.T) {
120 client := &Client{store: NewStore()}
121 params := protocol.EventParams{Event: protocol.EventSessionStart, Payload: bigInputPayload(protocol.ExternalizeFieldBytes + 1024)}
122 if err := client.externalizeEventParams(&params); err != nil {
123 t.Fatalf("externalizeEventParams: %v", err)
124 }
125 if len(params.Payload) != 0 {
126 t.Fatal("large event payload did not move to a null placeholder")
127 }
128 if len(params.Externalized) != 1 || params.Externalized[0].JSONPointer != "/payload" {
129 t.Fatalf("envelope = %+v", params.Externalized)
130 }
131 descriptor := params.Externalized[0]
132 reassembled, err := client.store.readAll(descriptor.ContentRef)
133 if err != nil {
134 t.Fatalf("readAll: %v", err)
135 }
136 if int64(len(reassembled)) != descriptor.TotalBytes {
137 t.Fatalf("reassembled %d bytes, want %d", len(reassembled), descriptor.TotalBytes)
138 }
139 sum := sha256.Sum256(reassembled)
140 if hex.EncodeToString(sum[:]) != descriptor.SHA256 {
141 t.Fatal("descriptor SHA-256 does not match the stored object")
142 }
143
144 small := protocol.EventParams{Event: protocol.EventSessionStart, Payload: json.RawMessage(`{"at":1}`)}
145 if err := client.externalizeEventParams(&small); err != nil {
146 t.Fatalf("externalizeEventParams small: %v", err)
147 }
148 if string(small.Payload) != `{"at":1}` || len(small.Externalized) != 0 {
149 t.Fatalf("small event payload = %s envelope %+v, want inline passthrough", small.Payload, small.Externalized)
150 }
151 }
152
153 // TestResolveExternalizedReplacementValidation pins the envelope validation:
154 // inline-plus-envelope, unknown refs, byte-count and digest mismatches, and
155 // over-cap descriptors are all protocol errors, never silent decodes.
156 func TestResolveExternalizedReplacementValidation(t *testing.T) {
157 client := &Client{store: NewStore()}
158 content := []byte(`{"text":"stored"}`)
159 ref, digest, totalBytes, err := client.store.Put(content)
160 if err != nil {
161 t.Fatalf("Put: %v", err)
162 }
163 descriptor := func() ExternalizedField {
164 return ExternalizedField{JSONPointer: "/replacement", ContentRef: ref, TotalBytes: totalBytes, SHA256: digest}
165 }
166
167 // Happy path: the replacement is reassembled and verified.
168 result := protocol.InterceptResult{Decision: protocol.DecisionReplace, Externalized: []ExternalizedField{descriptor()}}
169 if err := client.resolveExternalizedReplacement(&result); err != nil {
170 t.Fatalf("resolve: %v", err)
171 }
172 if string(result.Replacement) != string(content) {
173 t.Fatalf("replacement = %s, want %s", result.Replacement, content)
174 }
175
176 // Inline replacement alongside an envelope is malformed.
177 both := protocol.InterceptResult{Decision: protocol.DecisionReplace, Replacement: json.RawMessage(`{"text":"inline"}`), Externalized: []ExternalizedField{descriptor()}}
178 if err := client.resolveExternalizedReplacement(&both); err == nil {
179 t.Fatal("inline replacement plus envelope accepted")
180 }
181
182 // Unknown ref answers content_ref_expired.
183 unknown := descriptor()
184 unknown.ContentRef = "content_gone"
185 result = protocol.InterceptResult{Decision: protocol.DecisionReplace, Externalized: []ExternalizedField{unknown}}
186 if err := client.resolveExternalizedReplacement(&result); err == nil {
187 t.Fatal("unknown content ref accepted")
188 } else if reason := protocolReason(t, err); reason != protocol.ErrContentRefExpired {
189 t.Fatalf("unknown ref reason = %q, want %q", reason, protocol.ErrContentRefExpired)
190 }
191
192 // Byte-count mismatch is a protocol error.
193 short := descriptor()
194 short.TotalBytes = totalBytes - 1
195 result = protocol.InterceptResult{Decision: protocol.DecisionReplace, Externalized: []ExternalizedField{short}}
196 if err := client.resolveExternalizedReplacement(&result); err == nil {
197 t.Fatal("byte-count mismatch accepted")
198 }
199
200 // Digest mismatch is a protocol error.
201 tampered := descriptor()
202 tampered.SHA256 = strings.Repeat("0", 64)
203 result = protocol.InterceptResult{Decision: protocol.DecisionReplace, Externalized: []ExternalizedField{tampered}}
204 if err := client.resolveExternalizedReplacement(&result); err == nil {
205 t.Fatal("SHA-256 mismatch accepted")
206 }
207
208 // A descriptor beyond the object cap is frame_too_large.
209 oversize := descriptor()
210 oversize.TotalBytes = protocol.ContentRefObjectBytes + 1
211 result = protocol.InterceptResult{Decision: protocol.DecisionReplace, Externalized: []ExternalizedField{oversize}}
212 if err := client.resolveExternalizedReplacement(&result); err == nil {
213 t.Fatal("over-cap descriptor accepted")
214 } else if reason := protocolReason(t, err); reason != protocol.ErrFrameTooLarge {
215 t.Fatalf("over-cap reason = %q, want %q", reason, protocol.ErrFrameTooLarge)
216 }
217
218 // A pointer outside the schema's externalizable set is a protocol error.
219 wrongPointer := descriptor()
220 wrongPointer.JSONPointer = "/reason"
221 result = protocol.InterceptResult{Decision: protocol.DecisionReplace, Externalized: []ExternalizedField{wrongPointer}}
222 if err := client.resolveExternalizedReplacement(&result); err == nil {
223 t.Fatal("externalized pointer outside the schema set accepted")
224 }
225 }
226
226 lines GO