返回 DeepSeek-Reasonix
reload_extensions_test.go
根目录 / internal / acp / reload_extensions_test.go
1 package acp
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "strings"
8 "testing"
9
10 "reasonix/internal/command"
11 "reasonix/internal/control"
12 )
13
14 // reloadFactory wraps configurableFactory with the SessionRebuilder seam the
15 // reloadExtensions handler requires, recording the rebuild base controller.
16 type reloadFactory struct {
17 *configurableFactory
18 rebuildCalls int
19 lastOld *control.Controller
20 rebuildErr error
21 replacement *control.Controller
22 }
23
24 func (f *reloadFactory) RebuildSession(_ context.Context, _ SessionParams, old *control.Controller) (*control.Controller, error) {
25 f.rebuildCalls++
26 f.lastOld = old
27 if f.rebuildErr != nil {
28 return nil, f.rebuildErr
29 }
30 if f.replacement != nil {
31 return f.replacement, nil
32 }
33 return control.New(control.Options{Label: "rebuilt"}), nil
34 }
35
36 func reloadExtensionsSession(t *testing.T, id string, ctrl acpController, notifier *fakeNotifier) *acpSession {
37 t.Helper()
38 return &acpSession{
39 id: id,
40 ctrl: ctrl,
41 sink: newUpdateSink(notifier, id),
42 cwd: t.TempDir(),
43 model: "fast",
44 runtimeProfile: "balanced",
45 toolApprovalMode: control.ToolApprovalAsk,
46 modeID: sessionModeNormal,
47 }
48 }
49
50 func marshalReloadParams(t *testing.T, sessionID string) json.RawMessage {
51 t.Helper()
52 raw, err := json.Marshal(SessionReloadExtensionsParams{SessionID: sessionID})
53 if err != nil {
54 t.Fatal(err)
55 }
56 return raw
57 }
58
59 // TestSessionReloadExtensionsUnknownSession mirrors the sessionSteer unknown-
60 // session contract.
61 func TestSessionReloadExtensionsUnknownSession(t *testing.T) {
62 svc := &service{factory: &configurableFactory{}, sessions: map[string]*acpSession{}}
63 _, err := svc.sessionReloadExtensions(context.Background(), marshalReloadParams(t, "nope"))
64 rpcErr, ok := err.(*RPCError)
65 if !ok {
66 t.Fatalf("err = %T %v, want *RPCError", err, err)
67 }
68 if rpcErr.Code != ErrInvalidParams {
69 t.Fatalf("code = %d, want ErrInvalidParams", rpcErr.Code)
70 }
71 if !strings.Contains(rpcErr.Message, "unknown session") {
72 t.Fatalf("message = %q, want unknown-session detail", rpcErr.Message)
73 }
74 }
75
76 // TestSessionReloadExtensionsUnavailableWithoutRebuilder: a Factory without
77 // the SessionRebuilder seam fails closed instead of falling back to a plain
78 // rebuild.
79 func TestSessionReloadExtensionsUnavailableWithoutRebuilder(t *testing.T) {
80 notifier := &fakeNotifier{}
81 sess := reloadExtensionsSession(t, "sess-reload-noseam", control.New(control.Options{}), notifier)
82 svc := &service{factory: &configurableFactory{}, sessions: map[string]*acpSession{sess.id: sess}}
83 _, err := svc.sessionReloadExtensions(context.Background(), marshalReloadParams(t, sess.id))
84 rpcErr, ok := err.(*RPCError)
85 if !ok {
86 t.Fatalf("err = %T %v, want *RPCError", err, err)
87 }
88 if rpcErr.Code != ErrInvalidRequest {
89 t.Fatalf("code = %d, want ErrInvalidRequest", rpcErr.Code)
90 }
91 if !strings.Contains(rpcErr.Message, "unavailable") {
92 t.Fatalf("message = %q, want unavailable detail", rpcErr.Message)
93 }
94 }
95
96 // TestSessionReloadExtensionsSwapsAndClosesOldAfterSwap covers the success
97 // path: the replacement is built from the outgoing controller, published
98 // before the outgoing one is released, and clients get a fresh
99 // available_commands_update.
100 func TestSessionReloadExtensionsSwapsAndClosesOldAfterSwap(t *testing.T) {
101 notifier := &fakeNotifier{}
102 released := false
103 var ctrlAtRelease acpController
104 var sess *acpSession
105 old := control.New(control.Options{
106 Label: "old",
107 Cleanup: func() {
108 released = true
109 ctrlAtRelease = sess.ctrl
110 },
111 })
112 replacement := control.New(control.Options{
113 Label: "rebuilt",
114 Commands: []command.Command{{Name: "fresh-cmd", Description: "from the reloaded runtime"}},
115 })
116 factory := &reloadFactory{configurableFactory: &configurableFactory{}, replacement: replacement}
117 sess = reloadExtensionsSession(t, "sess-reload-ok", old, notifier)
118 svc := &service{factory: factory, sessions: map[string]*acpSession{sess.id: sess}}
119
120 res, err := svc.sessionReloadExtensions(context.Background(), marshalReloadParams(t, sess.id))
121 if err != nil {
122 t.Fatalf("sessionReloadExtensions: %v", err)
123 }
124 if got, ok := res.(SessionReloadExtensionsResult); !ok || got.Queued {
125 t.Fatalf("result = %#v, want SessionReloadExtensionsResult{Queued:false}", res)
126 }
127 if factory.rebuildCalls != 1 {
128 t.Fatalf("rebuild ran %d times, want 1", factory.rebuildCalls)
129 }
130 if factory.lastOld != old {
131 t.Fatal("replacement was not built from the outgoing controller")
132 }
133 if sess.ctrl != replacement {
134 t.Fatal("session controller was not swapped to the replacement")
135 }
136 if !released {
137 t.Fatal("outgoing controller was not released")
138 }
139 if ctrlAtRelease != replacement {
140 t.Fatal("outgoing controller was released before the swap published the replacement")
141 }
142 // Refreshed plugin commands are pushed to the client without waiting for
143 // the next turn.
144 foundCommands := false
145 for i := range notifier.notifs {
146 if reloadTestUpdateMap(t, notifier, i)["sessionUpdate"] == "available_commands_update" {
147 foundCommands = true
148 break
149 }
150 }
151 if !foundCommands {
152 t.Fatal("no available_commands_update notification after reload")
153 }
154 }
155
156 // reloadTestUpdateMap decodes the i-th captured session/update notification's
157 // nested update object (fakeNotifier.updateMap pins another test's session
158 // id, so this package-local variant skips that check).
159 func reloadTestUpdateMap(t *testing.T, f *fakeNotifier, i int) map[string]any {
160 t.Helper()
161 f.mu.Lock()
162 defer f.mu.Unlock()
163 if i >= len(f.notifs) {
164 t.Fatalf("only %d notifications captured, wanted index %d", len(f.notifs), i)
165 }
166 raw, err := json.Marshal(f.notifs[i].params)
167 if err != nil {
168 t.Fatalf("marshal params: %v", err)
169 }
170 var decoded struct {
171 Update map[string]any `json:"update"`
172 }
173 if err := json.Unmarshal(raw, &decoded); err != nil {
174 t.Fatalf("unmarshal params: %v", err)
175 }
176 return decoded.Update
177 }
178
179 // TestSessionReloadExtensionsBusyQueuesThenDrains covers the queue contract:
180 // exactly one reload is coalesced while a turn runs, and the drain rebuilds
181 // once the session is idle again.
182 func TestSessionReloadExtensionsBusyQueuesThenDrains(t *testing.T) {
183 notifier := &fakeNotifier{}
184 factory := &reloadFactory{configurableFactory: &configurableFactory{}}
185 sess := reloadExtensionsSession(t, "sess-reload-busy", control.New(control.Options{Label: "old"}), notifier)
186 svc := &service{factory: factory, sessions: map[string]*acpSession{sess.id: sess}}
187
188 // A turn is in flight.
189 if _, _, ok := sess.begin(context.Background()); !ok {
190 t.Fatal("could not mark the session running")
191 }
192 res, err := svc.sessionReloadExtensions(context.Background(), marshalReloadParams(t, sess.id))
193 if err != nil {
194 t.Fatalf("busy sessionReloadExtensions: %v", err)
195 }
196 if got, ok := res.(SessionReloadExtensionsResult); !ok || !got.Queued {
197 t.Fatalf("result = %#v, want SessionReloadExtensionsResult{Queued:true}", res)
198 }
199 // A second request while busy coalesces into the same queued reload.
200 if _, err := svc.sessionReloadExtensions(context.Background(), marshalReloadParams(t, sess.id)); err != nil {
201 t.Fatalf("second busy sessionReloadExtensions: %v", err)
202 }
203 if factory.rebuildCalls != 0 {
204 t.Fatalf("rebuild ran %d times while busy, want 0", factory.rebuildCalls)
205 }
206 if !sess.pendingReload {
207 t.Fatal("busy reload did not queue")
208 }
209
210 // The turn finishes; the drain runs exactly one rebuild against the idle
211 // session.
212 sess.finish()
213 svc.drainPendingReload(context.Background(), sess)
214 if factory.rebuildCalls != 1 {
215 t.Fatalf("drain rebuilt %d times, want exactly 1", factory.rebuildCalls)
216 }
217 if sess.pendingReload {
218 t.Fatal("queued reload flag survived the drain")
219 }
220 }
221
222 // TestSessionReloadExtensionsFailureKeepsOldController: a failed build leaves
223 // the session on the outgoing controller and reports the error.
224 func TestSessionReloadExtensionsFailureKeepsOldController(t *testing.T) {
225 notifier := &fakeNotifier{}
226 old := control.New(control.Options{Label: "old"})
227 factory := &reloadFactory{configurableFactory: &configurableFactory{}, rebuildErr: errReloadBuildForTest}
228 sess := reloadExtensionsSession(t, "sess-reload-fail", old, notifier)
229 svc := &service{factory: factory, sessions: map[string]*acpSession{sess.id: sess}}
230
231 _, err := svc.sessionReloadExtensions(context.Background(), marshalReloadParams(t, sess.id))
232 if err == nil {
233 t.Fatal("failed build produced a nil error")
234 }
235 if sess.ctrl != old {
236 t.Fatal("failed reload replaced the session controller")
237 }
238 }
239
240 var errReloadBuildForTest = errors.New("build exploded")
241
241 lines GO