返回 DeepSeek-Reasonix
extension_firstboot_test.go
根目录 / internal / boot / extension_firstboot_test.go
1 package boot
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "os"
9 "path/filepath"
10 "strconv"
11 "strings"
12 "testing"
13 "time"
14
15 "reasonix/internal/config"
16 "reasonix/internal/event"
17 "reasonix/internal/extension/protocol"
18 "reasonix/internal/extension/providerext"
19 "reasonix/internal/extension/sidecar"
20 "reasonix/internal/provider"
21 )
22
23 // First-boot coverage for extension-hosted providers (stage 7 follow-up):
24 // sidecars start in preflight BEFORE model resolution, so a plugin-namespaced
25 // default_model resolves and streams on the very first build, and switching
26 // to/from it rides the ordinary Rebuild path.
27
28 // writePluginDefaultFixture writes the shared runtime fixture with
29 // default_model pointed at a plugin-namespaced ref.
30 func writePluginDefaultFixture(t *testing.T, dir, pluginRef string) {
31 t.Helper()
32 writeFile(t, dir, "reasonix.toml", fmt.Sprintf(`
33 default_model = %q
34
35 [agent]
36 system_prompt = "BASE SYSTEM PROMPT"
37
38 [environment]
39 enabled = false
40
41 [[providers]]
42 name = "test-model"
43 kind = "openai"
44 base_url = "https://example.invalid"
45 model = "x"
46 api_key_env = "REASONIX_TEST_KEY_UNSET"
47 `, pluginRef))
48 }
49
50 // installProviderFake installs the fake sidecar in provider mode under name.
51 func installProviderFake(t *testing.T, home, name string, extraEnv map[string]string) {
52 t.Helper()
53 env := map[string]string{
54 bootFakeEnvPluginName: name,
55 bootFakeEnvProvider: "1",
56 }
57 for k, v := range extraEnv {
58 env[k] = v
59 }
60 installBootFakePlugin(t, home, name, map[string]any{
61 "capabilities": []string{"providers"},
62 "env": env,
63 })
64 }
65
66 // runTurnAndCollectAssistant drives one synchronous turn and returns the
67 // concatenated assistant text it appended to history.
68 func runTurnAndCollectAssistant(t *testing.T, res *BuildResult, input string) string {
69 t.Helper()
70 ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
71 defer cancel()
72 if err := res.Controller.RunTurn(ctx, input); err != nil {
73 t.Fatalf("RunTurn: %v", err)
74 }
75 var sb strings.Builder
76 for _, m := range res.Controller.History() {
77 if m.Role == provider.RoleAssistant {
78 sb.WriteString(m.Content)
79 }
80 }
81 return sb.String()
82 }
83
84 func TestBootFirstBootPluginDefaultModelStreams(t *testing.T) {
85 isolateConfigHome(t)
86 dir := robustTempDir(t)
87 t.Chdir(dir)
88 name := "firstboot"
89 ref := "plugin/" + name + "/fake/x"
90 writePluginDefaultFixture(t, dir, ref)
91 installProviderFake(t, config.ReasonixHomeDir(), name, nil)
92
93 res, err := BuildRuntime(context.Background(), Options{})
94 if err != nil {
95 t.Fatalf("BuildRuntime with plugin default_model: %v", err)
96 }
97 t.Cleanup(res.Controller.Close)
98
99 // The executor was built from the plugin ref on the FIRST boot — before
100 // any resolver merge of earlier stages, model resolution itself routed
101 // through the merged catalog.
102 if got := res.Controller.ModelRef(); got != ref {
103 t.Fatalf("executor model ref = %q, want %q", got, ref)
104 }
105 // The only streamable provider in this build is the extension one (the
106 // config provider's endpoint is unreachable), so the fixed fake
107 // completion in history proves the executor streams from the sidecar.
108 assistant := runTurnAndCollectAssistant(t, res, "say hi")
109 if !strings.Contains(assistant, "fake-hello fake-world") {
110 t.Fatalf("assistant text = %q, want the extension provider's fixed completion", assistant)
111 }
112 }
113
114 func TestBootUnknownPluginRefListsAvailableRefs(t *testing.T) {
115 isolateConfigHome(t)
116 dir := robustTempDir(t)
117 t.Chdir(dir)
118 writePluginDefaultFixture(t, dir, "plugin/nope/x/y")
119 installProviderFake(t, config.ReasonixHomeDir(), "providerdemo", nil)
120
121 _, err := BuildRuntime(context.Background(), Options{})
122 if err == nil {
123 t.Fatal("BuildRuntime succeeded with an unknown plugin default_model")
124 }
125 if !errors.Is(err, ErrUnknownModel) {
126 t.Fatalf("error %v is not boot.ErrUnknownModel", err)
127 }
128 if !strings.Contains(err.Error(), `"plugin/nope/x/y"`) {
129 t.Fatalf("error %q should name the requested ref", err)
130 }
131 if !strings.Contains(err.Error(), "plugin/providerdemo/fake/x") {
132 t.Fatalf("error %q should list the available plugin ref", err)
133 }
134 }
135
136 // TestBootSwitchToPluginModelStreams pins the switch path: boot on the
137 // builtin model, Rebuild onto the plugin ref, and the replacement streams
138 // from the extension provider while the old generation stays fully alive
139 // until the caller closes it.
140 func TestBootSwitchToPluginModelStreams(t *testing.T) {
141 isolateConfigHome(t)
142 dir := robustTempDir(t)
143 t.Chdir(dir)
144 writeRuntimeFixture(t, dir)
145 name := "switchdemo"
146 ref := "plugin/" + name + "/fake/x"
147 installProviderFake(t, config.ReasonixHomeDir(), name, nil)
148
149 oldRes, err := BuildRuntime(context.Background(), Options{})
150 if err != nil {
151 t.Fatalf("BuildRuntime: %v", err)
152 }
153 if oldRes.Extensions == nil || oldRes.Extensions.Client(name) == nil {
154 t.Fatal("first build has no sidecar client")
155 }
156 oldClient := oldRes.Extensions.Client(name)
157
158 newRes, err := Rebuild(context.Background(), oldRes.Controller, Options{Model: ref})
159 if err != nil {
160 oldRes.Controller.Close()
161 t.Fatalf("Rebuild onto plugin ref: %v", err)
162 }
163 t.Cleanup(newRes.Controller.Close)
164
165 // The old generation is untouched by the rebuild: its sidecar still
166 // serves, its runtime set is open.
167 if oldClient.Exited() {
168 t.Fatal("Rebuild retired the old sidecar before the swap completed")
169 }
170 if oldRes.Runtime.Closed() {
171 t.Fatal("Rebuild closed the old runtime set")
172 }
173 if got := newRes.Controller.ModelRef(); got != ref {
174 t.Fatalf("switched model ref = %q, want %q", got, ref)
175 }
176 assistant := runTurnAndCollectAssistant(t, newRes, "say hi")
177 if !strings.Contains(assistant, "fake-hello fake-world") {
178 t.Fatalf("switched turn = %q, want the extension provider's fixed completion", assistant)
179 }
180
181 // Closing the old controller after the swap retires only its generation.
182 oldRes.Controller.Close()
183 waitForCond(t, "old sidecar exit", 10*time.Second, oldClient.Exited)
184 newClient := newRes.Extensions.Client(name)
185 if newClient == nil || newClient.Exited() {
186 t.Fatal("the switched generation's sidecar died with the old controller")
187 }
188 }
189
190 // TestBootStartsExtensionPackagesOncePerBuild pins the single-start contract:
191 // preflight starts the generation's sidecars and snapshot assembly reuses
192 // that same Manager — no second StartPackages anywhere in one build.
193 func TestBootStartsExtensionPackagesOncePerBuild(t *testing.T) {
194 isolateConfigHome(t)
195 dir := robustTempDir(t)
196 t.Chdir(dir)
197 writeRuntimeFixture(t, dir)
198 installBootFakePlugin(t, config.ReasonixHomeDir(), "counted", map[string]any{})
199
200 calls := 0
201 orig := startExtensionPackages
202 startExtensionPackages = func(ctx context.Context, home string, sessionCtx protocol.SessionContext, ui sidecar.UIHandler) (*sidecar.Manager, []string, error) {
203 calls++
204 return orig(ctx, home, sessionCtx, ui)
205 }
206 t.Cleanup(func() { startExtensionPackages = orig })
207
208 res, err := BuildRuntime(context.Background(), Options{})
209 if err != nil {
210 t.Fatalf("BuildRuntime: %v", err)
211 }
212 t.Cleanup(res.Controller.Close)
213 if calls != 1 {
214 t.Fatalf("StartPackages ran %d times in one build, want exactly 1", calls)
215 }
216 if res.Extensions == nil || res.Extensions.Client("counted") == nil {
217 t.Fatal("the preflighted manager did not reach the build result")
218 }
219 }
220
221 func TestBootRedactsExtensionStartupWarnings(t *testing.T) {
222 const secret = "sk-abcdef1234567890SECRETKEY"
223 isolateConfigHome(t)
224 dir := robustTempDir(t)
225 t.Chdir(dir)
226 writeRuntimeFixture(t, dir)
227 installBootFakePlugin(t, config.ReasonixHomeDir(), "warning-source", map[string]any{})
228
229 orig := startExtensionPackages
230 startExtensionPackages = func(ctx context.Context, home string, sessionCtx protocol.SessionContext, ui sidecar.UIHandler) (*sidecar.Manager, []string, error) {
231 manager, warnings, err := orig(ctx, home, sessionCtx, ui)
232 return manager, append(warnings, "sidecar rejected api_key="+secret), err
233 }
234 t.Cleanup(func() { startExtensionPackages = orig })
235
236 var notices []string
237 res, err := BuildRuntime(context.Background(), Options{Sink: event.FuncSink(func(ev event.Event) {
238 if ev.Kind == event.Notice {
239 notices = append(notices, ev.Text)
240 }
241 })})
242 if err != nil {
243 t.Fatalf("BuildRuntime: %v", err)
244 }
245 t.Cleanup(res.Controller.Close)
246 joined := strings.Join(notices, "\n")
247 if strings.Contains(joined, secret) {
248 t.Fatalf("extension startup notice leaked a credential: %q", joined)
249 }
250 if !strings.Contains(joined, "****") {
251 t.Fatalf("extension startup notice contains no redaction marker: %q", joined)
252 }
253 }
254
255 // readFakePID polls for the fake sidecar's PID file and returns the PID.
256 func readFakePID(t *testing.T, path string) int {
257 t.Helper()
258 pid := 0
259 waitForCond(t, "fake sidecar PID file", 10*time.Second, func() bool {
260 body, err := os.ReadFile(path)
261 if err != nil {
262 return false
263 }
264 pid, err = strconv.Atoi(strings.TrimSpace(string(body)))
265 return err == nil && pid > 0
266 })
267 return pid
268 }
269
270 // TestBootRequiredExitLeavesNoSidecarProcess: a required sidecar that exits
271 // before answering the handshake fails the build with RequiredStartError —
272 // and its process is gone, not leaked.
273 func TestBootRequiredExitLeavesNoSidecarProcess(t *testing.T) {
274 isolateConfigHome(t)
275 dir := robustTempDir(t)
276 t.Chdir(dir)
277 writeRuntimeFixture(t, dir)
278 pidFile := filepath.Join(dir, "sidecar.pid")
279 installBootFakePlugin(t, config.ReasonixHomeDir(), "required-dies", map[string]any{
280 "required": true,
281 "env": map[string]string{
282 bootFakeEnvPIDFile: pidFile,
283 bootFakeEnvExitImmediately: "1",
284 },
285 })
286
287 _, err := BuildRuntime(context.Background(), Options{})
288 if err == nil {
289 t.Fatal("BuildRuntime succeeded with a required sidecar that exits immediately")
290 }
291 var requiredErr *sidecar.RequiredStartError
292 if !errors.As(err, &requiredErr) {
293 t.Fatalf("error %v is not a RequiredStartError", err)
294 }
295 pid := readFakePID(t, pidFile)
296 waitForCond(t, "required sidecar process exit", 10*time.Second, func() bool { return !pidAlive(pid) })
297 }
298
299 // TestBootProviderConflictLeavesNoSidecarProcess: a provider-ref conflict
300 // without the plugin's claim fails the build with ConflictError — and the
301 // preflighted sidecar is retired, not leaked.
302 func TestBootProviderConflictLeavesNoSidecarProcess(t *testing.T) {
303 isolateConfigHome(t)
304 dir := robustTempDir(t)
305 t.Chdir(dir)
306 name := "conflicter"
307 writeRuntimeFixtureWithConflictingProvider(t, dir, name)
308 pidFile := filepath.Join(dir, "sidecar.pid")
309 installProviderFake(t, config.ReasonixHomeDir(), name, map[string]string{
310 bootFakeEnvPIDFile: pidFile,
311 })
312
313 _, err := BuildRuntime(context.Background(), Options{})
314 if err == nil {
315 t.Fatal("BuildRuntime succeeded with an unclaimed provider conflict")
316 }
317 var conflictErr *providerext.ConflictError
318 if !errors.As(err, &conflictErr) {
319 t.Fatalf("error %v is not a providerext.ConflictError", err)
320 }
321 pid := readFakePID(t, pidFile)
322 waitForCond(t, "conflicted sidecar process exit", 10*time.Second, func() bool { return !pidAlive(pid) })
323 }
324
325 // TestRebuildPluginPreflightFailureKeepsOldRuntime pins reload atomicity with
326 // extensions: a Rebuild whose NEW generation fails preflight (a newly
327 // installed required plugin cannot start) returns the error and leaves the
328 // old controller, its manager, and its sidecars fully usable.
329 func TestRebuildPluginPreflightFailureKeepsOldRuntime(t *testing.T) {
330 isolateConfigHome(t)
331 dir := robustTempDir(t)
332 t.Chdir(dir)
333 writeRuntimeFixture(t, dir)
334 installBootFakePlugin(t, config.ReasonixHomeDir(), "stable", map[string]any{})
335
336 oldRes, err := BuildRuntime(context.Background(), Options{})
337 if err != nil {
338 t.Fatalf("BuildRuntime: %v", err)
339 }
340 t.Cleanup(oldRes.Controller.Close)
341 oldClient := oldRes.Extensions.Client("stable")
342 if oldClient == nil {
343 t.Fatal("first build has no sidecar client")
344 }
345
346 // A newly installed required plugin dies immediately: the replacement
347 // build's preflight must fail before touching the old generation.
348 installBootFakePlugin(t, config.ReasonixHomeDir(), "required-dies", map[string]any{
349 "required": true,
350 "env": map[string]string{bootFakeEnvExitImmediately: "1"},
351 })
352 _, err = Rebuild(context.Background(), oldRes.Controller, Options{})
353 if err == nil {
354 t.Fatal("Rebuild succeeded with a required plugin that cannot start")
355 }
356 var requiredErr *sidecar.RequiredStartError
357 if !errors.As(err, &requiredErr) {
358 t.Fatalf("Rebuild error %v is not a RequiredStartError", err)
359 }
360
361 // Old runtime fully usable: sidecar alive and answering, runtime set open.
362 if oldClient.Exited() {
363 t.Fatal("failed Rebuild retired the old sidecar")
364 }
365 if oldRes.Runtime.Closed() {
366 t.Fatal("failed Rebuild closed the old runtime set")
367 }
368 result, ierr := oldClient.Intercept(context.Background(), protocol.EventSessionStart, json.RawMessage(`{}`), 5*time.Second)
369 if ierr != nil || result.Decision != protocol.DecisionContinue {
370 t.Fatalf("old sidecar Intercept after failed Rebuild = %+v, %v", result, ierr)
371 }
372 }
373
373 lines GO