返回 DeepSeek-Reasonix
extensions_test.go
根目录 / internal / control / extensions_test.go
1 package control
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "os"
8 "path/filepath"
9 "strings"
10 "sync"
11 "testing"
12 "time"
13
14 "reasonix/internal/agent"
15 "reasonix/internal/agent/testutil"
16 "reasonix/internal/event"
17 "reasonix/internal/extension"
18 "reasonix/internal/extension/dispatch"
19 "reasonix/internal/extension/protocol"
20 "reasonix/internal/provider"
21 "reasonix/internal/tool"
22 )
23
24 // Stage 6b1 control wiring tests. The dispatcher under test is real; only its
25 // sidecar client is faked, so every assertion exercises the actual dispatch
26 // ruling logic (chain walk, strict replacement decode, slot ownership).
27
28 type recordedExtCall struct {
29 event protocol.InterceptEvent
30 payload json.RawMessage
31 }
32
33 // fakeExtClient is a scriptable dispatch.Client recording every call.
34 type fakeExtClient struct {
35 mu sync.Mutex
36 interceptFn func(event protocol.InterceptEvent, payload json.RawMessage) (protocol.InterceptResult, error)
37 intercepts []recordedExtCall
38 notifies []recordedExtCall
39 }
40
41 func (f *fakeExtClient) Intercept(_ context.Context, event protocol.InterceptEvent, payload json.RawMessage, _ time.Duration) (protocol.InterceptResult, error) {
42 f.mu.Lock()
43 f.intercepts = append(f.intercepts, recordedExtCall{event: event, payload: append(json.RawMessage(nil), payload...)})
44 fn := f.interceptFn
45 f.mu.Unlock()
46 if fn == nil {
47 return protocol.InterceptResult{Decision: protocol.DecisionContinue}, nil
48 }
49 return fn(event, payload)
50 }
51
52 func (f *fakeExtClient) TryNotifyEvent(event protocol.InterceptEvent, payload json.RawMessage) error {
53 f.mu.Lock()
54 defer f.mu.Unlock()
55 f.notifies = append(f.notifies, recordedExtCall{event: event, payload: append(json.RawMessage(nil), payload...)})
56 return nil
57 }
58
59 func (f *fakeExtClient) notifyEvents() []protocol.InterceptEvent {
60 f.mu.Lock()
61 defer f.mu.Unlock()
62 out := make([]protocol.InterceptEvent, len(f.notifies))
63 for i, call := range f.notifies {
64 out[i] = call.event
65 }
66 return out
67 }
68
69 func (f *fakeExtClient) notifyPayloadsFor(event protocol.InterceptEvent) []json.RawMessage {
70 f.mu.Lock()
71 defer f.mu.Unlock()
72 var out []json.RawMessage
73 for _, call := range f.notifies {
74 if call.event == event {
75 out = append(out, call.payload)
76 }
77 }
78 return out
79 }
80
81 const extensionTestPlugin = "fake"
82
83 // newExtensionTestDispatcher builds a dispatcher whose chain lists the fake
84 // plugin at every given point and whose slots (slot → plugin ID) are owned as
85 // given. The fake is optional-class unless it owns a slot.
86 func newExtensionTestDispatcher(client dispatch.Client, points []extension.InterceptorPoint, slots map[extension.Slot]string) *dispatch.Dispatcher {
87 chain := map[extension.InterceptorPoint][]extension.Contribution{}
88 for _, point := range points {
89 chain[point] = []extension.Contribution{{
90 Kind: extension.KindInterceptor,
91 ID: string(point),
92 Source: extension.ContributionSource{Scope: extension.ScopePlugin, PluginID: extensionTestPlugin},
93 }}
94 }
95 replacements := map[extension.Slot]extension.ContributionSource{}
96 for slot, plugin := range slots {
97 replacements[slot] = extension.ContributionSource{Scope: extension.ScopePlugin, PluginID: plugin}
98 }
99 return dispatch.New(chain, replacements, func(string) dispatch.Client { return client }, nil, dispatch.Options{})
100 }
101
102 var sessionPoints = []extension.InterceptorPoint{
103 extension.PointSessionStart, extension.PointSessionEnd, extension.PointSessionLoad,
104 extension.PointSessionSave, extension.PointSessionRotate,
105 }
106
107 // recordingSink captures emitted events.
108 type recordingSink struct {
109 mu sync.Mutex
110 events []event.Event
111 }
112
113 func (s *recordingSink) Emit(ev event.Event) {
114 s.mu.Lock()
115 defer s.mu.Unlock()
116 s.events = append(s.events, ev)
117 }
118
119 func (s *recordingSink) all() []event.Event {
120 s.mu.Lock()
121 defer s.mu.Unlock()
122 return append([]event.Event(nil), s.events...)
123 }
124
125 func runTestTurn(c *Controller, input string) error {
126 return newTurnOrchestrator(c).runTurnWithRawDisplay(context.Background(), input, input, "")
127 }
128
129 func TestInputReceiveContinue(t *testing.T) {
130 client := &fakeExtClient{}
131 d := newExtensionTestDispatcher(client, []extension.InterceptorPoint{extension.PointInputReceive}, nil)
132 runner := &fakeTurnRunner{}
133 c := New(Options{Runner: runner, Extensions: d})
134
135 if err := runTestTurn(c, "hello world"); err != nil {
136 t.Fatal(err)
137 }
138 if len(runner.inputs) != 1 || !strings.Contains(runner.inputs[0], "hello world") {
139 t.Fatalf("runner inputs = %v, want the composed turn", runner.inputs)
140 }
141 if len(client.intercepts) != 1 || client.intercepts[0].event != protocol.EventInputReceive {
142 t.Fatalf("intercepts = %+v, want exactly one input.receive", client.intercepts)
143 }
144 if !strings.Contains(string(client.intercepts[0].payload), "hello world") {
145 t.Fatalf("intercept payload = %s, want the composed text", client.intercepts[0].payload)
146 }
147 }
148
149 func TestInputReceiveReplace(t *testing.T) {
150 client := &fakeExtClient{
151 interceptFn: func(protocol.InterceptEvent, json.RawMessage) (protocol.InterceptResult, error) {
152 return protocol.InterceptResult{Decision: protocol.DecisionReplace, Replacement: json.RawMessage(`{"text":"rewritten input"}`)}, nil
153 },
154 }
155 d := newExtensionTestDispatcher(client, []extension.InterceptorPoint{extension.PointInputReceive}, nil)
156 runner := &fakeTurnRunner{}
157 c := New(Options{Runner: runner, Extensions: d})
158
159 if err := runTestTurn(c, "original"); err != nil {
160 t.Fatal(err)
161 }
162 if len(runner.inputs) != 1 || runner.inputs[0] != "rewritten input" {
163 t.Fatalf("runner inputs = %v, want the replaced text only", runner.inputs)
164 }
165 if !strings.Contains(string(client.intercepts[0].payload), "original") {
166 t.Fatalf("intercept payload = %s, want the pre-replacement text", client.intercepts[0].payload)
167 }
168 }
169
170 func TestInputReceiveBlock(t *testing.T) {
171 client := &fakeExtClient{
172 interceptFn: func(protocol.InterceptEvent, json.RawMessage) (protocol.InterceptResult, error) {
173 return protocol.InterceptResult{Decision: protocol.DecisionBlock, Reason: "api_key=sk-SECRET refused"}, nil
174 },
175 }
176 d := newExtensionTestDispatcher(client, []extension.InterceptorPoint{extension.PointInputReceive}, nil)
177 runner := &fakeTurnRunner{}
178 sink := &recordingSink{}
179 c := New(Options{Runner: runner, Sink: sink, Extensions: d})
180
181 if err := runTestTurn(c, "do something"); err != nil {
182 t.Fatal(err)
183 }
184 if len(runner.inputs) != 0 {
185 t.Fatalf("blocked turn reached the runner: %v", runner.inputs)
186 }
187 var notice *event.Event
188 for i, ev := range sink.all() {
189 if ev.Kind == event.Notice {
190 notice = &sink.all()[i]
191 }
192 }
193 if notice == nil {
194 t.Fatal("blocked turn surfaced no notice")
195 }
196 if strings.Contains(notice.Detail, "sk-SECRET") {
197 t.Fatalf("block reason was not credential-redacted: %q", notice.Detail)
198 }
199 if !strings.Contains(notice.Detail, "refused") {
200 t.Fatalf("block reason detail = %q, want the extension's reason", notice.Detail)
201 }
202 }
203
204 func TestInputReceiveNilDispatcherUntouched(t *testing.T) {
205 runner := &fakeTurnRunner{}
206 c := New(Options{Runner: runner})
207 if _, wrapped := c.sink.(*frontendEventSink); wrapped {
208 t.Fatal("sink wrapped without a dispatcher — the nil fast path must stay unwrapped")
209 }
210 if err := runTestTurn(c, "plain"); err != nil {
211 t.Fatal(err)
212 }
213 if len(runner.inputs) != 1 {
214 t.Fatalf("runner inputs = %v, want 1", runner.inputs)
215 }
216 }
217
218 // TestInputReceiveInterceptedOnHeadlessRun pins the shared seam: the
219 // synchronous headless Run path composes input outside the turn orchestrator
220 // and must cross the same input.receive chain.
221 func TestInputReceiveInterceptedOnHeadlessRun(t *testing.T) {
222 client := &fakeExtClient{
223 interceptFn: func(protocol.InterceptEvent, json.RawMessage) (protocol.InterceptResult, error) {
224 return protocol.InterceptResult{Decision: protocol.DecisionReplace, Replacement: json.RawMessage(`{"text":"headless rewritten"}`)}, nil
225 },
226 }
227 d := newExtensionTestDispatcher(client, []extension.InterceptorPoint{extension.PointInputReceive}, nil)
228 runner := &fakeTurnRunner{}
229 c := New(Options{Runner: runner, Extensions: d})
230
231 if err := c.Run(context.Background(), "original"); err != nil {
232 t.Fatal(err)
233 }
234 if len(runner.inputs) != 1 || runner.inputs[0] != "headless rewritten" {
235 t.Fatalf("runner inputs = %v, want the replaced headless input", runner.inputs)
236 }
237 }
238
239 func TestSetExtensionsInstallsDispatcher(t *testing.T) {
240 client := &fakeExtClient{}
241 d := newExtensionTestDispatcher(client, []extension.InterceptorPoint{extension.PointInputReceive}, nil)
242 runner := &fakeTurnRunner{}
243 c := New(Options{Runner: runner})
244
245 c.SetExtensions(nil) // no-op
246 if _, wrapped := c.sink.(*frontendEventSink); wrapped {
247 t.Fatal("SetExtensions(nil) wrapped the sink")
248 }
249 c.SetExtensions(d)
250 if _, wrapped := c.sink.(*frontendEventSink); !wrapped {
251 t.Fatal("SetExtensions did not wrap the sink")
252 }
253 // The first install wins; a later dispatcher is ignored.
254 c.SetExtensions(newExtensionTestDispatcher(&fakeExtClient{}, nil, nil))
255 if c.extensions != d {
256 t.Fatal("SetExtensions swapped an installed dispatcher")
257 }
258 if err := runTestTurn(c, "hello"); err != nil {
259 t.Fatal(err)
260 }
261 if len(client.intercepts) != 1 {
262 t.Fatalf("intercepts = %d, want the installed dispatcher to fire once", len(client.intercepts))
263 }
264 }
265
266 // newSessionController builds a controller with a real executor session and
267 // session file so lifecycle points have something to save/load/rotate.
268 func newSessionController(t *testing.T, d *dispatch.Dispatcher, sink event.Sink) (*Controller, string) {
269 t.Helper()
270 dir := t.TempDir()
271 sess := agent.NewSession("sys")
272 sess.Add(provider.Message{Role: provider.RoleUser, Content: "hi"})
273 exec := agent.New(nil, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
274 path := filepath.Join(dir, "s.jsonl")
275 opts := Options{Runner: &fakeTurnRunner{}, Executor: exec, SessionDir: dir, SessionPath: path, Extensions: d}
276 if sink != nil {
277 opts.Sink = sink
278 }
279 return New(opts), path
280 }
281
282 func TestSessionEventsFireAtLifecyclePoints(t *testing.T) {
283 client := &fakeExtClient{}
284 d := newExtensionTestDispatcher(client, sessionPoints, nil)
285 c, path := newSessionController(t, d, nil)
286
287 if err := runTestTurn(c, "hello"); err != nil {
288 t.Fatal(err)
289 }
290 if err := c.Snapshot(); err != nil {
291 t.Fatalf("Snapshot: %v", err)
292 }
293 loaded := agent.NewSession("sys2")
294 c.Resume(loaded, filepath.Join(filepath.Dir(path), "other.jsonl"))
295 if err := c.NewSession(); err != nil {
296 t.Fatalf("NewSession: %v", err)
297 }
298 c.Close()
299
300 want := []protocol.InterceptEvent{
301 protocol.EventSessionStart, // first turn
302 protocol.EventSessionSave, // Snapshot
303 protocol.EventSessionLoad, // Resume
304 protocol.EventSessionRotate, // NewSession
305 protocol.EventSessionEnd, // NewSession retiring the old session
306 protocol.EventSessionStart, // NewSession's fresh session
307 protocol.EventSessionEnd, // Close
308 }
309 got := client.notifyEvents()
310 if len(got) != len(want) {
311 t.Fatalf("session notify events = %v, want %v", got, want)
312 }
313 for i := range want {
314 if got[i] != want[i] {
315 t.Fatalf("session notify events = %v, want %v", got, want)
316 }
317 }
318 // The save event carries the phase payload: the session file and phase.
319 // Compare typed fields — a Windows path contains backslashes, which JSON
320 // escapes, so a raw-substring match on the payload would miss it.
321 payloads := client.notifyPayloadsFor(protocol.EventSessionSave)
322 if len(payloads) != 1 {
323 t.Fatalf("session.save payloads = %v, want exactly one", payloads)
324 }
325 var savePayload dispatch.SessionPayload
326 if err := json.Unmarshal(payloads[0], &savePayload); err != nil {
327 t.Fatalf("session.save payload does not decode: %v (%s)", err, payloads[0])
328 }
329 if savePayload.Phase != "save" || savePayload.SessionPath != path {
330 t.Fatalf("session.save payload = %+v, want phase=save path=%q", savePayload, path)
331 }
332 }
333
334 func TestSessionSaveStrategyVeto(t *testing.T) {
335 client := &fakeExtClient{
336 interceptFn: func(event protocol.InterceptEvent, _ json.RawMessage) (protocol.InterceptResult, error) {
337 if event == protocol.EventSessionSave {
338 return protocol.InterceptResult{Decision: protocol.DecisionBlock, Reason: "no saves today"}, nil
339 }
340 return protocol.InterceptResult{Decision: protocol.DecisionContinue}, nil
341 },
342 }
343 d := newExtensionTestDispatcher(client, sessionPoints, map[extension.Slot]string{extension.SlotSessionPolicy: extensionTestPlugin})
344 c, path := newSessionController(t, d, nil)
345
346 err := c.Snapshot()
347 if err == nil {
348 t.Fatal("Snapshot succeeded with a blocking session_policy owner")
349 }
350 var blockErr *dispatch.BlockError
351 if !errors.As(err, &blockErr) {
352 t.Fatalf("Snapshot error = %v, want a dispatch.BlockError", err)
353 }
354 if _, statErr := os.Stat(path); !os.IsNotExist(statErr) {
355 t.Fatalf("vetoed save still wrote %s", path)
356 }
357 if n := len(client.notifyPayloadsFor(protocol.EventSessionSave)); n != 0 {
358 t.Fatalf("vetoed save broadcast %d events, want none", n)
359 }
360 }
361
362 func TestSessionStrategyAdjustsObservedPayload(t *testing.T) {
363 client := &fakeExtClient{
364 interceptFn: func(event protocol.InterceptEvent, _ json.RawMessage) (protocol.InterceptResult, error) {
365 if event == protocol.EventSessionSave {
366 return protocol.InterceptResult{Decision: protocol.DecisionReplace,
367 Replacement: json.RawMessage(`{"sessionPath":"/adjusted.jsonl","phase":"save"}`)}, nil
368 }
369 return protocol.InterceptResult{Decision: protocol.DecisionContinue}, nil
370 },
371 }
372 d := newExtensionTestDispatcher(client, sessionPoints, map[extension.Slot]string{extension.SlotSessionPolicy: extensionTestPlugin})
373 c, path := newSessionController(t, d, nil)
374
375 if err := c.Snapshot(); err != nil {
376 t.Fatalf("Snapshot: %v", err)
377 }
378 // Host-side decision unchanged: the transcript lands on the original path.
379 if _, statErr := os.Stat(path); statErr != nil {
380 t.Fatalf("save did not write the original path: %v", statErr)
381 }
382 // Observers receive the owner-adjusted payload.
383 payloads := client.notifyPayloadsFor(protocol.EventSessionSave)
384 if len(payloads) != 1 || !strings.Contains(string(payloads[0]), "/adjusted.jsonl") {
385 t.Fatalf("session.save observed payload = %v, want the adjusted path", payloads)
386 }
387 }
388
389 func TestFrontendEventObserved(t *testing.T) {
390 client := &fakeExtClient{}
391 d := newExtensionTestDispatcher(client, []extension.InterceptorPoint{extension.PointFrontendEvent}, nil)
392 c := New(Options{Runner: &fakeTurnRunner{}, Extensions: d})
393
394 c.notice("hello frontend")
395 payloads := client.notifyPayloadsFor(protocol.EventFrontendEvent)
396 if len(payloads) != 1 {
397 t.Fatalf("frontend.event observations = %d, want 1", len(payloads))
398 }
399 var payload struct {
400 Kind string `json:"kind"`
401 Text string `json:"text"`
402 }
403 if err := json.Unmarshal(payloads[0], &payload); err != nil {
404 t.Fatalf("payload decode: %v", err)
405 }
406 if payload.Kind != "notice" || payload.Text != "hello frontend" {
407 t.Fatalf("observed payload = %+v, want notice/hello frontend", payload)
408 }
409 }
410
411 func TestFrontendEventStrategyRewrite(t *testing.T) {
412 client := &fakeExtClient{
413 interceptFn: func(protocol.InterceptEvent, json.RawMessage) (protocol.InterceptResult, error) {
414 return protocol.InterceptResult{Decision: protocol.DecisionReplace,
415 Replacement: json.RawMessage(`{"kind":"notice","text":"rewritten","detail":"adjusted detail"}`)}, nil
416 },
417 }
418 d := newExtensionTestDispatcher(client,
419 []extension.InterceptorPoint{extension.PointFrontendEvent},
420 map[extension.Slot]string{extension.SlotFrontendEvents: extensionTestPlugin})
421 sink := &recordingSink{}
422 c := New(Options{Runner: &fakeTurnRunner{}, Sink: sink, Extensions: d})
423
424 c.noticeDetail("original", "original detail")
425 events := sink.all()
426 if len(events) != 1 {
427 t.Fatalf("inner sink events = %d, want 1", len(events))
428 }
429 if events[0].Kind != event.Notice || events[0].Text != "rewritten" || events[0].Detail != "adjusted detail" {
430 t.Fatalf("emitted event = %+v, want rewritten text/detail with the kind intact", events[0])
431 }
432 // Observers see exactly what the frontend received.
433 payloads := client.notifyPayloadsFor(protocol.EventFrontendEvent)
434 if len(payloads) != 1 || !strings.Contains(string(payloads[0]), "rewritten") {
435 t.Fatalf("observed payloads = %v, want the rewritten event", payloads)
436 }
437 }
438
439 func TestFrontendEventStrategyKindChangeRejected(t *testing.T) {
440 client := &fakeExtClient{
441 interceptFn: func(protocol.InterceptEvent, json.RawMessage) (protocol.InterceptResult, error) {
442 return protocol.InterceptResult{Decision: protocol.DecisionReplace,
443 Replacement: json.RawMessage(`{"kind":"text","text":"hijacked"}`)}, nil
444 },
445 }
446 d := newExtensionTestDispatcher(client,
447 []extension.InterceptorPoint{extension.PointFrontendEvent},
448 map[extension.Slot]string{extension.SlotFrontendEvents: extensionTestPlugin})
449 sink := &recordingSink{}
450 c := New(Options{Runner: &fakeTurnRunner{}, Sink: sink, Extensions: d})
451
452 c.notice("original")
453 events := sink.all()
454 if len(events) != 1 || events[0].Text != "original" || events[0].Kind != event.Notice {
455 t.Fatalf("emitted events = %+v, want the original event when the owner tries to change the kind", events)
456 }
457 }
458
459 func TestFrontendEventStrategyBlockSuppresses(t *testing.T) {
460 client := &fakeExtClient{
461 interceptFn: func(protocol.InterceptEvent, json.RawMessage) (protocol.InterceptResult, error) {
462 return protocol.InterceptResult{Decision: protocol.DecisionBlock, Reason: "suppress"}, nil
463 },
464 }
465 d := newExtensionTestDispatcher(client,
466 []extension.InterceptorPoint{extension.PointFrontendEvent},
467 map[extension.Slot]string{extension.SlotFrontendEvents: extensionTestPlugin})
468 sink := &recordingSink{}
469 c := New(Options{Runner: &fakeTurnRunner{}, Sink: sink, Extensions: d})
470
471 c.notice("suppressed")
472 if events := sink.all(); len(events) != 0 {
473 t.Fatalf("blocked event reached the frontend: %+v", events)
474 }
475 }
476
477 // Stage 6b2: the dispatcher installed on the controller must reach the
478 // executor agent, and a strategy-replaced system prompt must land in the
479 // executor's live session (and survive session rotations).
480
481 func TestSetExtensionsPropagatesToExecutor(t *testing.T) {
482 client := &fakeExtClient{}
483 d := newExtensionTestDispatcher(client, []extension.InterceptorPoint{extension.PointAgentBeforeStart}, nil)
484 mp := testutil.NewMock("p", testutil.Turn{Text: "hi"})
485 exec := agent.New(mp, tool.NewRegistry(), agent.NewSession("sys"), agent.Options{}, event.Discard)
486 c := New(Options{Runner: &fakeTurnRunner{}, Executor: exec})
487
488 c.SetExtensions(d)
489 if err := c.Executor().Run(context.Background(), "hello"); err != nil {
490 t.Fatalf("Run: %v", err)
491 }
492 found := false
493 for _, call := range client.intercepts {
494 if call.event == protocol.EventAgentBeforeStart {
495 found = true
496 }
497 }
498 if !found {
499 t.Fatal("executor run did not consult the dispatcher installed by SetExtensions")
500 }
501 if mp.CallCount() != 1 {
502 t.Fatalf("provider calls = %d, want 1", mp.CallCount())
503 }
504 }
505
506 func TestApplyExtensionSystemPrompt(t *testing.T) {
507 dir := t.TempDir()
508 exec := agent.New(nil, tool.NewRegistry(), agent.NewSession("HOST PROMPT"), agent.Options{}, event.Discard)
509 c := New(Options{
510 Runner: &fakeTurnRunner{},
511 Executor: exec,
512 SessionDir: dir,
513 SessionPath: filepath.Join(dir, "s.jsonl"),
514 SystemPrompt: "HOST PROMPT",
515 })
516
517 c.ApplyExtensionSystemPrompt("EXTENSION PROMPT")
518 if got := controlSystemMessage(c.History()); got != "EXTENSION PROMPT" {
519 t.Fatalf("system message = %q, want the extension prompt", got)
520 }
521 // A session rotation must keep the strategy prompt, not revert to the
522 // host-composed one.
523 if err := c.NewSession(); err != nil {
524 t.Fatalf("NewSession: %v", err)
525 }
526 if got := controlSystemMessage(c.History()); got != "EXTENSION PROMPT" {
527 t.Fatalf("system message after rotation = %q, want the extension prompt", got)
528 }
529 }
530
531 func controlSystemMessage(msgs []provider.Message) string {
532 for _, m := range msgs {
533 if m.Role == provider.RoleSystem {
534 return m.Content
535 }
536 }
537 return ""
538 }
539
539 lines GO