返回 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 := newOwnedTestController(t, 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 := newOwnedTestController(t, 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 := newOwnedTestController(t, 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 := newOwnedTestController(t, Options{Runner: runner})
207 if sinkHasFrontendWrapper(c.sink) {
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 := newOwnedTestController(t, 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 := newOwnedTestController(t, 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 // Durable inbox observation sits outside the frontend wrapper.
251 if !sinkHasFrontendWrapper(c.sink) {
252 t.Fatal("SetExtensions did not wrap the sink")
253 }
254 // The first install wins; a later SetExtensions is ignored.
255 c.SetExtensions(newExtensionTestDispatcher(&fakeExtClient{}, nil, nil))
256 if c.extensions != d {
257 t.Fatal("SetExtensions swapped an installed dispatcher")
258 }
259 // ReplaceExtensions is the generation-safe rebuild path.
260 client2 := &fakeExtClient{}
261 d2 := newExtensionTestDispatcher(client2, []extension.InterceptorPoint{extension.PointInputReceive}, nil)
262 c.ReplaceExtensions(d2)
263 if c.extensions != d2 {
264 t.Fatal("ReplaceExtensions did not swap dispatcher")
265 }
266 if err := runTestTurn(c, "hello"); err != nil {
267 t.Fatal(err)
268 }
269 if len(client.intercepts) != 0 {
270 t.Fatalf("old dispatcher still fired: %d", len(client.intercepts))
271 }
272 if len(client2.intercepts) != 1 {
273 t.Fatalf("intercepts = %d, want the replaced dispatcher to fire once", len(client2.intercepts))
274 }
275 }
276
277 // newSessionController builds a controller with a real executor session and
278 // session file so lifecycle points have something to save/load/rotate.
279 func newSessionController(t *testing.T, d *dispatch.Dispatcher, sink event.Sink) (*Controller, string) {
280 t.Helper()
281 dir := t.TempDir()
282 sess := agent.NewSession("sys")
283 sess.Add(provider.Message{Role: provider.RoleUser, Content: "hi"})
284 exec := agent.New(nil, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
285 path := filepath.Join(dir, "s.jsonl")
286 opts := Options{Runner: &fakeTurnRunner{}, Executor: exec, SessionDir: dir, SessionPath: path, Extensions: d}
287 if sink != nil {
288 opts.Sink = sink
289 }
290 return newOwnedTestController(t, opts), path
291 }
292
293 func TestSessionEventsFireAtLifecyclePoints(t *testing.T) {
294 client := &fakeExtClient{}
295 d := newExtensionTestDispatcher(client, sessionPoints, nil)
296 c, path := newSessionController(t, d, nil)
297
298 if err := runTestTurn(c, "hello"); err != nil {
299 t.Fatal(err)
300 }
301 if err := c.Snapshot(); err != nil {
302 t.Fatalf("Snapshot: %v", err)
303 }
304 loaded := agent.NewSession("sys2")
305 c.Resume(loaded, filepath.Join(filepath.Dir(path), "other.jsonl"))
306 if err := c.NewSession(); err != nil {
307 t.Fatalf("NewSession: %v", err)
308 }
309 c.Close()
310
311 want := []protocol.InterceptEvent{
312 protocol.EventSessionStart, // first turn
313 protocol.EventSessionSave, // Snapshot
314 protocol.EventSessionLoad, // Resume
315 protocol.EventSessionRotate, // NewSession
316 protocol.EventSessionEnd, // NewSession retiring the old session
317 protocol.EventSessionStart, // NewSession's fresh session
318 protocol.EventSessionEnd, // Close
319 }
320 got := client.notifyEvents()
321 if len(got) != len(want) {
322 t.Fatalf("session notify events = %v, want %v", got, want)
323 }
324 for i := range want {
325 if got[i] != want[i] {
326 t.Fatalf("session notify events = %v, want %v", got, want)
327 }
328 }
329 // The save event carries the phase payload: the session file and phase.
330 // Compare typed fields — a Windows path contains backslashes, which JSON
331 // escapes, so a raw-substring match on the payload would miss it.
332 payloads := client.notifyPayloadsFor(protocol.EventSessionSave)
333 if len(payloads) != 1 {
334 t.Fatalf("session.save payloads = %v, want exactly one", payloads)
335 }
336 var savePayload dispatch.SessionPayload
337 if err := json.Unmarshal(payloads[0], &savePayload); err != nil {
338 t.Fatalf("session.save payload does not decode: %v (%s)", err, payloads[0])
339 }
340 if savePayload.Phase != "save" || savePayload.SessionPath != path {
341 t.Fatalf("session.save payload = %+v, want phase=save path=%q", savePayload, path)
342 }
343 }
344
345 func TestSessionSaveStrategyVeto(t *testing.T) {
346 client := &fakeExtClient{
347 interceptFn: func(event protocol.InterceptEvent, _ json.RawMessage) (protocol.InterceptResult, error) {
348 if event == protocol.EventSessionSave {
349 return protocol.InterceptResult{Decision: protocol.DecisionBlock, Reason: "no saves today"}, nil
350 }
351 return protocol.InterceptResult{Decision: protocol.DecisionContinue}, nil
352 },
353 }
354 d := newExtensionTestDispatcher(client, sessionPoints, map[extension.Slot]string{extension.SlotSessionPolicy: extensionTestPlugin})
355 c, path := newSessionController(t, d, nil)
356
357 err := c.Snapshot()
358 if err == nil {
359 t.Fatal("Snapshot succeeded with a blocking session_policy owner")
360 }
361 var blockErr *dispatch.BlockError
362 if !errors.As(err, &blockErr) {
363 t.Fatalf("Snapshot error = %v, want a dispatch.BlockError", err)
364 }
365 if _, statErr := os.Stat(path); !os.IsNotExist(statErr) {
366 t.Fatalf("vetoed save still wrote %s", path)
367 }
368 if n := len(client.notifyPayloadsFor(protocol.EventSessionSave)); n != 0 {
369 t.Fatalf("vetoed save broadcast %d events, want none", n)
370 }
371 }
372
373 func TestSessionStrategyAdjustsObservedPayload(t *testing.T) {
374 client := &fakeExtClient{
375 interceptFn: func(event protocol.InterceptEvent, _ json.RawMessage) (protocol.InterceptResult, error) {
376 if event == protocol.EventSessionSave {
377 return protocol.InterceptResult{Decision: protocol.DecisionReplace,
378 Replacement: json.RawMessage(`{"sessionPath":"/adjusted.jsonl","phase":"save"}`)}, nil
379 }
380 return protocol.InterceptResult{Decision: protocol.DecisionContinue}, nil
381 },
382 }
383 d := newExtensionTestDispatcher(client, sessionPoints, map[extension.Slot]string{extension.SlotSessionPolicy: extensionTestPlugin})
384 c, path := newSessionController(t, d, nil)
385
386 if err := c.Snapshot(); err != nil {
387 t.Fatalf("Snapshot: %v", err)
388 }
389 // Host-side decision unchanged: the transcript lands on the original path.
390 if _, statErr := os.Stat(path); statErr != nil {
391 t.Fatalf("save did not write the original path: %v", statErr)
392 }
393 // Observers receive the owner-adjusted payload.
394 payloads := client.notifyPayloadsFor(protocol.EventSessionSave)
395 if len(payloads) != 1 || !strings.Contains(string(payloads[0]), "/adjusted.jsonl") {
396 t.Fatalf("session.save observed payload = %v, want the adjusted path", payloads)
397 }
398 }
399
400 func TestFrontendEventObserved(t *testing.T) {
401 client := &fakeExtClient{}
402 d := newExtensionTestDispatcher(client, []extension.InterceptorPoint{extension.PointFrontendEvent}, nil)
403 c := newOwnedTestController(t, Options{Runner: &fakeTurnRunner{}, Extensions: d})
404
405 c.notice("hello frontend")
406 payloads := client.notifyPayloadsFor(protocol.EventFrontendEvent)
407 if len(payloads) != 1 {
408 t.Fatalf("frontend.event observations = %d, want 1", len(payloads))
409 }
410 var payload struct {
411 Kind string `json:"kind"`
412 Text string `json:"text"`
413 }
414 if err := json.Unmarshal(payloads[0], &payload); err != nil {
415 t.Fatalf("payload decode: %v", err)
416 }
417 if payload.Kind != "notice" || payload.Text != "hello frontend" {
418 t.Fatalf("observed payload = %+v, want notice/hello frontend", payload)
419 }
420 }
421
422 func TestFrontendEventStrategyRewrite(t *testing.T) {
423 client := &fakeExtClient{
424 interceptFn: func(protocol.InterceptEvent, json.RawMessage) (protocol.InterceptResult, error) {
425 return protocol.InterceptResult{Decision: protocol.DecisionReplace,
426 Replacement: json.RawMessage(`{"kind":"notice","text":"rewritten","detail":"adjusted detail"}`)}, nil
427 },
428 }
429 d := newExtensionTestDispatcher(client,
430 []extension.InterceptorPoint{extension.PointFrontendEvent},
431 map[extension.Slot]string{extension.SlotFrontendEvents: extensionTestPlugin})
432 sink := &recordingSink{}
433 c := newOwnedTestController(t, Options{Runner: &fakeTurnRunner{}, Sink: sink, Extensions: d})
434
435 c.noticeDetail("original", "original detail")
436 events := sink.all()
437 if len(events) != 1 {
438 t.Fatalf("inner sink events = %d, want 1", len(events))
439 }
440 if events[0].Kind != event.Notice || events[0].Text != "rewritten" || events[0].Detail != "adjusted detail" {
441 t.Fatalf("emitted event = %+v, want rewritten text/detail with the kind intact", events[0])
442 }
443 // Observers see exactly what the frontend received.
444 payloads := client.notifyPayloadsFor(protocol.EventFrontendEvent)
445 if len(payloads) != 1 || !strings.Contains(string(payloads[0]), "rewritten") {
446 t.Fatalf("observed payloads = %v, want the rewritten event", payloads)
447 }
448 }
449
450 func TestFrontendEventStrategyKindChangeRejected(t *testing.T) {
451 client := &fakeExtClient{
452 interceptFn: func(protocol.InterceptEvent, json.RawMessage) (protocol.InterceptResult, error) {
453 return protocol.InterceptResult{Decision: protocol.DecisionReplace,
454 Replacement: json.RawMessage(`{"kind":"text","text":"hijacked"}`)}, nil
455 },
456 }
457 d := newExtensionTestDispatcher(client,
458 []extension.InterceptorPoint{extension.PointFrontendEvent},
459 map[extension.Slot]string{extension.SlotFrontendEvents: extensionTestPlugin})
460 sink := &recordingSink{}
461 c := newOwnedTestController(t, Options{Runner: &fakeTurnRunner{}, Sink: sink, Extensions: d})
462
463 c.notice("original")
464 events := sink.all()
465 if len(events) != 1 || events[0].Text != "original" || events[0].Kind != event.Notice {
466 t.Fatalf("emitted events = %+v, want the original event when the owner tries to change the kind", events)
467 }
468 }
469
470 func TestFrontendEventStrategyBlockSuppresses(t *testing.T) {
471 client := &fakeExtClient{
472 interceptFn: func(protocol.InterceptEvent, json.RawMessage) (protocol.InterceptResult, error) {
473 return protocol.InterceptResult{Decision: protocol.DecisionBlock, Reason: "suppress"}, nil
474 },
475 }
476 d := newExtensionTestDispatcher(client,
477 []extension.InterceptorPoint{extension.PointFrontendEvent},
478 map[extension.Slot]string{extension.SlotFrontendEvents: extensionTestPlugin})
479 sink := &recordingSink{}
480 c := newOwnedTestController(t, Options{Runner: &fakeTurnRunner{}, Sink: sink, Extensions: d})
481
482 c.notice("suppressed")
483 if events := sink.all(); len(events) != 0 {
484 t.Fatalf("blocked event reached the frontend: %+v", events)
485 }
486 }
487
488 // Stage 6b2: the dispatcher installed on the controller must reach the
489 // executor agent, and a strategy-replaced system prompt must land in the
490 // executor's live session (and survive session rotations).
491
492 func TestSetExtensionsPropagatesToExecutor(t *testing.T) {
493 client := &fakeExtClient{}
494 d := newExtensionTestDispatcher(client, []extension.InterceptorPoint{extension.PointAgentBeforeStart}, nil)
495 mp := testutil.NewMock("p", testutil.Turn{Text: "hi"})
496 exec := agent.New(mp, tool.NewRegistry(), agent.NewSession("sys"), agent.Options{}, event.Discard)
497 c := newOwnedTestController(t, Options{Runner: &fakeTurnRunner{}, Executor: exec})
498
499 c.SetExtensions(d)
500 if err := c.Executor().Run(context.Background(), "hello"); err != nil {
501 t.Fatalf("Run: %v", err)
502 }
503 found := false
504 for _, call := range client.intercepts {
505 if call.event == protocol.EventAgentBeforeStart {
506 found = true
507 }
508 }
509 if !found {
510 t.Fatal("executor run did not consult the dispatcher installed by SetExtensions")
511 }
512 if mp.CallCount() != 1 {
513 t.Fatalf("provider calls = %d, want 1", mp.CallCount())
514 }
515 }
516
517 func TestApplyExtensionSystemPrompt(t *testing.T) {
518 dir := t.TempDir()
519 exec := agent.New(nil, tool.NewRegistry(), agent.NewSession("HOST PROMPT"), agent.Options{}, event.Discard)
520 c := newOwnedTestController(t, Options{
521 Runner: &fakeTurnRunner{},
522 Executor: exec,
523 SessionDir: dir,
524 SessionPath: filepath.Join(dir, "s.jsonl"),
525 SystemPrompt: "HOST PROMPT",
526 })
527
528 c.ApplyExtensionSystemPrompt("EXTENSION PROMPT")
529 if got := controlSystemMessage(c.History()); got != "EXTENSION PROMPT" {
530 t.Fatalf("system message = %q, want the extension prompt", got)
531 }
532 // A session rotation must keep the strategy prompt, not revert to the
533 // host-composed one.
534 if err := c.NewSession(); err != nil {
535 t.Fatalf("NewSession: %v", err)
536 }
537 if got := controlSystemMessage(c.History()); got != "EXTENSION PROMPT" {
538 t.Fatalf("system message after rotation = %q, want the extension prompt", got)
539 }
540 }
541
542 func controlSystemMessage(msgs []provider.Message) string {
543 for _, m := range msgs {
544 if m.Role == provider.RoleSystem {
545 return m.Content
546 }
547 }
548 return ""
549 }
550
551 func sinkHasFrontendWrapper(s event.Sink) bool {
552 switch t := s.(type) {
553 case *frontendEventSink:
554 return true
555 case *inboxEventSink:
556 if _, ok := t.inner.(*frontendEventSink); ok {
557 return true
558 }
559 if lifecycle, ok := t.inner.(*turnEventSink); ok {
560 _, wrapped := lifecycle.inner.(*frontendEventSink)
561 return wrapped
562 }
563 return false
564 default:
565 return false
566 }
567 }
568
568 lines GO