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