返回 DeepSeek-Reasonix
extension_dispatch_test.go
根目录 / internal / boot / extension_dispatch_test.go
1 package boot
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "os"
8 "path/filepath"
9 "strings"
10 "testing"
11 "time"
12
13 "reasonix/internal/config"
14 "reasonix/internal/extension"
15 "reasonix/internal/extension/dispatch"
16 )
17
18 // Stage 6b1 dispatch wiring tests: the boot builds the dispatcher, runs the
19 // system_prompt.build strategy before the snapshot freezes, and broadcasts
20 // the final prompt to observers — all without letting dispatch results flow
21 // back into the frozen snapshot.
22
23 func TestBootSystemPromptStrategyReplacementLandsInSnapshot(t *testing.T) {
24 res := bootWithFakePlugin(t, "prompt-owner", map[string]any{
25 "replaces": []string{"system_prompt"},
26 "env": map[string]string{bootFakeEnvReplacePrompt: "EXTENSION PROMPT"},
27 })
28 if res.Dispatcher == nil {
29 t.Fatal("BuildRuntime returned no dispatcher with a live sidecar")
30 }
31 if got := res.Snapshot.SystemPrompt(); got != "EXTENSION PROMPT" {
32 t.Fatalf("snapshot system prompt = %q, want the strategy replacement %q", got, "EXTENSION PROMPT")
33 }
34 // Stage 6b2 handoff: the snapshot (and its cache hash) records the
35 // strategy's final prompt, and the live executor session was swapped to
36 // the same prompt before the build returned — the session's system
37 // message is exactly what the agent will send on its first request.
38 if got := systemMessage(res.Controller.History()); got != "EXTENSION PROMPT" {
39 t.Fatalf("controller system message = %.80q, want the strategy-replaced prompt", got)
40 }
41 }
42
43 // TestBootObservationOnlyKeepsSessionPrompt guards the swap condition: a
44 // build whose strategy did NOT replace the prompt must leave the executor
45 // session on the host-composed prompt (byte-identical pre-dispatch path).
46 func TestBootObservationOnlyKeepsSessionPrompt(t *testing.T) {
47 res := bootWithFakePlugin(t, "observer", map[string]any{
48 "intercepts": []string{"input.receive"},
49 })
50 if res.Dispatcher == nil {
51 t.Fatal("BuildRuntime returned no dispatcher with a live sidecar")
52 }
53 if got := systemMessage(res.Controller.History()); !strings.Contains(got, "BASE SYSTEM PROMPT") {
54 t.Fatalf("controller system message = %.80q, want the host-composed prompt", got)
55 }
56 }
57
58 func TestBootSystemPromptStrategyAttribution(t *testing.T) {
59 isolateConfigHome(t)
60 dir := robustTempDir(t)
61 t.Chdir(dir)
62 writeRuntimeFixture(t, dir)
63 home := config.ReasonixHomeDir()
64
65 // Baseline: no sidecars — no dispatcher, and the golden-path fingerprint.
66 plain, err := BuildRuntime(context.Background(), Options{})
67 if err != nil {
68 t.Fatalf("BuildRuntime: %v", err)
69 }
70 t.Cleanup(plain.Controller.Close)
71 if plain.Dispatcher != nil || plain.Extensions != nil {
72 t.Fatal("no-plugin build produced a dispatcher or manager")
73 }
74 baseHash := plain.Snapshot.CacheHash()
75
76 // An observation-only sidecar (continue at input.receive) must not
77 // perturb the provider-visible fingerprint at all.
78 installBootFakePlugin(t, home, "observer", map[string]any{
79 "intercepts": []string{"input.receive"},
80 })
81 observed, err := BuildRuntime(context.Background(), Options{})
82 if err != nil {
83 t.Fatalf("BuildRuntime with observer: %v", err)
84 }
85 t.Cleanup(observed.Controller.Close)
86 if observed.Dispatcher == nil {
87 t.Fatal("observer build returned no dispatcher")
88 }
89 if got := observed.Snapshot.CacheHash(); got != baseHash {
90 t.Fatalf("observation-only build changed the cache hash: %s vs %s", got, baseHash)
91 }
92
93 // A strategy replacement lands IN the hash: two builds whose owner
94 // returns different prompts produce different hashes, and both differ
95 // from the host-composed baseline. That is attribution — the fingerprint
96 // covers what the session was actually built with.
97 installBootFakePlugin(t, home, "strategist", map[string]any{
98 "replaces": []string{"system_prompt"},
99 "env": map[string]string{bootFakeEnvReplacePrompt: "PROMPT A"},
100 })
101 buildA, err := BuildRuntime(context.Background(), Options{})
102 if err != nil {
103 t.Fatalf("BuildRuntime with strategist A: %v", err)
104 }
105 t.Cleanup(buildA.Controller.Close)
106 if got := buildA.Snapshot.SystemPrompt(); got != "PROMPT A" {
107 t.Fatalf("build A prompt = %q, want PROMPT A", got)
108 }
109 if buildA.Snapshot.CacheHash() == baseHash {
110 t.Fatal("strategy-replaced prompt did not change the cache hash")
111 }
112
113 installBootFakePlugin(t, home, "strategist", map[string]any{
114 "replaces": []string{"system_prompt"},
115 "env": map[string]string{bootFakeEnvReplacePrompt: "PROMPT B"},
116 })
117 buildB, err := BuildRuntime(context.Background(), Options{})
118 if err != nil {
119 t.Fatalf("BuildRuntime with strategist B: %v", err)
120 }
121 t.Cleanup(buildB.Controller.Close)
122 if got := buildB.Snapshot.SystemPrompt(); got != "PROMPT B" {
123 t.Fatalf("build B prompt = %q, want PROMPT B", got)
124 }
125 if buildB.Snapshot.CacheHash() == buildA.Snapshot.CacheHash() {
126 t.Fatal("different strategy replacements produced the same cache hash — attribution is broken")
127 }
128 }
129
130 // TestBootStableExtensionCacheGuard proves that an enabled deterministic
131 // strategy has one stable provider-visible prefix across independent runtime
132 // generations. Installing the extension may intentionally create one cold
133 // prefix versus the no-extension baseline; identical reloads must not create
134 // a new one.
135 func TestBootStableExtensionCacheGuard(t *testing.T) {
136 isolateConfigHome(t)
137 dir := robustTempDir(t)
138 t.Chdir(dir)
139 writeRuntimeFixture(t, dir)
140 installBootFakePlugin(t, config.ReasonixHomeDir(), "stable-strategist", map[string]any{
141 "replaces": []string{"system_prompt"},
142 "env": map[string]string{bootFakeEnvReplacePrompt: "STABLE EXTENSION PROMPT"},
143 })
144
145 first, err := BuildRuntime(context.Background(), Options{})
146 if err != nil {
147 t.Fatalf("first BuildRuntime: %v", err)
148 }
149 firstPrompt := first.Snapshot.SystemPrompt()
150 firstHash := first.Snapshot.CacheHash()
151 firstSystemHash, firstToolsHash := first.Snapshot.CacheShape()
152 firstSchemas, err := json.Marshal(first.Snapshot.ToolSchemas())
153 if err != nil {
154 first.Controller.Close()
155 t.Fatalf("marshal first tool schemas: %v", err)
156 }
157 firstSessionPrompt := systemMessage(first.Controller.History())
158 first.Controller.Close()
159
160 second, err := BuildRuntime(context.Background(), Options{})
161 if err != nil {
162 t.Fatalf("second BuildRuntime: %v", err)
163 }
164 t.Cleanup(second.Controller.Close)
165 secondSchemas, err := json.Marshal(second.Snapshot.ToolSchemas())
166 if err != nil {
167 t.Fatalf("marshal second tool schemas: %v", err)
168 }
169 secondSystemHash, secondToolsHash := second.Snapshot.CacheShape()
170
171 if second.Snapshot.Generation() == first.Snapshot.Generation() {
172 t.Fatal("independent builds reused the same runtime generation")
173 }
174 if got := second.Snapshot.SystemPrompt(); got != firstPrompt {
175 t.Fatalf("stable extension prompt drifted: %q vs %q", got, firstPrompt)
176 }
177 if got := systemMessage(second.Controller.History()); got != firstSessionPrompt {
178 t.Fatalf("controller prompt drifted across stable reload: %q vs %q", got, firstSessionPrompt)
179 }
180 if got := second.Snapshot.CacheHash(); got != firstHash {
181 t.Fatalf("stable extension cache hash drifted: %s vs %s", got, firstHash)
182 }
183 if secondSystemHash != firstSystemHash || secondToolsHash != firstToolsHash {
184 t.Fatalf("stable extension cache shape drifted: system %s/%s tools %s/%s",
185 firstSystemHash, secondSystemHash, firstToolsHash, secondToolsHash)
186 }
187 if string(secondSchemas) != string(firstSchemas) {
188 t.Fatal("stable extension tool schema bytes drifted across reload")
189 }
190 }
191
192 func TestBootSystemPromptStrategyFailureFailsBuild(t *testing.T) {
193 t.Run("block", func(t *testing.T) {
194 isolateConfigHome(t)
195 dir := robustTempDir(t)
196 t.Chdir(dir)
197 writeRuntimeFixture(t, dir)
198 installBootFakePlugin(t, config.ReasonixHomeDir(), "blocker", map[string]any{
199 "replaces": []string{"system_prompt"},
200 "env": map[string]string{bootFakeEnvBlockEvent: "system_prompt.build"},
201 })
202 _, err := BuildRuntime(context.Background(), Options{})
203 if err == nil {
204 t.Fatal("BuildRuntime succeeded with a blocking system_prompt owner")
205 }
206 var blockErr *dispatch.BlockError
207 if !errors.As(err, &blockErr) {
208 t.Fatalf("error %v is not a dispatch.BlockError", err)
209 }
210 })
211 t.Run("contract violation", func(t *testing.T) {
212 isolateConfigHome(t)
213 dir := robustTempDir(t)
214 t.Chdir(dir)
215 writeRuntimeFixture(t, dir)
216 installBootFakePlugin(t, config.ReasonixHomeDir(), "violator", map[string]any{
217 "replaces": []string{"system_prompt"},
218 "env": map[string]string{bootFakeEnvInvalidEvent: "system_prompt.build"},
219 })
220 _, err := BuildRuntime(context.Background(), Options{})
221 if err == nil {
222 t.Fatal("BuildRuntime succeeded with a DTO-violating system_prompt owner")
223 }
224 var violationErr *dispatch.ViolationError
225 if !errors.As(err, &violationErr) {
226 t.Fatalf("error %v is not a dispatch.ViolationError", err)
227 }
228 })
229 }
230
231 func TestBootSystemPromptBuildEventObserved(t *testing.T) {
232 isolateConfigHome(t)
233 dir := robustTempDir(t)
234 t.Chdir(dir)
235 writeRuntimeFixture(t, dir)
236 home := config.ReasonixHomeDir()
237 eventLog := filepath.Join(dir, "events.log")
238
239 // The slot owner replaces the prompt; the observer subscribes to
240 // system_prompt.build and records what the broadcast carries.
241 installBootFakePlugin(t, home, "prompt-owner", map[string]any{
242 "replaces": []string{"system_prompt"},
243 "env": map[string]string{bootFakeEnvReplacePrompt: "EXTENSION PROMPT"},
244 })
245 installBootFakePlugin(t, home, "prompt-watcher", map[string]any{
246 "intercepts": []string{"system_prompt.build"},
247 "env": map[string]string{bootFakeEnvEventLog: eventLog},
248 })
249 res, err := BuildRuntime(context.Background(), Options{})
250 if err != nil {
251 t.Fatalf("BuildRuntime: %v", err)
252 }
253 t.Cleanup(res.Controller.Close)
254
255 // The notification is delivered when the host writes the frame; the fake
256 // logs it on its own read loop, so poll instead of reading once.
257 var line string
258 waitForCond(t, "system_prompt.build event observation", 10*time.Second, func() bool {
259 data, err := os.ReadFile(eventLog)
260 if err != nil {
261 return false
262 }
263 for _, candidate := range strings.Split(strings.TrimSpace(string(data)), "\n") {
264 if strings.HasPrefix(candidate, "system_prompt.build ") {
265 line = candidate
266 return true
267 }
268 }
269 return false
270 })
271 if !strings.Contains(line, `"prompt":"EXTENSION PROMPT"`) {
272 t.Fatalf("observer saw %q, want the final replaced prompt", line)
273 }
274 }
275
276 // TestBootSnapshotImmutableAcrossDispatch is the cache-safety invariant: no
277 // dispatch call — intercept replace, strategy, or event — ever flows back
278 // into the frozen snapshot. A replacement at input.receive shapes only that
279 // call's payload; SystemPrompt, ToolSchemas, and CacheHash stay byte-identical.
280 func TestBootSnapshotImmutableAcrossDispatch(t *testing.T) {
281 res := bootWithFakePlugin(t, "input-rewriter", map[string]any{
282 "intercepts": []string{"input.receive"},
283 "env": map[string]string{bootFakeEnvReplaceInput: "REPLACED INPUT"},
284 })
285 if res.Dispatcher == nil {
286 t.Fatal("BuildRuntime returned no dispatcher")
287 }
288 snap := res.Snapshot
289 promptBefore := snap.SystemPrompt()
290 schemasBefore, err := json.Marshal(snap.ToolSchemas())
291 if err != nil {
292 t.Fatalf("marshal tool schemas: %v", err)
293 }
294 hashBefore := snap.CacheHash()
295
296 // A replace at input.receive affects only that call's payload.
297 payload := dispatch.InputPayload{Text: "original user text"}
298 result, err := res.Dispatcher.Intercept(context.Background(), extension.PointInputReceive, &payload)
299 if err != nil {
300 t.Fatalf("Intercept: %v", err)
301 }
302 if result.Blocked || len(result.Applied) != 1 || result.Applied[0] != "input-rewriter" {
303 t.Fatalf("intercept result = %+v, want one applied replacement", result)
304 }
305 if payload.Text != "REPLACED INPUT" {
306 t.Fatalf("payload text = %q, want the extension replacement", payload.Text)
307 }
308
309 // Strategy + event traffic flows too (session_policy is unowned here, so
310 // the strategy is a no-op; events are fire-and-forget).
311 if err := res.Dispatcher.RunStrategy(context.Background(), extension.SlotSessionPolicy, extension.PointSessionSave,
312 &dispatch.SessionPayload{SessionPath: "/tmp/s.jsonl", Phase: dispatch.PhaseSave}); err != nil {
313 t.Fatalf("RunStrategy on an unowned slot: %v", err)
314 }
315 res.Dispatcher.Event(extension.PointSessionStart, dispatch.SessionPayload{Phase: dispatch.PhaseStart})
316 res.Dispatcher.Event(extension.PointFrontendEvent, dispatch.FrontendEventPayload{Kind: "notice", Text: "hi"})
317
318 if got := snap.SystemPrompt(); got != promptBefore {
319 t.Fatal("dispatch mutated the snapshot system prompt")
320 }
321 schemasAfter, err := json.Marshal(snap.ToolSchemas())
322 if err != nil {
323 t.Fatalf("marshal tool schemas after: %v", err)
324 }
325 if string(schemasAfter) != string(schemasBefore) {
326 t.Fatal("dispatch mutated the snapshot tool schemas")
327 }
328 if got := snap.CacheHash(); got != hashBefore {
329 t.Fatalf("dispatch mutated the snapshot cache hash: %s vs %s", got, hashBefore)
330 }
331 }
332
332 lines GO