返回 DeepSeek-Reasonix
extension_sidecar_test.go
根目录 / internal / boot / extension_sidecar_test.go
1 package boot
2
3 import (
4 "bufio"
5 "context"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "io"
10 "os"
11 "path/filepath"
12 "strings"
13 "sync"
14 "testing"
15 "time"
16
17 "reasonix/internal/config"
18 "reasonix/internal/extension"
19 "reasonix/internal/extension/protocol"
20 "reasonix/internal/extension/sidecar"
21 "reasonix/internal/pluginpkg"
22 )
23
24 // Boot-level fake sidecar (re-exec helper-process pattern, mirroring the
25 // sidecar package's own tests): the boot test binary re-executes itself with
26 // REASONIX_BOOT_FAKE_SIDECAR=1 and speaks Extension Protocol v1 over
27 // stdin/stdout. REASONIX_BOOT_FAKE_INIT_RESULT overrides the initialize
28 // result; REASONIX_BOOT_FAKE_MODE=ignore_shutdown keeps the process alive
29 // through extension/shutdown. Intercept steering for the dispatch tests:
30 //
31 // REASONIX_BOOT_FAKE_BLOCK_EVENT answer block at this event
32 // REASONIX_BOOT_FAKE_INVALID_EVENT answer a DTO-violating replace at this event
33 // REASONIX_BOOT_FAKE_REPLACE_PROMPT answer system_prompt.build replace with this prompt
34 // REASONIX_BOOT_FAKE_REPLACE_INPUT answer input.receive replace with this text
35 // REASONIX_BOOT_FAKE_EVENT_LOG append one "event payload" line per extension/event
36 //
37 // Provider steering for the stage 7 adapter tests:
38 //
39 // REASONIX_BOOT_FAKE_PLUGIN_NAME the installed plugin name (provider ref namespace)
40 // REASONIX_BOOT_FAKE_PROVIDER when "1", declare plugin/<name>/fake/x and serve
41 // catalog/stream/open/stream/cancel with a fixed
42 // two-chunk completion plus usage
43 //
44 // UI steering for the stage 8a hub tests:
45 //
46 // REASONIX_BOOT_FAKE_UI_PUBLISH when "1", publish one credential-bearing
47 // status surface through host/ui/publish after
48 // the handshake completes
49 //
50 // Process-lifecycle steering for the failure-cleanup tests:
51 //
52 // REASONIX_BOOT_FAKE_PID_FILE write the sidecar PID to this file on start,
53 // so the parent can poll for a leaked process
54 // REASONIX_BOOT_FAKE_EXIT_IMMEDIATELY when "1", write the PID file (if set) and
55 // exit 0 at once — a sidecar that dies before
56 // answering the handshake
57 const (
58 bootFakeEnvEnable = "REASONIX_BOOT_FAKE_SIDECAR"
59 bootFakeEnvInitResult = "REASONIX_BOOT_FAKE_INIT_RESULT"
60 bootFakeEnvMode = "REASONIX_BOOT_FAKE_MODE"
61 bootFakeEnvBlockEvent = "REASONIX_BOOT_FAKE_BLOCK_EVENT"
62 bootFakeEnvInvalidEvent = "REASONIX_BOOT_FAKE_INVALID_EVENT"
63 bootFakeEnvReplacePrompt = "REASONIX_BOOT_FAKE_REPLACE_PROMPT"
64 bootFakeEnvReplaceInput = "REASONIX_BOOT_FAKE_REPLACE_INPUT"
65 bootFakeEnvEventLog = "REASONIX_BOOT_FAKE_EVENT_LOG"
66 bootFakeEnvPluginName = "REASONIX_BOOT_FAKE_PLUGIN_NAME"
67 bootFakeEnvProvider = "REASONIX_BOOT_FAKE_PROVIDER"
68 bootFakeEnvUIPublish = "REASONIX_BOOT_FAKE_UI_PUBLISH"
69 bootFakeEnvPIDFile = "REASONIX_BOOT_FAKE_PID_FILE"
70 bootFakeEnvExitImmediately = "REASONIX_BOOT_FAKE_EXIT_IMMEDIATELY"
71 )
72
73 // TestExtensionFakeSidecarHelperProcess is the re-exec entry point; it skips
74 // in the parent run.
75 func TestExtensionFakeSidecarHelperProcess(t *testing.T) {
76 if os.Getenv(bootFakeEnvEnable) != "1" {
77 t.Skip("boot fake sidecar helper process")
78 }
79 runBootFakeSidecar(os.Stdin, os.Stdout)
80 os.Exit(0)
81 }
82
83 func runBootFakeSidecar(stdin io.Reader, stdout io.Writer) {
84 if pidFile := strings.TrimSpace(os.Getenv(bootFakeEnvPIDFile)); pidFile != "" {
85 _ = os.WriteFile(pidFile, []byte(fmt.Sprintf("%d", os.Getpid())), 0o644)
86 }
87 if os.Getenv(bootFakeEnvExitImmediately) == "1" {
88 os.Exit(0)
89 }
90 out := bufio.NewWriter(stdout)
91 var writeMu sync.Mutex
92 write := func(format string, args ...any) {
93 writeMu.Lock()
94 defer writeMu.Unlock()
95 fmt.Fprintf(out, format+"\n", args...)
96 _ = out.Flush()
97 }
98 pluginName := strings.TrimSpace(os.Getenv(bootFakeEnvPluginName))
99 providerMode := os.Getenv(bootFakeEnvProvider) == "1" && pluginName != ""
100 providerRef := "plugin/" + pluginName + "/fake/x"
101 providerDescriptor := func() string {
102 return fmt.Sprintf(`{"ref":%q,"displayName":"Boot Fake","model":"x","contextWindow":64000,"tools":true,"reasoning":true,"efforts":["low","high"],"defaultEffort":"low"}`, providerRef)
103 }
104 initResult := strings.TrimSpace(os.Getenv(bootFakeEnvInitResult))
105 if initResult == "" && providerMode {
106 initResult = fmt.Sprintf(`{"protocolVersion":"1","name":"boot-fake","version":"1.0.0","stateSchemaVersion":0,"providers":[%s]}`, providerDescriptor())
107 }
108 if initResult == "" {
109 initResult = `{"protocolVersion":"1","name":"boot-fake","version":"1.0.0","stateSchemaVersion":0}`
110 }
111 ignoreShutdown := os.Getenv(bootFakeEnvMode) == "ignore_shutdown"
112
113 // streamFakeCompletion answers stream/open and then pushes the fixed
114 // completion — two text chunks and one usage chunk, sealed by stream/end —
115 // from its own goroutine so the read loop keeps answering other requests.
116 streamFakeCompletion := func(id json.RawMessage, rawParams json.RawMessage) {
117 var params struct {
118 StreamID string `json:"streamId"`
119 }
120 _ = json.Unmarshal(rawParams, &params)
121 write(`{"jsonrpc":"2.0","id":%s,"result":{"accepted":true}}`, string(id))
122 go func() {
123 chunk := func(seq int, body string) {
124 write(`{"jsonrpc":"2.0","method":"extension/provider/stream/chunk","params":{"streamId":%q,"seq":%d,"chunk":%s}}`, params.StreamID, seq, body)
125 }
126 chunk(1, `{"type":"text","text":"fake-hello "}`)
127 chunk(2, `{"type":"text","text":"fake-world"}`)
128 chunk(3, `{"type":"usage","usage":{"promptTokens":5,"completionTokens":7,"totalTokens":12,"cacheHitTokens":2,"cacheMissTokens":3,"reasoningTokens":4,"finishReason":"stop"}}`)
129 write(`{"jsonrpc":"2.0","method":"extension/provider/stream/end","params":{"streamId":%q,"lastSeq":3}}`, params.StreamID)
130 }()
131 }
132
133 in := bufio.NewReader(stdin)
134 var sessionID string
135 var generation uint64
136 uiPublish := os.Getenv(bootFakeEnvUIPublish) == "1"
137 for {
138 line, err := in.ReadBytes('\n')
139 if len(line) > 0 {
140 var frame struct {
141 ID json.RawMessage `json:"id"`
142 Method string `json:"method"`
143 Params json.RawMessage `json:"params"`
144 }
145 if json.Unmarshal(line, &frame) == nil && frame.Method != "" {
146 var result string
147 switch frame.Method {
148 case "extension/initialize":
149 var params struct {
150 Session struct {
151 SessionID string `json:"sessionId"`
152 Generation uint64 `json:"generation"`
153 } `json:"session"`
154 }
155 _ = json.Unmarshal(frame.Params, &params)
156 sessionID = params.Session.SessionID
157 generation = params.Session.Generation
158 result = initResult
159 case "extension/initialized":
160 // notification; the stage-8a publish mode fires one
161 // credential-bearing status surface once the handshake
162 // completes (the host must redact before surfacing).
163 if uiPublish {
164 uiPublish = false
165 payload, _ := json.Marshal(map[string]any{
166 "surfaceId": "boot-status", "sessionId": sessionID, "generation": generation,
167 "kind": "status",
168 "payload": map[string]any{
169 "label": "boot fake ready api_key=sk-abcdef1234567890SECRETKEY", "severity": "info",
170 },
171 })
172 write(`{"jsonrpc":"2.0","id":66001,"method":"host/ui/publish","params":%s}`, string(payload))
173 }
174 continue
175 case "extension/ui/action":
176 result = `{"accepted":true,"message":"boot fake action ran"}`
177 case "extension/ui/submit":
178 result = `{"accepted":true}`
179 case "extension/intercept":
180 result = bootFakeInterceptAnswer(frame.Params)
181 case "extension/event":
182 bootFakeLogEvent(frame.Params)
183 continue // notification: never answer
184 case "extension/provider/catalog":
185 result = fmt.Sprintf(`{"providers":[%s]}`, providerDescriptor())
186 case "extension/provider/stream/open":
187 streamFakeCompletion(frame.ID, frame.Params)
188 continue
189 case "extension/provider/stream/cancel":
190 result = `{"cancelled":true}`
191 case "extension/shutdown":
192 if ignoreShutdown {
193 continue
194 }
195 write(`{"jsonrpc":"2.0","id":%s,"result":{"accepted":true}}`, string(frame.ID))
196 return
197 default:
198 continue
199 }
200 write(`{"jsonrpc":"2.0","id":%s,"result":%s}`, string(frame.ID), result)
201 }
202 }
203 if err != nil {
204 return
205 }
206 }
207 }
208
209 // bootFakeInterceptAnswer computes the steered ruling for one
210 // extension/intercept call from the env knobs.
211 func bootFakeInterceptAnswer(rawParams json.RawMessage) string {
212 var params struct {
213 Event string `json:"event"`
214 Payload json.RawMessage `json:"payload"`
215 }
216 _ = json.Unmarshal(rawParams, &params)
217 switch {
218 case params.Event != "" && params.Event == os.Getenv(bootFakeEnvBlockEvent):
219 return `{"decision":"block","reason":"boot fake block"}`
220 case params.Event != "" && params.Event == os.Getenv(bootFakeEnvInvalidEvent):
221 // A replacement that fails the point's DTO: the host must treat it as
222 // a contract violation, not apply it.
223 return `{"decision":"replace","replacement":{"bogus":true}}`
224 case params.Event == "system_prompt.build" && os.Getenv(bootFakeEnvReplacePrompt) != "":
225 // Echo the incoming workspaceRoot back so the replacement passes the
226 // payload DTO validation.
227 var payload struct {
228 WorkspaceRoot string `json:"workspaceRoot"`
229 }
230 _ = json.Unmarshal(params.Payload, &payload)
231 replacement, _ := json.Marshal(map[string]string{
232 "prompt": os.Getenv(bootFakeEnvReplacePrompt),
233 "workspaceRoot": payload.WorkspaceRoot,
234 })
235 return fmt.Sprintf(`{"decision":"replace","replacement":%s}`, string(replacement))
236 case params.Event == "input.receive" && os.Getenv(bootFakeEnvReplaceInput) != "":
237 replacement, _ := json.Marshal(map[string]string{"text": os.Getenv(bootFakeEnvReplaceInput)})
238 return fmt.Sprintf(`{"decision":"replace","replacement":%s}`, string(replacement))
239 default:
240 return `{"decision":"continue"}`
241 }
242 }
243
244 // bootFakeLogEvent appends one "event payload" line per extension/event
245 // notification to the env-named log file, so the parent test can assert what
246 // observers received.
247 func bootFakeLogEvent(rawParams json.RawMessage) {
248 logPath := os.Getenv(bootFakeEnvEventLog)
249 if logPath == "" {
250 return
251 }
252 var params struct {
253 Event string `json:"event"`
254 Payload json.RawMessage `json:"payload"`
255 }
256 if json.Unmarshal(rawParams, &params) != nil {
257 return
258 }
259 f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
260 if err != nil {
261 return
262 }
263 defer f.Close()
264 fmt.Fprintf(f, "%s %s\n", params.Event, string(params.Payload))
265 }
266
267 // installBootFakePlugin installs an enabled v1 runtime package (the
268 // re-executed test binary) into the pluginpkg state under home.
269 func installBootFakePlugin(t *testing.T, home, name string, runtime map[string]any) {
270 t.Helper()
271 exe, err := os.Executable()
272 if err != nil {
273 t.Fatalf("os.Executable: %v", err)
274 }
275 env := map[string]any{bootFakeEnvEnable: "1"}
276 for key, value := range runtime {
277 if key == "env" {
278 for k, v := range value.(map[string]string) {
279 env[k] = v
280 }
281 delete(runtime, "env")
282 }
283 }
284 runtime["command"] = exe
285 runtime["args"] = []string{"-test.run=^TestExtensionFakeSidecarHelperProcess$"}
286 runtime["env"] = env
287
288 root := filepath.Join(home, "plugins", name)
289 if err := os.MkdirAll(root, 0o755); err != nil {
290 t.Fatalf("MkdirAll: %v", err)
291 }
292 manifest, err := json.Marshal(map[string]any{
293 "apiVersion": pluginpkg.ManifestAPIVersionV1,
294 "name": name,
295 "version": "1.0.0",
296 "runtime": runtime,
297 })
298 if err != nil {
299 t.Fatalf("marshal manifest: %v", err)
300 }
301 if err := os.WriteFile(filepath.Join(root, pluginpkg.NativeManifest), manifest, 0o644); err != nil {
302 t.Fatalf("write manifest: %v", err)
303 }
304 if err := pluginpkg.Upsert(home, pluginpkg.InstalledPlugin{
305 Name: name, Root: pluginpkg.RelativeRoot(home, root), Version: "1.0.0", Enabled: true,
306 }); err != nil {
307 t.Fatalf("Upsert: %v", err)
308 }
309 }
310
311 // bootWithFakePlugin builds the runtime fixture with one installed sidecar
312 // package and returns the build result.
313 func bootWithFakePlugin(t *testing.T, name string, runtime map[string]any) *BuildResult {
314 t.Helper()
315 isolateConfigHome(t)
316 dir := robustTempDir(t)
317 t.Chdir(dir)
318 writeRuntimeFixture(t, dir)
319 installBootFakePlugin(t, config.ReasonixHomeDir(), name, runtime)
320 res, err := BuildRuntime(context.Background(), Options{})
321 if err != nil {
322 t.Fatalf("BuildRuntime: %v", err)
323 }
324 t.Cleanup(res.Controller.Close)
325 return res
326 }
327
328 func TestBootStartsExtensionSidecar(t *testing.T) {
329 res := bootWithFakePlugin(t, "bootplugin", map[string]any{
330 "intercepts": []string{"input.receive"},
331 })
332 if res.Extensions == nil {
333 t.Fatal("BuildRuntime returned no extension manager")
334 }
335 if res.Runtime == nil || res.Runtime.Len() != 1 {
336 t.Fatalf("runtime set holds %d closers, want 1 (the sidecar manager)", res.Runtime.Len())
337 }
338 client := res.Extensions.Client("bootplugin")
339 if client == nil {
340 t.Fatal("manager has no client for bootplugin")
341 }
342
343 // The sidecar speaks the real protocol: ping it with an intercept.
344 result, err := client.Intercept(context.Background(), protocol.EventInputReceive, json.RawMessage(`{"text":"ping"}`), 5*time.Second)
345 if err != nil {
346 t.Fatalf("Intercept: %v", err)
347 }
348 if result.Decision != protocol.DecisionContinue {
349 t.Fatalf("decision = %q", result.Decision)
350 }
351
352 // The snapshot catalog carries the declaration-level contribution.
353 if res.Snapshot == nil {
354 t.Fatal("snapshot is nil")
355 }
356 stubs := res.Snapshot.Catalog().Get(extension.KindInterceptor, "input.receive")
357 if len(stubs) != 1 || stubs[0].Source.PluginID != "bootplugin" {
358 t.Fatalf("interceptor stubs = %+v", stubs)
359 }
360
361 // Controller teardown retires the sidecar: process exits, runtime set
362 // closes with the controller generation.
363 res.Controller.Close()
364 waitForCond(t, "sidecar process exit", 10*time.Second, client.Exited)
365 if !res.Runtime.Closed() {
366 t.Fatal("runtime set was not closed by controller teardown")
367 }
368 }
369
370 func TestBootExtensionStrategyClaimInSnapshot(t *testing.T) {
371 res := bootWithFakePlugin(t, "claimer", map[string]any{
372 "replaces": []string{"compaction"},
373 })
374 if res.Snapshot == nil {
375 t.Fatal("snapshot is nil")
376 }
377 owner, ok := res.Snapshot.Replacements()[extension.SlotCompaction]
378 if !ok || owner.PluginID != "claimer" {
379 t.Fatalf("compaction slot owner = %+v (ok=%v)", owner, ok)
380 }
381 }
382
383 func TestBootFailsWhenTwoRuntimesClaimOneSlot(t *testing.T) {
384 isolateConfigHome(t)
385 dir := robustTempDir(t)
386 t.Chdir(dir)
387 writeRuntimeFixture(t, dir)
388 reasonixHome := config.ReasonixHomeDir()
389 installBootFakePlugin(t, reasonixHome, "claim-one", map[string]any{
390 "replaces": []string{"system_prompt"},
391 })
392 installBootFakePlugin(t, reasonixHome, "claim-two", map[string]any{
393 "replaces": []string{"system_prompt"},
394 })
395 _, err := BuildRuntime(context.Background(), Options{})
396 if err == nil {
397 t.Fatal("BuildRuntime succeeded with two runtimes claiming system_prompt")
398 }
399 var slotErr *extension.SlotConflictError
400 if !errors.As(err, &slotErr) {
401 t.Fatalf("error %v is not a SlotConflictError", err)
402 }
403 }
404
405 func TestBootFailsWhenRequiredRuntimeFails(t *testing.T) {
406 isolateConfigHome(t)
407 dir := robustTempDir(t)
408 t.Chdir(dir)
409 writeRuntimeFixture(t, dir)
410 installBootFakePlugin(t, config.ReasonixHomeDir(), "required-broken", map[string]any{
411 "required": true,
412 "env": map[string]string{bootFakeEnvInitResult: `{"protocolVersion":"2","name":"x","version":"1","stateSchemaVersion":0}`},
413 })
414 _, err := BuildRuntime(context.Background(), Options{})
415 if err == nil {
416 t.Fatal("BuildRuntime succeeded with a broken required runtime")
417 }
418 var requiredErr *sidecar.RequiredStartError
419 if !errors.As(err, &requiredErr) {
420 t.Fatalf("error %v is not a RequiredStartError", err)
421 }
422 }
423
424 func TestBootOptionalRuntimeFailureDegradesToWarning(t *testing.T) {
425 res := bootWithFakePlugin(t, "optional-broken", map[string]any{
426 "env": map[string]string{bootFakeEnvInitResult: `{"protocolVersion":"2","name":"x","version":"1","stateSchemaVersion":0}`},
427 })
428 // Optional failure: boot succeeds, no manager, empty runtime set.
429 if res.Extensions != nil {
430 t.Fatal("broken optional runtime produced a manager")
431 }
432 if res.Runtime == nil || res.Runtime.Len() != 0 {
433 t.Fatalf("runtime set holds %d closers, want 0", res.Runtime.Len())
434 }
435 }
436
437 // TestRebuildRetiresOldSidecars pins the Rebuild contract: the old
438 // controller's Close retires its sidecars, while the replacement build's
439 // sidecars keep serving their own generation.
440 func TestRebuildRetiresOldSidecars(t *testing.T) {
441 isolateConfigHome(t)
442 dir := robustTempDir(t)
443 t.Chdir(dir)
444 writeRuntimeFixture(t, dir)
445 installBootFakePlugin(t, config.ReasonixHomeDir(), "rebuildplugin", map[string]any{})
446
447 oldRes, err := BuildRuntime(context.Background(), Options{})
448 if err != nil {
449 t.Fatalf("BuildRuntime: %v", err)
450 }
451 newRes, err := Rebuild(context.Background(), oldRes.Controller, Options{})
452 if err != nil {
453 oldRes.Controller.Close()
454 t.Fatalf("Rebuild: %v", err)
455 }
456 t.Cleanup(newRes.Controller.Close)
457 if oldRes.Extensions == nil || newRes.Extensions == nil {
458 t.Fatal("both builds must have extension managers")
459 }
460 oldClient := oldRes.Extensions.Client("rebuildplugin")
461 newClient := newRes.Extensions.Client("rebuildplugin")
462 if oldClient == nil || newClient == nil {
463 t.Fatal("both builds must have a sidecar client")
464 }
465 if oldRes.Snapshot.Generation() == newRes.Snapshot.Generation() {
466 t.Fatal("rebuild reused the old generation")
467 }
468
469 // Closing the old controller retires the old sidecar only.
470 oldRes.Controller.Close()
471 waitForCond(t, "old sidecar exit", 10*time.Second, oldClient.Exited)
472 result, err := newClient.Intercept(context.Background(), protocol.EventSessionStart, json.RawMessage(`{}`), 5*time.Second)
473 if err != nil || result.Decision != protocol.DecisionContinue {
474 t.Fatalf("new sidecar Intercept after old close = %+v, %v", result, err)
475 }
476
477 newRes.Controller.Close()
478 waitForCond(t, "new sidecar exit", 10*time.Second, newClient.Exited)
479 }
480
481 func waitForCond(t *testing.T, what string, timeout time.Duration, cond func() bool) {
482 t.Helper()
483 deadline := time.Now().Add(timeout)
484 for time.Now().Before(deadline) {
485 if cond() {
486 return
487 }
488 time.Sleep(10 * time.Millisecond)
489 }
490 t.Fatalf("timed out waiting for %s", what)
491 }
492
492 lines GO