返回 DeepSeek-Reasonix
gateway_test.go
根目录 / internal / bot / gateway_test.go
1 package bot
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "io"
9 "log/slog"
10 "net/http"
11 "net/http/httptest"
12 "os"
13 "path/filepath"
14 "strings"
15 "sync"
16 "testing"
17 "time"
18
19 "reasonix/internal/agent"
20 "reasonix/internal/control"
21 "reasonix/internal/event"
22 "reasonix/internal/provider"
23 "reasonix/internal/tool"
24 )
25
26 // fakeAdapter 是一个内存中的假适配器,用于测试 BotGateway。
27 type fakeAdapter struct {
28 mu sync.Mutex
29 stopOnce sync.Once
30 platform Platform
31 name string
32 msgCh chan InboundMessage
33 sent []OutboundMessage
34 started bool
35 startErr error
36 }
37
38 type resultAdapter struct {
39 *fakeAdapter
40 result SendResult
41 err error
42 }
43
44 func (a *resultAdapter) Send(_ context.Context, msg OutboundMessage) (SendResult, error) {
45 a.mu.Lock()
46 a.sent = append(a.sent, msg)
47 a.mu.Unlock()
48 return a.result, a.err
49 }
50
51 func newFakeAdapter(platform Platform, name string) *fakeAdapter {
52 return &fakeAdapter{
53 platform: platform,
54 name: name,
55 msgCh: make(chan InboundMessage, 16),
56 }
57 }
58
59 func (f *fakeAdapter) Platform() Platform { return f.platform }
60 func (f *fakeAdapter) Name() string { return f.name }
61 func (f *fakeAdapter) Messages() <-chan InboundMessage { return f.msgCh }
62
63 func (f *fakeAdapter) Start(ctx context.Context) error {
64 if f.startErr != nil {
65 return f.startErr
66 }
67 f.mu.Lock()
68 f.started = true
69 f.mu.Unlock()
70 return nil
71 }
72
73 func (f *fakeAdapter) Stop() error {
74 f.stopOnce.Do(func() {
75 close(f.msgCh)
76 })
77 return nil
78 }
79
80 func (f *fakeAdapter) Send(ctx context.Context, msg OutboundMessage) (SendResult, error) {
81 f.mu.Lock()
82 f.sent = append(f.sent, msg)
83 f.mu.Unlock()
84 return SendResult{MessageID: "fake_msg_1"}, nil
85 }
86
87 func (f *fakeAdapter) SendTyping(ctx context.Context, chatID string) error { return nil }
88
89 func (f *fakeAdapter) sentMessages() []OutboundMessage {
90 f.mu.Lock()
91 defer f.mu.Unlock()
92 out := make([]OutboundMessage, len(f.sent))
93 copy(out, f.sent)
94 return out
95 }
96
97 type blockingSendAdapter struct {
98 *fakeAdapter
99 entered chan struct{}
100 release chan struct{}
101 once sync.Once
102 }
103
104 func newBlockingSendAdapter(platform Platform, name string) *blockingSendAdapter {
105 return &blockingSendAdapter{
106 fakeAdapter: newFakeAdapter(platform, name),
107 entered: make(chan struct{}),
108 release: make(chan struct{}),
109 }
110 }
111
112 func (f *blockingSendAdapter) Send(ctx context.Context, msg OutboundMessage) (SendResult, error) {
113 f.once.Do(func() { close(f.entered) })
114 select {
115 case <-f.release:
116 case <-ctx.Done():
117 return SendResult{}, ctx.Err()
118 }
119 return f.fakeAdapter.Send(ctx, msg)
120 }
121
122 type fakeReactionAdapter struct {
123 *fakeAdapter
124 reactions []string
125 cleanups []string
126 }
127
128 type gatewayFakeProvider struct{}
129
130 func (gatewayFakeProvider) Name() string { return "fake" }
131
132 func (gatewayFakeProvider) Stream(context.Context, provider.Request) (<-chan provider.Chunk, error) {
133 ch := make(chan provider.Chunk)
134 close(ch)
135 return ch, nil
136 }
137
138 func (f *fakeReactionAdapter) AddPendingReaction(ctx context.Context, messageID string) (func(), error) {
139 f.mu.Lock()
140 f.reactions = append(f.reactions, messageID)
141 f.mu.Unlock()
142 return func() {
143 f.mu.Lock()
144 defer f.mu.Unlock()
145 f.cleanups = append(f.cleanups, messageID)
146 }, nil
147 }
148
149 func (f *fakeReactionAdapter) cleanupMessages() []string {
150 f.mu.Lock()
151 defer f.mu.Unlock()
152 out := make([]string, len(f.cleanups))
153 copy(out, f.cleanups)
154 return out
155 }
156
157 type queueTestController struct {
158 stubBotController
159 mu sync.Mutex
160 steers []string
161 rejectSteer bool
162 canceled bool
163 }
164
165 func (c *queueTestController) Steer(text string) {
166 _ = c.TrySteer(text)
167 }
168
169 func (c *queueTestController) TrySteer(text string) bool {
170 c.mu.Lock()
171 defer c.mu.Unlock()
172 if c.rejectSteer {
173 return false
174 }
175 c.steers = append(c.steers, text)
176 return true
177 }
178
179 func (c *queueTestController) Cancel() {
180 c.mu.Lock()
181 defer c.mu.Unlock()
182 c.canceled = true
183 }
184
185 func (c *queueTestController) SessionPath() string { return "" }
186 func (c *queueTestController) WorkspaceRoot() string { return "" }
187
188 func (c *queueTestController) steered() []string {
189 c.mu.Lock()
190 defer c.mu.Unlock()
191 out := make([]string, len(c.steers))
192 copy(out, c.steers)
193 return out
194 }
195
196 func (c *queueTestController) wasCanceled() bool {
197 c.mu.Lock()
198 defer c.mu.Unlock()
199 return c.canceled
200 }
201
202 type rotatingBotController struct {
203 stubBotController
204 path string
205 newPath string
206 newCalls int
207 closed bool
208 }
209
210 func (c *rotatingBotController) Running() bool { return false }
211 func (c *rotatingBotController) NewSession() error {
212 c.newCalls++
213 c.path = c.newPath
214 return nil
215 }
216 func (c *rotatingBotController) SessionPath() string { return c.path }
217 func (c *rotatingBotController) Close() { c.closed = true }
218
219 type runtimeStatusBotController struct {
220 stubBotController
221 status control.RuntimeStatus
222 workspaceRoot string
223 sessionPath string
224 closed bool
225 }
226
227 func (c *runtimeStatusBotController) RuntimeStatus() control.RuntimeStatus { return c.status }
228 func (c *runtimeStatusBotController) WorkspaceRoot() string { return c.workspaceRoot }
229 func (c *runtimeStatusBotController) SessionPath() string { return c.sessionPath }
230 func (c *runtimeStatusBotController) Close() { c.closed = true }
231
232 type blockingApprovalController struct {
233 stubBotController
234 emit func(event.Event)
235 emitted chan struct{}
236 approved chan struct{}
237 done chan struct{}
238 once sync.Once
239 }
240
241 func (c *blockingApprovalController) RunTurn(ctx context.Context, input string) error {
242 c.emit(event.Event{Kind: event.ApprovalRequest, Approval: event.Approval{ID: "appr-1", Tool: "bash", Subject: "sample command"}})
243 close(c.emitted)
244 select {
245 case <-c.approved:
246 close(c.done)
247 return nil
248 case <-ctx.Done():
249 return ctx.Err()
250 }
251 }
252
253 func (c *blockingApprovalController) Approve(id string, allow, session, persist bool) {
254 c.once.Do(func() { close(c.approved) })
255 }
256
257 type blockingAskController struct {
258 stubBotController
259 emit func(event.Event)
260 emitted chan struct{}
261 answered chan []event.AskAnswer
262 done chan struct{}
263 once sync.Once
264 }
265
266 func (c *blockingAskController) RunTurn(ctx context.Context, input string) error {
267 c.emit(event.Event{Kind: event.AskRequest, Ask: event.Ask{ID: "ask-1", Questions: []event.AskQuestion{{
268 ID: "q1",
269 Header: "Planner",
270 Prompt: "Which plan?",
271 Options: []event.AskOption{
272 {Label: "Small patch"},
273 {Label: "Refactor"},
274 },
275 }}}})
276 close(c.emitted)
277 select {
278 case <-c.answered:
279 close(c.done)
280 return nil
281 case <-ctx.Done():
282 return ctx.Err()
283 }
284 }
285
286 func (c *blockingAskController) AnswerQuestion(id string, answers []event.AskAnswer) {
287 c.once.Do(func() { c.answered <- answers })
288 }
289
290 func TestFakeAdapterInterface(t *testing.T) {
291 fa := newFakeAdapter(PlatformQQ, "fake-qq")
292
293 if fa.Platform() != PlatformQQ {
294 t.Error("wrong platform")
295 }
296 if fa.Name() != "fake-qq" {
297 t.Error("wrong name")
298 }
299
300 ctx := context.Background()
301 if err := fa.Start(ctx); err != nil {
302 t.Fatal("start:", err)
303 }
304 if !fa.started {
305 t.Error("should be started")
306 }
307
308 _, err := fa.Send(ctx, OutboundMessage{ChatID: "c1", Text: "hello"})
309 if err != nil {
310 t.Fatal("send:", err)
311 }
312
313 sent := fa.sentMessages()
314 if len(sent) != 1 {
315 t.Fatalf("sent count = %d, want 1", len(sent))
316 }
317 if sent[0].Text != "hello" {
318 t.Errorf("sent text = %q, want %q", sent[0].Text, "hello")
319 }
320
321 if err := fa.Stop(); err != nil {
322 t.Fatal("stop:", err)
323 }
324 }
325
326 func TestGatewayConstructAndStop(t *testing.T) {
327 cfg := GatewayConfig{
328 Model: "test",
329 MaxSteps: 10,
330 WorkspaceRoot: ".",
331 Enabled: map[Platform]bool{PlatformQQ: true},
332 Allowlist: AllowlistConfig{Enabled: false},
333 }
334
335 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
336 gw := NewGateway(cfg, map[Platform]Adapter{
337 PlatformQQ: newFakeAdapter(PlatformQQ, "fake-qq"),
338 }, logger)
339
340 // 网关不应该 panic
341 if gw == nil {
342 t.Fatal("gateway should not be nil")
343 }
344 gw.Stop()
345 }
346
347 func TestGatewayStartsHealthyAdaptersWhenOneFails(t *testing.T) {
348 cfg := GatewayConfig{
349 Enabled: map[Platform]bool{PlatformFeishu: true, PlatformWeixin: true},
350 Allowlist: AllowlistConfig{AllowAll: true},
351 }
352 good := newFakeAdapter(PlatformFeishu, "good-feishu")
353 bad := newFakeAdapter(PlatformWeixin, "bad-weixin")
354 bad.startErr = errors.New("missing token")
355 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
356 gw := NewGatewayWithAdapterBindings(cfg, []AdapterBinding{
357 {ID: "feishu-lark", Platform: PlatformFeishu, Adapter: good},
358 {ID: "weixin-weixin", Platform: PlatformWeixin, Adapter: bad},
359 }, logger)
360
361 if err := gw.Start(context.Background()); err != nil {
362 t.Fatalf("start should keep healthy adapters running: %v", err)
363 }
364 defer gw.Stop()
365 if got := gw.AdapterCount(); got != 1 {
366 t.Fatalf("adapter count = %d, want 1", got)
367 }
368 if !good.started {
369 t.Fatal("healthy adapter was not started")
370 }
371 if bad.started {
372 t.Fatal("failing adapter should not be marked started")
373 }
374 startErr := gw.StartErrors()
375 if len(startErr) != 1 || !strings.Contains(startErr[0].Error(), "weixin-weixin") {
376 t.Fatalf("start errors = %#v, want wrapped connection error", startErr)
377 }
378 }
379
380 func TestGatewaySendToAdapterReleasesLockBeforeSend(t *testing.T) {
381 adapter := newBlockingSendAdapter(PlatformFeishu, "blocking-feishu")
382 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
383 gw := NewGatewayWithAdapterBindings(GatewayConfig{}, []AdapterBinding{{
384 ID: "feishu-lark",
385 Domain: "lark",
386 Platform: PlatformFeishu,
387 Adapter: adapter,
388 }}, logger)
389
390 sendDone := make(chan error, 1)
391 go func() {
392 _, err := gw.SendToAdapter(context.Background(), "feishu-lark", "lark", OutboundMessage{ChatID: "chat", Text: "hello"})
393 sendDone <- err
394 }()
395
396 select {
397 case <-adapter.entered:
398 case <-time.After(500 * time.Millisecond):
399 t.Fatal("adapter send did not start")
400 }
401
402 updateDone := make(chan struct{})
403 go func() {
404 gw.UpdateConnectionToolApprovalMode("feishu-lark", "ask")
405 close(updateDone)
406 }()
407 select {
408 case <-updateDone:
409 case <-time.After(500 * time.Millisecond):
410 t.Fatal("UpdateConnectionToolApprovalMode blocked behind SendToAdapter")
411 }
412
413 close(adapter.release)
414 select {
415 case err := <-sendDone:
416 if err != nil {
417 t.Fatalf("SendToAdapter returned error: %v", err)
418 }
419 case <-time.After(500 * time.Millisecond):
420 t.Fatal("SendToAdapter did not finish after release")
421 }
422 }
423
424 func TestGatewayReturnsErrorWhenAllAdaptersFail(t *testing.T) {
425 cfg := GatewayConfig{
426 Enabled: map[Platform]bool{PlatformWeixin: true},
427 Allowlist: AllowlistConfig{AllowAll: true},
428 }
429 bad := newFakeAdapter(PlatformWeixin, "bad-weixin")
430 bad.startErr = errors.New("missing token")
431 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
432 gw := NewGatewayWithAdapterBindings(cfg, []AdapterBinding{
433 {ID: "weixin-weixin", Platform: PlatformWeixin, Adapter: bad},
434 }, logger)
435
436 err := gw.Start(context.Background())
437 if err == nil {
438 t.Fatal("start should fail when every adapter fails")
439 }
440 if !strings.Contains(err.Error(), "weixin-weixin") {
441 t.Fatalf("error = %v, want connection id", err)
442 }
443 if got := gw.AdapterCount(); got != 0 {
444 t.Fatalf("adapter count = %d, want 0", got)
445 }
446 if len(gw.StartErrors()) != 1 {
447 t.Fatalf("start errors = %#v, want one", gw.StartErrors())
448 }
449 }
450
451 func TestGatewayAllowlistCheck(t *testing.T) {
452 cfg := GatewayConfig{
453 Allowlist: AllowlistConfig{
454 Enabled: true,
455 Users: map[Platform][]string{
456 PlatformQQ: {"allowed_user_1"},
457 },
458 },
459 }
460
461 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
462 gw := NewGateway(cfg, nil, logger)
463
464 if !gw.checkAllowlist(PlatformQQ, InboundMessage{Platform: PlatformQQ, ChatType: ChatDM, UserID: "allowed_user_1"}) {
465 t.Error("allowed user should pass")
466 }
467 if gw.checkAllowlist(PlatformQQ, InboundMessage{Platform: PlatformQQ, ChatType: ChatDM, UserID: "unknown_user"}) {
468 t.Error("unknown user should not pass")
469 }
470 // 不同平台
471 if gw.checkAllowlist(PlatformFeishu, InboundMessage{Platform: PlatformFeishu, ChatType: ChatDM, UserID: "allowed_user_1"}) {
472 t.Error("QQ allowlist should not apply to feishu")
473 }
474 }
475
476 func TestGatewayRejectsBeforeInboundEnrichment(t *testing.T) {
477 adapter := newFakeAdapter(PlatformFeishu, "feishu")
478 gw := NewGateway(GatewayConfig{Allowlist: AllowlistConfig{
479 Enabled: true,
480 Users: map[Platform][]string{PlatformFeishu: {"allowed-user"}},
481 }}, map[Platform]Adapter{PlatformFeishu: adapter}, discardLogger())
482
483 mediaLoads := 0
484 nameLoads := 0
485 gw.handleMessage(context.Background(), AdapterBinding{ID: "feishu", Platform: PlatformFeishu, Adapter: adapter}, InboundMessage{
486 Platform: PlatformFeishu,
487 ChatType: ChatDM,
488 ChatID: "chat",
489 UserID: "blocked-user",
490 Media: []InboundMedia{{Load: func(context.Context) ([]byte, string, error) {
491 mediaLoads++
492 return []byte("payload"), "payload.txt", nil
493 }}},
494 ResolveUserName: func(context.Context) string {
495 nameLoads++
496 return "Blocked User"
497 },
498 })
499
500 if mediaLoads != 0 || nameLoads != 0 {
501 t.Fatalf("pre-admission enrichment calls = media:%d name:%d, want zero", mediaLoads, nameLoads)
502 }
503 }
504
505 func TestGatewayRoleListsGrantAllowlistAdmission(t *testing.T) {
506 cfg := GatewayConfig{
507 Allowlist: AllowlistConfig{
508 Enabled: true,
509 Admins: map[Platform][]string{
510 PlatformFeishu: {"admin_user"},
511 },
512 Approvers: map[Platform][]string{
513 PlatformFeishu: {"approver_user"},
514 },
515 },
516 }
517
518 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
519 gw := NewGateway(cfg, nil, logger)
520
521 if !gw.checkAllowlist(PlatformFeishu, InboundMessage{Platform: PlatformFeishu, ChatType: ChatDM, UserID: "admin_user"}) {
522 t.Error("admin role should grant base bot admission")
523 }
524 if !gw.checkAllowlist(PlatformFeishu, InboundMessage{Platform: PlatformFeishu, ChatType: ChatDM, UserID: "approver_user"}) {
525 t.Error("approver role should grant base bot admission")
526 }
527 if gw.checkAllowlist(PlatformFeishu, InboundMessage{Platform: PlatformFeishu, ChatType: ChatDM, UserID: "unknown_user"}) {
528 t.Error("unknown user should still be rejected")
529 }
530 }
531
532 func TestGatewayApproverRoleDoesNotGrantAdminCommands(t *testing.T) {
533 cfg := GatewayConfig{
534 Allowlist: AllowlistConfig{
535 Enabled: true,
536 Approvers: map[Platform][]string{
537 PlatformFeishu: {"approver_user"},
538 },
539 },
540 }
541
542 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
543 gw := NewGateway(cfg, nil, logger)
544 msg := InboundMessage{Platform: PlatformFeishu, ChatType: ChatDM, UserID: "approver_user"}
545
546 if !gw.checkCommandRole(PlatformFeishu, msg, "approver") {
547 t.Error("approver should be allowed to run approver commands")
548 }
549 if gw.checkCommandRole(PlatformFeishu, msg, "admin") {
550 t.Error("approver should not be allowed to run admin commands")
551 }
552 }
553
554 func TestGatewayAllowlistDoesNotApplyGroupsToDirectMessages(t *testing.T) {
555 cfg := GatewayConfig{
556 Allowlist: AllowlistConfig{
557 Enabled: true,
558 Users: map[Platform][]string{
559 PlatformQQ: {"allowed_user"},
560 },
561 Groups: map[Platform][]string{
562 PlatformQQ: {"allowed_group"},
563 },
564 },
565 }
566
567 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
568 gw := NewGateway(cfg, nil, logger)
569
570 if !gw.checkAllowlist(PlatformQQ, InboundMessage{Platform: PlatformQQ, ChatType: ChatDirect, ChatID: "guild-dm", UserID: "allowed_user"}) {
571 t.Error("direct message should not be rejected by group allowlist")
572 }
573 if gw.checkAllowlist(PlatformQQ, InboundMessage{Platform: PlatformQQ, ChatType: ChatGroup, ChatID: "unknown_group", UserID: "allowed_user"}) {
574 t.Error("unknown group should still be rejected by group allowlist")
575 }
576 }
577
578 func TestGatewayGroupAllowlistStillNarrowsRoleAdmission(t *testing.T) {
579 cfg := GatewayConfig{
580 Allowlist: AllowlistConfig{
581 Enabled: true,
582 Admins: map[Platform][]string{
583 PlatformFeishu: {"admin_user"},
584 },
585 Groups: map[Platform][]string{
586 PlatformFeishu: {"allowed_group"},
587 },
588 },
589 }
590
591 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
592 gw := NewGateway(cfg, nil, logger)
593
594 if !gw.checkAllowlist(PlatformFeishu, InboundMessage{Platform: PlatformFeishu, ChatType: ChatDM, ChatID: "direct", UserID: "admin_user"}) {
595 t.Error("admin role admission should still allow direct messages")
596 }
597 if !gw.checkAllowlist(PlatformFeishu, InboundMessage{Platform: PlatformFeishu, ChatType: ChatGroup, ChatID: "allowed_group", UserID: "admin_user"}) {
598 t.Error("admin role should pass in allowed group")
599 }
600 if gw.checkAllowlist(PlatformFeishu, InboundMessage{Platform: PlatformFeishu, ChatType: ChatGroup, ChatID: "unknown_group", UserID: "admin_user"}) {
601 t.Error("admin role should still be rejected in an unknown group")
602 }
603 }
604
605 func TestGatewayAllowlistGatesOnOperatorNotCardRequester(t *testing.T) {
606 cfg := GatewayConfig{
607 Allowlist: AllowlistConfig{
608 Enabled: true,
609 Users: map[Platform][]string{
610 PlatformFeishu: {"requester"},
611 },
612 },
613 }
614 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
615 gw := NewGateway(cfg, nil, logger)
616
617 stranger := InboundMessage{Platform: PlatformFeishu, ChatType: ChatGroup, ChatID: "chat", UserID: "requester", OperatorID: "stranger"}
618 if gw.checkAllowlist(PlatformFeishu, stranger) {
619 t.Error("a non-allowlisted operator must be rejected even when the card carries an allowlisted requester id")
620 }
621
622 allowed := InboundMessage{Platform: PlatformFeishu, ChatType: ChatGroup, ChatID: "chat", UserID: "requester", OperatorID: "requester"}
623 if !gw.checkAllowlist(PlatformFeishu, allowed) {
624 t.Error("an allowlisted operator should pass")
625 }
626 }
627
628 func TestGatewayAllowlistDisabledRejectsByDefault(t *testing.T) {
629 cfg := GatewayConfig{
630 Allowlist: AllowlistConfig{Enabled: false},
631 }
632 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
633 gw := NewGateway(cfg, nil, logger)
634
635 if gw.checkAllowlist(PlatformQQ, InboundMessage{Platform: PlatformQQ, ChatType: ChatDM, UserID: "any_user"}) {
636 t.Error("disabled allowlist should reject unless allow_all is explicit")
637 }
638 }
639
640 func TestGatewayAllowAll(t *testing.T) {
641 cfg := GatewayConfig{
642 Allowlist: AllowlistConfig{AllowAll: true},
643 }
644 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
645 gw := NewGateway(cfg, nil, logger)
646
647 if !gw.checkAllowlist(PlatformQQ, InboundMessage{Platform: PlatformQQ, ChatType: ChatDM, UserID: "any_user"}) {
648 t.Error("allow_all should allow everyone")
649 }
650 }
651
652 func TestGatewayNormalizesNumericApprovalShortcutsOnlyWhenPending(t *testing.T) {
653 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
654 gw := NewGateway(GatewayConfig{}, nil, logger)
655 key := "session-key"
656
657 if _, ok := gw.normalizeApprovalShortcut(key, "1"); ok {
658 t.Fatal("numeric text without a pending approval should stay a normal message")
659 }
660
661 gw.controllers[key] = &sessionState{
662 pendingApprovals: map[string]event.Approval{
663 "42": {ID: "42", Tool: "explore"},
664 },
665 lastApprovalID: "42",
666 }
667
668 got, ok := gw.normalizeApprovalShortcut(key, "1")
669 if !ok || got != "/approve 42" {
670 t.Fatalf("normalize 1 = %q,%v; want /approve 42,true", got, ok)
671 }
672 got, ok = gw.normalizeApprovalShortcut(key, "2")
673 if !ok || got != "/deny 42" {
674 t.Fatalf("normalize 2 = %q,%v; want /deny 42,true", got, ok)
675 }
676 gw.forgetPendingApproval(key, "42")
677 if _, ok := gw.normalizeApprovalShortcut(key, "1"); ok {
678 t.Fatal("numeric text after approval is forgotten should stay a normal message")
679 }
680 }
681
682 func TestGatewayNormalizesTaskGrantRecoveryShortcuts(t *testing.T) {
683 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
684 gw := NewGateway(GatewayConfig{}, nil, logger)
685 key := "recovery-key"
686 gw.controllers[key] = &sessionState{
687 pendingApprovals: map[string]event.Approval{
688 "r1": {ID: "r1", Kind: "recovery", Recovery: &event.RecoveryApproval{CanGrantTask: true}},
689 },
690 lastApprovalID: "r1",
691 }
692 for input, want := range map[string]string{
693 "1": "/recovery-continue r1",
694 "2": "/recovery-continue-task r1",
695 "3": "/recovery-revise r1",
696 } {
697 got, ok := gw.normalizeApprovalShortcut(key, input)
698 if !ok || got != want {
699 t.Fatalf("normalize %q = %q,%v; want %q,true", input, got, ok, want)
700 }
701 }
702 }
703
704 func TestGatewayNormalizesAskShortcutForPendingAsk(t *testing.T) {
705 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
706 gw := NewGateway(GatewayConfig{}, nil, logger)
707 key := "session-key"
708
709 if _, ok := gw.normalizeAskShortcut(key, "1"); ok {
710 t.Fatal("numeric text without a pending ask should stay a normal message")
711 }
712
713 gw.controllers[key] = &sessionState{
714 pendingAsks: map[string][]event.AskQuestion{
715 "ask-1": {{
716 ID: "q1",
717 Prompt: "Choose one",
718 Options: []event.AskOption{
719 {Label: "Allow once"},
720 {Label: "Deny"},
721 },
722 }},
723 },
724 lastAskID: "ask-1",
725 }
726
727 got, ok := gw.normalizeAskShortcut(key, "2")
728 if !ok || got != "/answer ask-1 2" {
729 t.Fatalf("normalize 2 = %q,%v; want /answer ask-1 2,true", got, ok)
730 }
731 got, ok = gw.normalizeAskShortcut(key, "1;2")
732 if !ok || got != "/answer ask-1 1;2" {
733 t.Fatalf("normalize 1;2 = %q,%v; want /answer ask-1 1;2,true", got, ok)
734 }
735 got, ok = gw.normalizeAskShortcut(key, "freeform answer")
736 if !ok || got != "/answer ask-1 freeform answer" {
737 t.Fatalf("normalize freeform answer = %q,%v; want /answer ask-1 freeform answer,true", got, ok)
738 }
739
740 gw.controllers[key].pendingAsks["ask-2"] = []event.AskQuestion{
741 {ID: "q1", Prompt: "First", Options: []event.AskOption{{Label: "A"}}},
742 {ID: "q2", Prompt: "Second", Options: []event.AskOption{{Label: "B"}}},
743 }
744 gw.controllers[key].lastAskID = "ask-2"
745 got, ok = gw.normalizeAskShortcut(key, "1")
746 if !ok || got != "/answer ask-2 1" {
747 t.Fatalf("normalize 1 on multi-question = %q,%v; want /answer ask-2 1,true", got, ok)
748 }
749 if _, ok := gw.normalizeAskShortcut(key, "/stop"); ok {
750 t.Fatal("slash commands should not be normalized/routed by ask shortcut")
751 }
752 }
753
754 func TestGatewaySessionOptionsUseConnectionToolApprovalOverride(t *testing.T) {
755 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
756 gw := NewGateway(GatewayConfig{
757 Model: "default-model",
758 ToolApprovalMode: "auto",
759 Channels: map[Platform]ChannelConfig{
760 PlatformFeishu: {Model: "platform-model", ToolApprovalMode: "ask"},
761 },
762 ConnectionChannels: map[string]ChannelConfig{
763 "feishu-lark": {Model: "lark-model", ToolApprovalMode: "yolo"},
764 },
765 }, nil, logger)
766
767 model, _, mode := gw.sessionOptionsForMessage(InboundMessage{
768 Platform: PlatformFeishu,
769 ConnectionID: "feishu-lark",
770 })
771 if model != "lark-model" || mode != control.ToolApprovalWorkspaceWrite {
772 t.Fatalf("lark session options = model %q mode %q, want lark-model/workspace-write", model, mode)
773 }
774
775 model, _, mode = gw.sessionOptionsForMessage(InboundMessage{Platform: PlatformFeishu})
776 if model != "platform-model" || mode != control.ToolApprovalReadOnly {
777 t.Fatalf("platform session options = model %q mode %q, want platform-model/read-only", model, mode)
778 }
779 }
780
781 func TestGatewayNumericApprovalShortcutActiveWithoutPendingSendsGuidance(t *testing.T) {
782 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
783 gw := NewGateway(GatewayConfig{Allowlist: AllowlistConfig{AllowAll: true}}, nil, logger)
784 adapter := newFakeAdapter(PlatformWeixin, "fake-weixin")
785 binding := AdapterBinding{ID: "weixin-weixin", Domain: "weixin", Platform: PlatformWeixin, Adapter: adapter}
786 msg := InboundMessage{
787 Platform: PlatformWeixin,
788 ConnectionID: "weixin-weixin",
789 Domain: "weixin",
790 ChatType: ChatDM,
791 ChatID: "chat",
792 UserID: "user",
793 Text: "seed",
794 }
795 key := BuildSessionKey(msg.Session())
796 if acquired, _ := gw.sessions.TryAcquire(key, msg); !acquired {
797 t.Fatal("failed to mark session active")
798 }
799
800 msg.Text = "1"
801 gw.handleMessage(context.Background(), binding, msg)
802
803 sent := adapter.sentMessages()
804 if len(sent) != 1 {
805 t.Fatalf("sent count = %d, want 1", len(sent))
806 }
807 if !strings.Contains(sent[0].Text, "没有找到可匹配的待处理操作") {
808 t.Fatalf("sent text = %q, want pending operation guidance", sent[0].Text)
809 }
810 }
811
812 func TestGatewayApproveWithoutSessionSendsGuidance(t *testing.T) {
813 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
814 gw := NewGateway(GatewayConfig{}, nil, logger)
815 adapter := newFakeAdapter(PlatformWeixin, "fake-weixin")
816 msg := InboundMessage{ChatType: ChatDM, ChatID: "chat", UserID: "user", Text: "/approve 1"}
817
818 gw.handleSlashCommand(context.Background(), adapter, "missing-session", msg)
819
820 sent := adapter.sentMessages()
821 if len(sent) != 1 {
822 t.Fatalf("sent count = %d, want 1", len(sent))
823 }
824 if !strings.Contains(sent[0].Text, "没有找到当前会话中的待审批操作") {
825 t.Fatalf("sent text = %q, want missing approval guidance", sent[0].Text)
826 }
827 }
828
829 func TestGatewayNewSessionRemembersRotatedSessionPath(t *testing.T) {
830 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
831 var remembered string
832 gw := NewGateway(GatewayConfig{
833 OnSessionReady: func(msg InboundMessage, sessionID string) error {
834 remembered = sessionID
835 return nil
836 },
837 }, nil, logger)
838 adapter := newFakeAdapter(PlatformWeixin, "fake-weixin")
839 msg := InboundMessage{
840 Platform: PlatformWeixin,
841 ConnectionID: "weixin-weixin",
842 Domain: "weixin",
843 ChatType: ChatDM,
844 ChatID: "chat",
845 UserID: "user",
846 Text: "/new",
847 }
848 key := BuildSessionKey(msg.Session())
849 sessionDir := t.TempDir()
850 oldPath := agent.NewSessionPath(sessionDir, "old-model")
851 exec := agent.New(gatewayFakeProvider{}, tool.NewRegistry(), agent.NewSession("system"), agent.Options{}, event.Discard)
852 ctrl := control.New(control.Options{Executor: exec, SessionDir: sessionDir, SessionPath: oldPath, Label: "fake-model"})
853 leases := control.NewSessionLeaseKeeper()
854 if err := leases.Rebind(oldPath); err != nil {
855 t.Fatalf("bind old session lease: %v", err)
856 }
857 gw.controllers[key] = &sessionState{ctrl: ctrl, leases: leases, sessionPath: oldPath}
858
859 gw.handleSlashCommand(context.Background(), adapter, key, msg)
860
861 if remembered == "" || !strings.HasPrefix(remembered, "path:") {
862 t.Fatalf("remembered session = %q, want path target", remembered)
863 }
864 if remembered == "path:"+oldPath {
865 t.Fatalf("remembered session = %q, want rotated path", remembered)
866 }
867 if ctrl.SessionPath() == oldPath {
868 t.Fatalf("controller session path was not rotated")
869 }
870 if got := leases.HeldPath(); got != agent.CanonicalSessionPath(ctrl.SessionPath()) {
871 t.Fatalf("held lease = %q, want rotated path %q", got, agent.CanonicalSessionPath(ctrl.SessionPath()))
872 }
873 oldLease, err := agent.TryAcquireSessionLease(oldPath)
874 if err != nil {
875 t.Fatalf("old session lease was not released: %v", err)
876 }
877 oldLease.Release()
878 gw.closeSessions()
879 }
880
881 func TestGatewayRecoveryRebindsLeaseAndRemembersSessionPath(t *testing.T) {
882 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
883 dir := schemaOneTempDir(t)
884 originalPath := filepath.Join(dir, "session.jsonl")
885
886 disk := agent.NewSession("sys")
887 disk.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
888 disk.Add(provider.Message{Role: provider.RoleAssistant, Content: "disk"})
889 if err := disk.Save(originalPath); err != nil {
890 t.Fatalf("save disk session: %v", err)
891 }
892
893 local := agent.NewSession("sys")
894 local.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
895 local.Add(provider.Message{Role: provider.RoleAssistant, Content: "local"})
896 exec := agent.New(nil, nil, local, agent.Options{}, event.Discard)
897
898 var remembered []string
899 gw := NewGateway(GatewayConfig{
900 OnSessionReady: func(_ InboundMessage, sessionID string) error {
901 remembered = append(remembered, sessionID)
902 return nil
903 },
904 }, nil, logger)
905 msg := InboundMessage{
906 Platform: PlatformWeixin,
907 ConnectionID: "weixin-main",
908 Domain: "weixin",
909 ChatType: ChatDM,
910 ChatID: "chat",
911 UserID: "user",
912 }
913 key := BuildSessionKey(msg.Session())
914 adapter := newFakeAdapter(PlatformWeixin, "fake-weixin")
915 sessionSink := &sessionEventSink{}
916 sessionSink.setTarget(newRenderSink(
917 context.Background(), adapter, msg.ConnectionID, msg.Domain, msg.ChatID,
918 msg.ChatType, msg.UserID, msg.MessageID, logger, nil, nil,
919 ))
920 t.Cleanup(func() { sessionSink.setTarget(nil) })
921 leases := control.NewSessionLeaseKeeper()
922 if err := leases.Rebind(originalPath); err != nil {
923 t.Fatalf("bind original session lease: %v", err)
924 }
925 state := &sessionState{sink: sessionSink, leases: leases, sessionPath: originalPath}
926 gw.controllers[key] = state
927 gw.sessionOverrides[key] = sessionRuntimeOverride{sessionPath: originalPath, label: "session:original"}
928 ctrl := control.New(control.Options{
929 Executor: exec,
930 SessionDir: dir,
931 SessionPath: originalPath,
932 Label: "test",
933 Sink: sessionSink,
934 OnSessionRecovered: gw.botSessionRecoveredHandler(key, msg, state),
935 })
936 state.ctrl = ctrl
937 mustBindBotControllerAuthority(t, leases, ctrl)
938 t.Cleanup(gw.closeSessions)
939
940 if err := ctrl.Snapshot(); err != nil {
941 t.Fatalf("snapshot diverged session: %v", err)
942 }
943 recoveryPath := ctrl.SessionPath()
944 if recoveryPath == "" || recoveryPath == originalPath {
945 t.Fatalf("controller path = %q, want recovery path", recoveryPath)
946 }
947 if got := leases.HeldPath(); got != agent.CanonicalSessionPath(recoveryPath) {
948 t.Fatalf("held lease = %q, want recovery path %q", got, agent.CanonicalSessionPath(recoveryPath))
949 }
950 leases.WaitForRetiredLeases()
951 mustAcquireAndReleaseBotSessionLease(t, originalPath)
952
953 gw.mu.Lock()
954 gotStatePath := state.sessionPath
955 gotOverridePath := gw.sessionOverrides[key].sessionPath
956 gw.mu.Unlock()
957 if canonicalBotPath(gotStatePath) != canonicalBotPath(recoveryPath) {
958 t.Fatalf("state path = %q, want recovery path %q", gotStatePath, recoveryPath)
959 }
960 if canonicalBotPath(gotOverridePath) != canonicalBotPath(recoveryPath) {
961 t.Fatalf("override path = %q, want recovery path %q", gotOverridePath, recoveryPath)
962 }
963 if len(remembered) != 1 || remembered[0] != botSessionTarget(recoveryPath) {
964 t.Fatalf("remembered sessions = %v, want [%q]", remembered, botSessionTarget(recoveryPath))
965 }
966
967 if err := ctrl.Snapshot(); err != nil {
968 t.Fatalf("snapshot recovered session: %v", err)
969 }
970 matches, err := filepath.Glob(filepath.Join(dir, "*-recovery-*.jsonl"))
971 if err != nil {
972 t.Fatalf("glob recovery sessions: %v", err)
973 }
974 transcripts := matches[:0]
975 for _, path := range matches {
976 if !strings.HasSuffix(path, ".events.jsonl") && !strings.HasSuffix(path, ".conflicts.jsonl") && !strings.HasSuffix(path, ".turns.jsonl") {
977 transcripts = append(transcripts, path)
978 }
979 }
980 if len(transcripts) != 1 || transcripts[0] != recoveryPath {
981 t.Fatalf("recovery transcripts = %v, want only %q", transcripts, recoveryPath)
982 }
983 if sent := adapter.sentMessages(); len(sent) != 0 {
984 t.Fatalf("recovery maintenance leaked into IM messages: %+v", sent)
985 }
986 }
987
988 func TestGatewayRecoveryLeaseFailureKeepsOriginalGeneration(t *testing.T) {
989 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
990 dir := t.TempDir()
991 originalPath := filepath.Join(dir, "original.jsonl")
992 recoveryPath := filepath.Join(dir, "recovery.jsonl")
993 readyCalls := 0
994 gw := NewGateway(GatewayConfig{
995 OnSessionReady: func(InboundMessage, string) error {
996 readyCalls++
997 return nil
998 },
999 }, nil, logger)
1000 msg := InboundMessage{Platform: PlatformWeixin, ChatType: ChatDM, ChatID: "chat", UserID: "user"}
1001 key := BuildSessionKey(msg.Session())
1002 leases := control.NewSessionLeaseKeeper()
1003 if err := leases.Rebind(originalPath); err != nil {
1004 t.Fatalf("bind original session lease: %v", err)
1005 }
1006 defer leases.Release()
1007 blocker, err := agent.TryAcquireSessionLease(recoveryPath)
1008 if err != nil {
1009 t.Fatalf("bind recovery blocker: %v", err)
1010 }
1011 defer blocker.Release()
1012 state := &sessionState{leases: leases, sessionPath: originalPath}
1013 gw.controllers[key] = state
1014 gw.sessionOverrides[key] = sessionRuntimeOverride{sessionPath: originalPath}
1015
1016 err = gw.botSessionRecoveredHandler(key, msg, state)(control.SessionRecoveryInfo{
1017 OriginalPath: originalPath,
1018 RecoveryPath: recoveryPath,
1019 })
1020 if err == nil {
1021 t.Fatal("recovery handoff succeeded while recovery lease was held")
1022 }
1023 if got := leases.HeldPath(); got != agent.CanonicalSessionPath(originalPath) {
1024 t.Fatalf("held lease = %q, want original path %q", got, agent.CanonicalSessionPath(originalPath))
1025 }
1026 gw.mu.Lock()
1027 gotStatePath := state.sessionPath
1028 gotOverridePath := gw.sessionOverrides[key].sessionPath
1029 gw.mu.Unlock()
1030 if gotStatePath != originalPath || gotOverridePath != originalPath {
1031 t.Fatalf("paths changed after failed handoff: state=%q override=%q", gotStatePath, gotOverridePath)
1032 }
1033 if readyCalls != 0 {
1034 t.Fatalf("session-ready callback ran %d times after failed handoff", readyCalls)
1035 }
1036 }
1037
1038 func TestGatewayLateRecoveryCannotReplaceCurrentSessionMapping(t *testing.T) {
1039 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
1040 dir := t.TempDir()
1041 oldPath := filepath.Join(dir, "old.jsonl")
1042 oldRecoveryPath := filepath.Join(dir, "old-recovery.jsonl")
1043 currentPath := filepath.Join(dir, "current.jsonl")
1044 readyCalls := 0
1045 gw := NewGateway(GatewayConfig{
1046 OnSessionReady: func(InboundMessage, string) error {
1047 readyCalls++
1048 return nil
1049 },
1050 }, nil, logger)
1051 msg := InboundMessage{Platform: PlatformWeixin, ChatType: ChatDM, ChatID: "chat", UserID: "user"}
1052 key := BuildSessionKey(msg.Session())
1053 oldLeases := control.NewSessionLeaseKeeper()
1054 if err := oldLeases.Rebind(oldPath); err != nil {
1055 t.Fatalf("bind old session lease: %v", err)
1056 }
1057 defer oldLeases.Release()
1058 oldState := &sessionState{leases: oldLeases, sessionPath: oldPath}
1059 currentState := &sessionState{sessionPath: currentPath}
1060 gw.controllers[key] = currentState
1061 gw.sessionOverrides[key] = sessionRuntimeOverride{sessionPath: currentPath}
1062
1063 if err := gw.botSessionRecoveredHandler(key, msg, oldState)(control.SessionRecoveryInfo{
1064 OriginalPath: oldPath,
1065 RecoveryPath: oldRecoveryPath,
1066 }); err != nil {
1067 t.Fatalf("late recovery handoff: %v", err)
1068 }
1069 if got := oldLeases.HeldPath(); got != agent.CanonicalSessionPath(oldRecoveryPath) {
1070 t.Fatalf("old generation lease = %q, want recovery path %q", got, agent.CanonicalSessionPath(oldRecoveryPath))
1071 }
1072 gw.mu.Lock()
1073 gotCurrentPath := currentState.sessionPath
1074 gotOverridePath := gw.sessionOverrides[key].sessionPath
1075 gw.mu.Unlock()
1076 if gotCurrentPath != currentPath || gotOverridePath != currentPath {
1077 t.Fatalf("current mapping overwritten by late recovery: state=%q override=%q", gotCurrentPath, gotOverridePath)
1078 }
1079 if readyCalls != 0 {
1080 t.Fatalf("session-ready callback ran %d times for retired generation", readyCalls)
1081 }
1082 }
1083
1084 func TestGatewayLateRecoveryAfterRetirementDoesNotReacquireLease(t *testing.T) {
1085 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
1086 dir := t.TempDir()
1087 originalPath := filepath.Join(dir, "original.jsonl")
1088 recoveryPath := filepath.Join(dir, "recovery.jsonl")
1089 gw := NewGateway(GatewayConfig{}, nil, logger)
1090 msg := InboundMessage{Platform: PlatformWeixin, ChatType: ChatDM, ChatID: "chat", UserID: "user"}
1091 key := BuildSessionKey(msg.Session())
1092 leases := control.NewSessionLeaseKeeper()
1093 if err := leases.Rebind(originalPath); err != nil {
1094 t.Fatalf("bind original session lease: %v", err)
1095 }
1096 state := &sessionState{leases: leases, sessionPath: originalPath}
1097 gw.controllers[key] = state
1098 handler := gw.botSessionRecoveredHandler(key, msg, state)
1099
1100 // Gateway shutdown retires and releases the state before waiting for every
1101 // turn goroutine. A callback already captured by the controller must not
1102 // reacquire a lease after that teardown.
1103 gw.closeSessions()
1104 if got := leases.HeldPath(); got != "" {
1105 t.Fatalf("lease after retirement = %q, want empty", got)
1106 }
1107 if err := handler(control.SessionRecoveryInfo{
1108 OriginalPath: originalPath,
1109 RecoveryPath: recoveryPath,
1110 }); !errors.Is(err, errBotSessionRetired) {
1111 t.Fatalf("late recovery error = %v, want %v", err, errBotSessionRetired)
1112 }
1113 if got := leases.HeldPath(); got != "" {
1114 t.Fatalf("late recovery reacquired lease after retirement: %q", got)
1115 }
1116 probe, err := agent.TryAcquireSessionLease(recoveryPath)
1117 if err != nil {
1118 t.Fatalf("recovery lease remained unavailable after late callback: %v", err)
1119 }
1120 probe.Release()
1121 }
1122
1123 func TestGatewayNewSessionLeaseFailureRetiresSession(t *testing.T) {
1124 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
1125 readyCalls := 0
1126 gw := NewGateway(GatewayConfig{
1127 OnSessionReady: func(InboundMessage, string) error {
1128 readyCalls++
1129 return nil
1130 },
1131 }, nil, logger)
1132 adapter := newFakeAdapter(PlatformWeixin, "fake-weixin")
1133 msg := InboundMessage{
1134 Platform: PlatformWeixin,
1135 ConnectionID: "weixin-weixin",
1136 Domain: "weixin",
1137 ChatType: ChatDM,
1138 ChatID: "chat",
1139 UserID: "user",
1140 Text: "/new",
1141 }
1142 key := BuildSessionKey(msg.Session())
1143 sessionDir := t.TempDir()
1144 oldPath := filepath.Join(sessionDir, "old.jsonl")
1145 newPath := filepath.Join(sessionDir, "new.jsonl")
1146 ctrl := &rotatingBotController{path: oldPath, newPath: newPath}
1147 leases := control.NewSessionLeaseKeeper()
1148 if err := leases.Rebind(oldPath); err != nil {
1149 t.Fatalf("bind old session lease: %v", err)
1150 }
1151 blocker, err := agent.TryAcquireSessionLease(newPath)
1152 if err != nil {
1153 t.Fatalf("hold rotated session lease: %v", err)
1154 }
1155 defer blocker.Release()
1156 gw.controllers[key] = &sessionState{ctrl: ctrl, leases: leases, sessionPath: oldPath}
1157
1158 gw.handleSlashCommand(context.Background(), adapter, key, msg)
1159
1160 gw.mu.Lock()
1161 _, exists := gw.controllers[key]
1162 gw.mu.Unlock()
1163 if exists {
1164 t.Fatal("lease-failed session remains registered")
1165 }
1166 if ctrl.newCalls != 1 || !ctrl.closed {
1167 t.Fatalf("controller lifecycle = new calls %d closed %v, want 1/true", ctrl.newCalls, ctrl.closed)
1168 }
1169 if got := leases.HeldPath(); got != "" {
1170 t.Fatalf("old lease remained held after retirement: %q", got)
1171 }
1172 oldLease, err := agent.TryAcquireSessionLease(oldPath)
1173 if err != nil {
1174 t.Fatalf("old session lease was not released after retirement: %v", err)
1175 }
1176 oldLease.Release()
1177 if readyCalls != 0 {
1178 t.Fatalf("session-ready callback ran %d times after failed creation", readyCalls)
1179 }
1180 sent := adapter.sentMessages()
1181 if len(sent) != 1 || !strings.Contains(sent[0].Text, "新会话创建失败") || strings.Contains(sent[0].Text, "已开始新会话") {
1182 t.Fatalf("sent messages = %+v, want a single creation-failed response", sent)
1183 }
1184 }
1185
1186 func TestGatewayCloseSessionStateReleasesSessionLease(t *testing.T) {
1187 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
1188 gw := NewGateway(GatewayConfig{}, nil, logger)
1189 path := filepath.Join(t.TempDir(), "session.jsonl")
1190 leases := control.NewSessionLeaseKeeper()
1191 if err := leases.Rebind(path); err != nil {
1192 t.Fatalf("bind session lease: %v", err)
1193 }
1194 ctrl := control.New(control.Options{})
1195
1196 gw.closeSessionState(&sessionState{ctrl: ctrl, leases: leases})
1197
1198 lease, err := agent.TryAcquireSessionLease(path)
1199 if err != nil {
1200 t.Fatalf("session lease was not released after controller close: %v", err)
1201 }
1202 lease.Release()
1203 }
1204
1205 func TestGatewayFullAccessCommandUpdatesCurrentSessionAndConnectionDefault(t *testing.T) {
1206 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
1207 var persistedMode string
1208 var persistedConnection string
1209 gw := NewGateway(GatewayConfig{
1210 ToolApprovalMode: "ask",
1211 ConnectionChannels: map[string]ChannelConfig{
1212 "feishu-lark": {ToolApprovalMode: "ask"},
1213 },
1214 OnToolApprovalModeChange: func(msg InboundMessage, mode string) error {
1215 persistedConnection = msg.ConnectionID
1216 persistedMode = mode
1217 return nil
1218 },
1219 }, nil, logger)
1220 adapter := newFakeAdapter(PlatformFeishu, "fake-lark")
1221 msg := InboundMessage{
1222 Platform: PlatformFeishu,
1223 ConnectionID: "feishu-lark",
1224 Domain: "lark",
1225 ChatType: ChatDM,
1226 ChatID: "chat",
1227 UserID: "user",
1228 Text: "/mode danger-full-access",
1229 }
1230 key := BuildSessionKey(msg.Session())
1231 ctrl := control.New(control.Options{})
1232 ctrl.SetToolApprovalMode(control.ToolApprovalAsk)
1233 gw.controllers[key] = &sessionState{ctrl: ctrl}
1234
1235 gw.handleSlashCommand(context.Background(), adapter, key, msg)
1236
1237 if got := ctrl.ToolApprovalMode(); got != control.ToolApprovalDangerFullAccess {
1238 t.Fatalf("current session mode = %q, want danger-full-access", got)
1239 }
1240 if got := gw.cfg.ConnectionChannels["feishu-lark"].ToolApprovalMode; got != control.ToolApprovalDangerFullAccess {
1241 t.Fatalf("connection default mode = %q, want danger-full-access", got)
1242 }
1243 if persistedConnection != "feishu-lark" || persistedMode != control.ToolApprovalDangerFullAccess {
1244 t.Fatalf("persisted = %q/%q, want feishu-lark/danger-full-access", persistedConnection, persistedMode)
1245 }
1246 sent := adapter.sentMessages()
1247 if len(sent) != 1 || !strings.Contains(sent[0].Text, "已切换为完全权限") {
1248 t.Fatalf("sent = %#v, want full-access confirmation", sent)
1249 }
1250 }
1251
1252 func TestGatewayUpdateConnectionToolApprovalModeUpdatesHashedActiveSessions(t *testing.T) {
1253 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
1254 gw := NewGateway(GatewayConfig{
1255 ToolApprovalMode: "ask",
1256 ConnectionChannels: map[string]ChannelConfig{
1257 "feishu-lark": {ToolApprovalMode: "yolo"},
1258 },
1259 }, nil, logger)
1260
1261 msg := InboundMessage{
1262 Platform: PlatformFeishu,
1263 ConnectionID: "feishu-lark",
1264 Domain: "lark",
1265 ChatType: ChatDM,
1266 ChatID: "chat",
1267 UserID: "user",
1268 }
1269 key := BuildSessionKey(msg.Session())
1270 if strings.HasPrefix(key, msg.ConnectionID) {
1271 t.Fatalf("test setup expected hashed key, got %q", key)
1272 }
1273 ctrl := control.New(control.Options{})
1274 ctrl.SetToolApprovalMode(control.ToolApprovalYolo)
1275 gw.controllers[key] = &sessionState{ctrl: ctrl, platform: msg.Platform, connectionID: msg.ConnectionID}
1276
1277 gw.UpdateConnectionToolApprovalMode("feishu-lark", control.ToolApprovalAsk)
1278
1279 if got := ctrl.ToolApprovalMode(); got != control.ToolApprovalAsk {
1280 t.Fatalf("active session mode = %q, want ask", got)
1281 }
1282 if got := gw.cfg.ConnectionChannels["feishu-lark"].ToolApprovalMode; got != control.ToolApprovalAsk {
1283 t.Fatalf("connection default mode = %q, want ask", got)
1284 }
1285 }
1286
1287 func TestGatewayUpdateConnectionToolApprovalModeInheritsGatewayDefault(t *testing.T) {
1288 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
1289 gw := NewGateway(GatewayConfig{
1290 ToolApprovalMode: control.ToolApprovalAuto,
1291 ConnectionChannels: map[string]ChannelConfig{
1292 "feishu-lark": {ToolApprovalMode: control.ToolApprovalYolo},
1293 "feishu-feishu": {ToolApprovalMode: control.ToolApprovalYolo},
1294 },
1295 }, nil, logger)
1296
1297 larkMsg := InboundMessage{
1298 Platform: PlatformFeishu,
1299 ConnectionID: "feishu-lark",
1300 Domain: "lark",
1301 ChatType: ChatDM,
1302 ChatID: "chat",
1303 UserID: "user",
1304 }
1305 larkKey := BuildSessionKey(larkMsg.Session())
1306 larkCtrl := control.New(control.Options{})
1307 larkCtrl.SetToolApprovalMode(control.ToolApprovalYolo)
1308 gw.controllers[larkKey] = &sessionState{ctrl: larkCtrl, platform: larkMsg.Platform, connectionID: larkMsg.ConnectionID}
1309
1310 otherCtrl := control.New(control.Options{})
1311 otherCtrl.SetToolApprovalMode(control.ToolApprovalYolo)
1312 gw.controllers["other-hashed-key"] = &sessionState{ctrl: otherCtrl, platform: PlatformFeishu, connectionID: "feishu-feishu"}
1313
1314 gw.UpdateConnectionToolApprovalMode("feishu-lark", "")
1315
1316 if got := gw.cfg.ConnectionChannels["feishu-lark"].ToolApprovalMode; got != "" {
1317 t.Fatalf("connection override = %q, want empty inherit", got)
1318 }
1319 if got := larkCtrl.ToolApprovalMode(); got != control.ToolApprovalAuto {
1320 t.Fatalf("lark active session mode = %q, want inherited auto", got)
1321 }
1322 if got := otherCtrl.ToolApprovalMode(); got != control.ToolApprovalYolo {
1323 t.Fatalf("other connection mode = %q, want unchanged yolo", got)
1324 }
1325 }
1326
1327 func TestGatewayApprovalReplyUnblocksWedgedTurn(t *testing.T) {
1328 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
1329 gw := NewGateway(GatewayConfig{Allowlist: AllowlistConfig{AllowAll: true}}, nil, logger)
1330 adapter := newFakeAdapter(PlatformFeishu, "fake-feishu")
1331 binding := AdapterBinding{ID: "feishu", Platform: PlatformFeishu, Adapter: adapter}
1332 msg := InboundMessage{
1333 Platform: PlatformFeishu,
1334 ConnectionID: "feishu",
1335 ChatType: ChatDM,
1336 ChatID: "chat",
1337 UserID: "user",
1338 Text: "delete everything",
1339 }
1340 key := BuildSessionKey(msg.Session())
1341 sink := &sessionEventSink{}
1342 ctrl := &blockingApprovalController{
1343 emit: sink.Emit,
1344 emitted: make(chan struct{}),
1345 approved: make(chan struct{}),
1346 done: make(chan struct{}),
1347 }
1348 gw.controllers[key] = &sessionState{
1349 ctrl: ctrl,
1350 sink: sink,
1351 pendingApprovals: make(map[string]event.Approval),
1352 pendingAsks: make(map[string][]event.AskQuestion),
1353 }
1354
1355 ctx := t.Context()
1356 go gw.dispatchLoop(ctx, binding)
1357
1358 adapter.msgCh <- msg
1359 select {
1360 case <-ctrl.emitted:
1361 case <-time.After(2 * time.Second):
1362 t.Fatal("approval request was never emitted; turn did not start")
1363 }
1364
1365 adapter.msgCh <- InboundMessage{
1366 Platform: PlatformFeishu,
1367 ConnectionID: "feishu",
1368 ChatType: ChatDM,
1369 ChatID: "chat",
1370 UserID: "user",
1371 Text: "/approve appr-1",
1372 }
1373
1374 select {
1375 case <-ctrl.done:
1376 case <-time.After(2 * time.Second):
1377 t.Fatal("deadlock: /approve reply was not delivered while the turn blocked on approval")
1378 }
1379 }
1380
1381 func TestGatewayAskReplyUnblocksWedgedTurn(t *testing.T) {
1382 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
1383 gw := NewGateway(GatewayConfig{Allowlist: AllowlistConfig{AllowAll: true}}, nil, logger)
1384 adapter := newFakeAdapter(PlatformFeishu, "fake-feishu")
1385 binding := AdapterBinding{ID: "feishu", Platform: PlatformFeishu, Adapter: adapter}
1386 msg := InboundMessage{
1387 Platform: PlatformFeishu,
1388 ConnectionID: "feishu",
1389 ChatType: ChatDM,
1390 ChatID: "chat",
1391 UserID: "user",
1392 Text: "choose a plan",
1393 }
1394 key := BuildSessionKey(msg.Session())
1395 sink := &sessionEventSink{}
1396 ctrl := &blockingAskController{
1397 emit: sink.Emit,
1398 emitted: make(chan struct{}),
1399 answered: make(chan []event.AskAnswer, 1),
1400 done: make(chan struct{}),
1401 }
1402 gw.controllers[key] = &sessionState{
1403 ctrl: ctrl,
1404 sink: sink,
1405 pendingApprovals: make(map[string]event.Approval),
1406 pendingAsks: make(map[string][]event.AskQuestion),
1407 }
1408
1409 ctx := t.Context()
1410 go gw.dispatchLoop(ctx, binding)
1411
1412 adapter.msgCh <- msg
1413 select {
1414 case <-ctrl.emitted:
1415 case <-time.After(2 * time.Second):
1416 t.Fatal("ask request was never emitted; turn did not start")
1417 }
1418
1419 adapter.msgCh <- InboundMessage{
1420 Platform: PlatformFeishu,
1421 ConnectionID: "feishu",
1422 ChatType: ChatDM,
1423 ChatID: "chat",
1424 UserID: "user",
1425 Text: "1",
1426 }
1427
1428 select {
1429 case <-ctrl.done:
1430 case <-time.After(2 * time.Second):
1431 t.Fatal("deadlock: ask reply was not delivered while the turn blocked on user choice")
1432 }
1433 }
1434
1435 func TestGatewayModeCommandSupportsPermissionPresetsAndStatus(t *testing.T) {
1436 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
1437 gw := NewGateway(GatewayConfig{
1438 ConnectionChannels: map[string]ChannelConfig{
1439 "weixin-weixin": {ToolApprovalMode: "ask"},
1440 },
1441 }, nil, logger)
1442 adapter := newFakeAdapter(PlatformWeixin, "fake-weixin")
1443 msg := InboundMessage{
1444 Platform: PlatformWeixin,
1445 ConnectionID: "weixin-weixin",
1446 Domain: "weixin",
1447 ChatType: ChatDM,
1448 ChatID: "chat",
1449 UserID: "user",
1450 }
1451 key := BuildSessionKey(msg.Session())
1452
1453 msg.Text = "/mode workspace-write"
1454 gw.handleSlashCommand(context.Background(), adapter, key, msg)
1455 if got := gw.cfg.ConnectionChannels["weixin-weixin"].ToolApprovalMode; got != control.ToolApprovalWorkspaceWrite {
1456 t.Fatalf("/mode workspace-write default = %q, want workspace-write", got)
1457 }
1458
1459 msg.Text = "/mode read-only"
1460 gw.handleSlashCommand(context.Background(), adapter, key, msg)
1461 if got := gw.cfg.ConnectionChannels["weixin-weixin"].ToolApprovalMode; got != control.ToolApprovalReadOnly {
1462 t.Fatalf("/mode read-only default = %q, want read-only", got)
1463 }
1464
1465 msg.Text = "/mode"
1466 gw.handleSlashCommand(context.Background(), adapter, key, msg)
1467 sent := adapter.sentMessages()
1468 if len(sent) != 3 {
1469 t.Fatalf("sent count = %d, want 3", len(sent))
1470 }
1471 if !strings.Contains(sent[2].Text, "当前权限:仅可查看") {
1472 t.Fatalf("status = %q, want read-only status", sent[2].Text)
1473 }
1474 }
1475
1476 func TestGatewayHelpMentionsPermissionPresetCommands(t *testing.T) {
1477 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
1478 gw := NewGateway(GatewayConfig{}, nil, logger)
1479 adapter := newFakeAdapter(PlatformFeishu, "fake-feishu")
1480 msg := InboundMessage{ChatType: ChatDM, ChatID: "chat", UserID: "user", Text: "/help"}
1481
1482 gw.handleSlashCommand(context.Background(), adapter, "session-key", msg)
1483
1484 sent := adapter.sentMessages()
1485 if len(sent) != 1 {
1486 t.Fatalf("sent count = %d, want 1", len(sent))
1487 }
1488 if !strings.Contains(sent[0].Text, "/mode read-only|workspace-write|danger-full-access|status") {
1489 t.Fatalf("help = %q, want permission preset commands", sent[0].Text)
1490 }
1491 if !strings.Contains(sent[0].Text, "/projects") || !strings.Contains(sent[0].Text, "/attach session") || !strings.Contains(sent[0].Text, "/search all") {
1492 t.Fatalf("help = %q, want project/session commands", sent[0].Text)
1493 }
1494 }
1495
1496 func TestGatewayProjectCommandsListAndUseProjectOverride(t *testing.T) {
1497 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
1498 base := t.TempDir()
1499 alpha := filepath.Join(base, "alpha-project")
1500 beta := filepath.Join(base, "beta-project")
1501 if err := os.MkdirAll(alpha, 0o755); err != nil {
1502 t.Fatal(err)
1503 }
1504 if err := os.MkdirAll(beta, 0o755); err != nil {
1505 t.Fatal(err)
1506 }
1507 gw := NewGateway(GatewayConfig{
1508 WorkspaceRoot: alpha,
1509 ConnectionChannels: map[string]ChannelConfig{
1510 "weixin-main": {WorkspaceRoot: beta},
1511 },
1512 }, nil, logger)
1513 adapter := newFakeAdapter(PlatformWeixin, "fake-weixin")
1514 msg := InboundMessage{
1515 Platform: PlatformWeixin,
1516 ConnectionID: "weixin-main",
1517 ChatType: ChatDM,
1518 ChatID: "chat",
1519 UserID: "user",
1520 Text: "/projects",
1521 }
1522 key := BuildSessionKey(msg.Session())
1523
1524 gw.handleSlashCommand(context.Background(), adapter, key, msg)
1525 sent := adapter.sentMessages()
1526 if len(sent) != 1 || !strings.Contains(sent[0].Text, "alpha-project") || !strings.Contains(sent[0].Text, "beta-project") {
1527 t.Fatalf("/projects sent = %#v, want both projects", sent)
1528 }
1529
1530 msg.Text = "/use project alpha"
1531 gw.handleSlashCommand(context.Background(), adapter, key, msg)
1532 _, root, _ := gw.sessionOptionsForMessage(msg)
1533 if canonicalBotPath(root) != canonicalBotPath(alpha) {
1534 t.Fatalf("workspace after /use project = %q, want %q", root, alpha)
1535 }
1536
1537 msg.Text = "/use project default"
1538 gw.handleSlashCommand(context.Background(), adapter, key, msg)
1539 _, root, _ = gw.sessionOptionsForMessage(msg)
1540 if canonicalBotPath(root) != canonicalBotPath(beta) {
1541 t.Fatalf("workspace after /use project default = %q, want connection default %q", root, beta)
1542 }
1543 }
1544
1545 func TestGatewaySessionsSearchAndAttachSessionOverride(t *testing.T) {
1546 t.Setenv("REASONIX_HOME", t.TempDir())
1547 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
1548 projectRoot := filepath.Join(t.TempDir(), "attach-project")
1549 if err := os.MkdirAll(projectRoot, 0o755); err != nil {
1550 t.Fatal(err)
1551 }
1552 sessionDir := botSessionDir(projectRoot)
1553 sessionPath := filepath.Join(sessionDir, "attached.jsonl")
1554 sess := agent.NewSession("system")
1555 sess.Add(provider.Message{Role: provider.RoleUser, Content: "needle attach conversation"})
1556 if err := sess.Save(sessionPath); err != nil {
1557 t.Fatalf("Save session: %v", err)
1558 }
1559 if err := agent.UpdateSessionMeta(sessionPath, "model-a", "needle attach conversation", 1, true); err != nil {
1560 t.Fatalf("UpdateSessionMeta: %v", err)
1561 }
1562 gw := NewGateway(GatewayConfig{WorkspaceRoot: projectRoot}, nil, logger)
1563 adapter := newFakeAdapter(PlatformFeishu, "fake-feishu")
1564 msg := InboundMessage{
1565 Platform: PlatformFeishu,
1566 ConnectionID: "feishu-lark",
1567 ChatType: ChatDM,
1568 ChatID: "chat",
1569 UserID: "user",
1570 Text: "/sessions search needle",
1571 }
1572 key := BuildSessionKey(msg.Session())
1573
1574 gw.handleSlashCommand(context.Background(), adapter, key, msg)
1575 sent := adapter.sentMessages()
1576 if len(sent) != 1 || !strings.Contains(sent[0].Text, "needle attach") || !strings.Contains(sent[0].Text, "s1") {
1577 t.Fatalf("/sessions sent = %#v, want indexed session", sent)
1578 }
1579
1580 msg.Text = "/attach session s1"
1581 gw.handleSlashCommand(context.Background(), adapter, key, msg)
1582 profile := gw.sessionProfileForMessage(msg)
1583 if canonicalBotPath(profile.sessionPath) != canonicalBotPath(sessionPath) {
1584 t.Fatalf("attached session path = %q, want %q", profile.sessionPath, sessionPath)
1585 }
1586 if canonicalBotPath(profile.workspaceRoot) != canonicalBotPath(projectRoot) {
1587 t.Fatalf("attached workspace root = %q, want %q", profile.workspaceRoot, projectRoot)
1588 }
1589 }
1590
1591 func TestGatewayRuntimeOverridePreservesControllersWithActiveWork(t *testing.T) {
1592 tests := []struct {
1593 name string
1594 status control.RuntimeStatus
1595 admittedTurn bool
1596 }{
1597 {name: "foreground turn", status: control.RuntimeStatus{Running: true}},
1598 {name: "pending prompt", status: control.RuntimeStatus{PendingPrompt: true}},
1599 {name: "background job", status: control.RuntimeStatus{BackgroundJobs: 1}},
1600 {name: "admitted turn", admittedTurn: true},
1601 }
1602 for _, tc := range tests {
1603 t.Run(tc.name, func(t *testing.T) {
1604 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
1605 gw := NewGateway(GatewayConfig{}, nil, logger)
1606 msg := InboundMessage{Platform: PlatformFeishu, ChatType: ChatDM, ChatID: "chat", UserID: "user"}
1607 key := BuildSessionKey(msg.Session())
1608 ctrl := &runtimeStatusBotController{status: tc.status, workspaceRoot: "/old"}
1609 state := &sessionState{ctrl: ctrl, workspaceRoot: "/old"}
1610 gw.controllers[key] = state
1611 gw.sessionOverrides[key] = sessionRuntimeOverride{channel: ChannelConfig{WorkspaceRoot: "/old"}, label: "project:old"}
1612 if tc.admittedTurn {
1613 if result := gw.sessions.TryAcquireWithQueue(key, msg, QueueOptions{}); !result.Acquired {
1614 t.Fatalf("admit turn: %+v", result)
1615 }
1616 }
1617
1618 text := gw.handleUseProjectCommand(context.Background(), msg, "/use project default")
1619 if !strings.Contains(text, "请先完成或停止") {
1620 t.Fatalf("busy response = %q", text)
1621 }
1622 if ctrl.closed || gw.controllers[key] != state {
1623 t.Fatalf("active controller was replaced or closed: closed=%v installed=%v", ctrl.closed, gw.controllers[key] == state)
1624 }
1625 if override, ok := gw.sessionOverrides[key]; !ok || override.label != "project:old" {
1626 t.Fatalf("active override changed: %+v, present=%v", override, ok)
1627 }
1628 })
1629 }
1630 }
1631
1632 func TestGatewayDefersProfileMismatchWhileBackgroundWorkIsActive(t *testing.T) {
1633 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
1634 newRoot := t.TempDir()
1635 gw := NewGateway(GatewayConfig{WorkspaceRoot: newRoot}, nil, logger)
1636 msg := InboundMessage{Platform: PlatformFeishu, ChatType: ChatDM, ChatID: "chat", UserID: "user"}
1637 key := BuildSessionKey(msg.Session())
1638 ctrl := &runtimeStatusBotController{
1639 status: control.RuntimeStatus{BackgroundJobs: 1}, workspaceRoot: t.TempDir(),
1640 }
1641 state := &sessionState{ctrl: ctrl, workspaceRoot: ctrl.workspaceRoot}
1642 gw.controllers[key] = state
1643
1644 got := gw.getOrCreateSession(context.Background(), key, msg)
1645 if got != state || gw.controllers[key] != state || ctrl.closed {
1646 t.Fatalf("profile mismatch canceled active work: gotOld=%v installed=%v closed=%v", got == state, gw.controllers[key] == state, ctrl.closed)
1647 }
1648 if state.workspaceRoot == newRoot {
1649 t.Fatalf("deferred runtime change mutated the live profile to %q", state.workspaceRoot)
1650 }
1651 }
1652
1653 func TestGatewaySearchAllSearchesIndexedProjects(t *testing.T) {
1654 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
1655 projectRoot := t.TempDir()
1656 if err := os.WriteFile(filepath.Join(projectRoot, "needle.txt"), []byte("alpha\nunique-cross-project-needle\n"), 0o644); err != nil {
1657 t.Fatal(err)
1658 }
1659 gw := NewGateway(GatewayConfig{WorkspaceRoot: projectRoot}, nil, logger)
1660
1661 text := gw.handleProjectSearchCommand(context.Background(), "/search all unique-cross-project-needle")
1662 if !strings.Contains(text, "needle.txt") || !strings.Contains(text, "unique-cross-project-needle") {
1663 t.Fatalf("search text = %q, want file hit", text)
1664 }
1665 }
1666
1667 func TestSearchBotProjectsFallbackStopsAtLimit(t *testing.T) {
1668 projectRoot := t.TempDir()
1669 if err := os.WriteFile(filepath.Join(projectRoot, "one.txt"), []byte("first fallback needle\n"), 0o644); err != nil {
1670 t.Fatal(err)
1671 }
1672 if err := os.WriteFile(filepath.Join(projectRoot, "two.txt"), []byte("second fallback needle\n"), 0o644); err != nil {
1673 t.Fatal(err)
1674 }
1675 projects := []botProjectEntry{{
1676 ID: "p1",
1677 Name: "project",
1678 Root: projectRoot,
1679 }}
1680
1681 results, err := searchBotProjectsFallback(context.Background(), projects, []string{projectRoot}, "fallback needle", 1)
1682 if err != nil {
1683 t.Fatalf("fallback search: %v", err)
1684 }
1685
1686 if len(results) != 1 {
1687 t.Fatalf("fallback results = %d, want 1", len(results))
1688 }
1689 if results[0].ProjectID != "p1" || !strings.Contains(results[0].Text, "fallback needle") {
1690 t.Fatalf("fallback result = %#v, want project hit", results[0])
1691 }
1692 }
1693
1694 func TestSearchBotProjectsFallbackHonorsContextCancel(t *testing.T) {
1695 projectRoot := t.TempDir()
1696 if err := os.WriteFile(filepath.Join(projectRoot, "needle.txt"), []byte("fallback needle\n"), 0o644); err != nil {
1697 t.Fatal(err)
1698 }
1699 projects := []botProjectEntry{{
1700 ID: "p1",
1701 Name: "project",
1702 Root: projectRoot,
1703 }}
1704 ctx, cancel := context.WithCancel(context.Background())
1705 cancel()
1706
1707 results, err := searchBotProjectsFallback(ctx, projects, []string{projectRoot}, "fallback needle", 1)
1708
1709 if !errors.Is(err, context.Canceled) {
1710 t.Fatalf("fallback error = %v, want context canceled", err)
1711 }
1712 if len(results) != 0 {
1713 t.Fatalf("fallback results = %d, want 0 after canceled context", len(results))
1714 }
1715 }
1716
1717 func TestGatewayAdminRoleRequiredForProjectIndexCommands(t *testing.T) {
1718 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
1719 gw := NewGateway(GatewayConfig{
1720 Allowlist: AllowlistConfig{
1721 Enabled: true,
1722 Users: map[Platform][]string{PlatformWeixin: []string{"user"}},
1723 Admins: map[Platform][]string{PlatformWeixin: []string{"admin"}},
1724 },
1725 }, nil, logger)
1726 adapter := newFakeAdapter(PlatformWeixin, "fake-weixin")
1727 msg := InboundMessage{
1728 Platform: PlatformWeixin,
1729 ChatType: ChatDM,
1730 ChatID: "chat",
1731 UserID: "user",
1732 Text: "/projects",
1733 }
1734 key := BuildSessionKey(msg.Session())
1735
1736 gw.handleSlashCommand(context.Background(), adapter, key, msg)
1737
1738 sent := adapter.sentMessages()
1739 if len(sent) != 1 || !strings.Contains(sent[0].Text, "没有执行此 bot 命令的权限") {
1740 t.Fatalf("sent = %#v, want permission denial", sent)
1741 }
1742 }
1743
1744 func TestGatewayDefaultQueueSteersActiveTurn(t *testing.T) {
1745 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
1746 gw := NewGateway(GatewayConfig{Allowlist: AllowlistConfig{AllowAll: true}}, nil, logger)
1747 adapter := &fakeReactionAdapter{fakeAdapter: newFakeAdapter(PlatformFeishu, "fake-feishu")}
1748 msg := InboundMessage{
1749 Platform: PlatformFeishu,
1750 ConnectionID: "feishu-feishu",
1751 ChatType: ChatDM,
1752 ChatID: "chat",
1753 UserID: "user",
1754 Text: "please adjust the current task",
1755 MessageID: "m2",
1756 }
1757 key := BuildSessionKey(msg.Session())
1758 ctrl := &queueTestController{}
1759 gw.controllers[key] = &sessionState{ctrl: ctrl, sink: &sessionEventSink{}}
1760 if result := gw.sessions.TryAcquireWithQueue(key, msg, QueueOptions{Mode: QueueModeFollowup}); !result.Acquired {
1761 t.Fatalf("failed to mark session active: %+v", result)
1762 }
1763
1764 gw.handleMessage(context.Background(), AdapterBinding{ID: "feishu-feishu", Platform: PlatformFeishu, Adapter: adapter}, msg)
1765
1766 if got := ctrl.steered(); len(got) != 1 || got[0] != msg.Text {
1767 t.Fatalf("steers = %#v, want current message", got)
1768 }
1769 sent := adapter.sentMessages()
1770 if len(sent) != 1 || !strings.Contains(sent[0].Text, "并入当前任务") {
1771 t.Fatalf("sent = %#v, want steer acknowledgement", sent)
1772 }
1773 if pending := gw.sessions.PendingCount(key); pending != 0 {
1774 t.Fatalf("pending = %d, want 0", pending)
1775 }
1776 if cleaned := adapter.cleanupMessages(); len(cleaned) != 1 || cleaned[0] != "m2" {
1777 t.Fatalf("cleanup messages = %#v, want [m2]", cleaned)
1778 }
1779 }
1780
1781 func TestGatewayRejectedSteerFallsBackToFollowupQueue(t *testing.T) {
1782 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
1783 gw := NewGateway(GatewayConfig{Allowlist: AllowlistConfig{AllowAll: true}}, nil, logger)
1784 adapter := &fakeReactionAdapter{fakeAdapter: newFakeAdapter(PlatformFeishu, "fake-feishu")}
1785 msg := InboundMessage{
1786 Platform: PlatformFeishu,
1787 ConnectionID: "feishu-feishu",
1788 ChatType: ChatDM,
1789 ChatID: "chat",
1790 UserID: "user",
1791 Text: "run this after the current turn",
1792 MessageID: "m-rejected-steer",
1793 }
1794 key := BuildSessionKey(msg.Session())
1795 ctrl := &queueTestController{rejectSteer: true}
1796 gw.controllers[key] = &sessionState{ctrl: ctrl, sink: &sessionEventSink{}}
1797 if result := gw.sessions.TryAcquireWithQueue(key, msg, QueueOptions{Mode: QueueModeFollowup}); !result.Acquired {
1798 t.Fatalf("failed to mark session active: %+v", result)
1799 }
1800
1801 gw.handleMessage(context.Background(), AdapterBinding{ID: "feishu-feishu", Platform: PlatformFeishu, Adapter: adapter}, msg)
1802
1803 if got := ctrl.steered(); len(got) != 0 {
1804 t.Fatalf("rejected steers = %#v, want none", got)
1805 }
1806 if pending := gw.sessions.PendingCount(key); pending != 1 {
1807 t.Fatalf("pending = %d, want rejected steer preserved as one follow-up", pending)
1808 }
1809 if sent := adapter.sentMessages(); len(sent) != 0 {
1810 t.Fatalf("sent = %#v, rejected steer must not receive an applied acknowledgement", sent)
1811 }
1812 }
1813
1814 func TestGatewayDefaultQueueSteersMediaOnlyActiveTurn(t *testing.T) {
1815 imageServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1816 w.Header().Set("Content-Type", "image/png")
1817 _, _ = w.Write([]byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'})
1818 }))
1819 defer imageServer.Close()
1820
1821 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
1822 gw := NewGateway(GatewayConfig{
1823 Allowlist: AllowlistConfig{AllowAll: true},
1824 WorkspaceRoot: t.TempDir(),
1825 }, nil, logger)
1826 adapter := newFakeAdapter(PlatformFeishu, "fake-feishu")
1827 msg := InboundMessage{
1828 Platform: PlatformFeishu,
1829 ConnectionID: "feishu-feishu",
1830 ChatType: ChatDM,
1831 ChatID: "chat",
1832 UserID: "user",
1833 MediaURLs: []string{imageServer.URL + "/image.png"},
1834 MessageID: "m-media",
1835 }
1836 key := BuildSessionKey(msg.Session())
1837 ctrl := &queueTestController{}
1838 gw.controllers[key] = &sessionState{ctrl: ctrl, sink: &sessionEventSink{}}
1839 if result := gw.sessions.TryAcquireWithQueue(key, msg, QueueOptions{Mode: QueueModeFollowup}); !result.Acquired {
1840 t.Fatalf("failed to mark session active: %+v", result)
1841 }
1842
1843 gw.handleMessage(context.Background(), AdapterBinding{ID: "feishu-feishu", Platform: PlatformFeishu, Adapter: adapter}, msg)
1844
1845 got := ctrl.steered()
1846 if len(got) != 1 || !strings.Contains(got[0], "Attachments:") || !strings.Contains(got[0], "@.reasonix/attachments/") {
1847 t.Fatalf("steers = %#v, want saved attachment reference", got)
1848 }
1849 }
1850
1851 func TestGatewayQueueFollowupKeepsMessagesForLaterTurns(t *testing.T) {
1852 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
1853 gw := NewGateway(GatewayConfig{Allowlist: AllowlistConfig{AllowAll: true}}, nil, logger)
1854 adapter := newFakeAdapter(PlatformWeixin, "fake-weixin")
1855 msg := InboundMessage{
1856 Platform: PlatformWeixin,
1857 ConnectionID: "weixin-weixin",
1858 ChatType: ChatDM,
1859 ChatID: "chat",
1860 UserID: "user",
1861 Text: "first followup",
1862 }
1863 key := BuildSessionKey(msg.Session())
1864 ctrl := &queueTestController{}
1865 gw.controllers[key] = &sessionState{ctrl: ctrl, sink: &sessionEventSink{}}
1866 if result := gw.sessions.TryAcquireWithQueue(key, msg, QueueOptions{Mode: QueueModeFollowup}); !result.Acquired {
1867 t.Fatalf("failed to mark session active: %+v", result)
1868 }
1869 gw.sessions.SetQueueMode(key, QueueModeFollowup)
1870
1871 gw.handleMessage(context.Background(), AdapterBinding{ID: "weixin-weixin", Platform: PlatformWeixin, Adapter: adapter}, msg)
1872 second := msg
1873 second.Text = "second followup"
1874 gw.handleMessage(context.Background(), AdapterBinding{ID: "weixin-weixin", Platform: PlatformWeixin, Adapter: adapter}, second)
1875
1876 if got := ctrl.steered(); len(got) != 0 {
1877 t.Fatalf("steers = %#v, want none in followup mode", got)
1878 }
1879 if pending := gw.sessions.PendingCount(key); pending != 2 {
1880 t.Fatalf("pending = %d, want 2", pending)
1881 }
1882 next := gw.sessions.Release(key)
1883 if next == nil || next.Text != "first followup" {
1884 t.Fatalf("first release = %#v, want first followup", next)
1885 }
1886 next = gw.sessions.Release(key)
1887 if next == nil || next.Text != "second followup" {
1888 t.Fatalf("second release = %#v, want second followup", next)
1889 }
1890 }
1891
1892 func TestGatewayQueueInterruptCancelsAndKeepsNewestMessage(t *testing.T) {
1893 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
1894 gw := NewGateway(GatewayConfig{Allowlist: AllowlistConfig{AllowAll: true}}, nil, logger)
1895 adapter := newFakeAdapter(PlatformQQ, "fake-qq")
1896 msg := InboundMessage{
1897 Platform: PlatformQQ,
1898 ConnectionID: "qq",
1899 ChatType: ChatDM,
1900 ChatID: "chat",
1901 UserID: "user",
1902 Text: "newest request",
1903 }
1904 key := BuildSessionKey(msg.Session())
1905 ctrl := &queueTestController{}
1906 gw.controllers[key] = &sessionState{ctrl: ctrl, sink: &sessionEventSink{}}
1907 if result := gw.sessions.TryAcquireWithQueue(key, msg, QueueOptions{Mode: QueueModeFollowup}); !result.Acquired {
1908 t.Fatalf("failed to mark session active: %+v", result)
1909 }
1910 gw.sessions.SetQueueMode(key, QueueModeInterrupt)
1911
1912 gw.handleMessage(context.Background(), AdapterBinding{ID: "qq", Platform: PlatformQQ, Adapter: adapter}, msg)
1913
1914 if !ctrl.wasCanceled() {
1915 t.Fatal("controller was not canceled")
1916 }
1917 // Durable interrupt keeps the message in the session inbox (not SessionManager.pending).
1918 if next := gw.sessions.Release(key); next != nil {
1919 t.Fatalf("legacy pending should be empty after durable interrupt, got %#v", next)
1920 }
1921 if n := gw.nextInboxMessage(key); n == nil || n.Text != "newest request" {
1922 // Controllers without SessionAPI cannot durable-queue; accept cancel-only.
1923 if api, ok := any(ctrl).(control.SessionAPI); ok {
1924 _ = api
1925 t.Fatalf("durable inbox missing newest request")
1926 }
1927 }
1928 sent := adapter.sentMessages()
1929 if len(sent) != 1 || (!strings.Contains(sent[0].Text, "稍后处理") && !strings.Contains(sent[0].Text, "已持久排队") && !strings.Contains(sent[0].Text, "排队失败")) {
1930 t.Fatalf("sent = %#v, want interrupt acknowledgement", sent)
1931 }
1932 }
1933
1934 func TestGatewayUnknownDMGetsPairingCode(t *testing.T) {
1935 t.Setenv("REASONIX_HOME", t.TempDir())
1936 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
1937 gw := NewGateway(GatewayConfig{
1938 PairingEnabled: true,
1939 Allowlist: AllowlistConfig{Enabled: true, Users: map[Platform][]string{PlatformFeishu: nil}},
1940 }, nil, logger)
1941 adapter := newFakeAdapter(PlatformFeishu, "fake-feishu")
1942 msg := InboundMessage{
1943 Platform: PlatformFeishu,
1944 ConnectionID: "feishu-feishu",
1945 ChatType: ChatDM,
1946 ChatID: "chat",
1947 UserID: "user",
1948 Text: "hello",
1949 }
1950
1951 gw.handleMessage(context.Background(), AdapterBinding{ID: "feishu-feishu", Platform: PlatformFeishu, Adapter: adapter}, msg)
1952
1953 sent := adapter.sentMessages()
1954 if len(sent) != 1 || !strings.Contains(sent[0].Text, "配对码") || !strings.Contains(sent[0].Text, "reasonix bot pairing approve") {
1955 t.Fatalf("sent = %#v, want pairing instructions", sent)
1956 }
1957 reqs, err := ListPairingRequests()
1958 if err != nil {
1959 t.Fatalf("list pairing: %v", err)
1960 }
1961 if len(reqs) != 1 || reqs[0].UserID != "user" || reqs[0].ChatID != "chat" {
1962 t.Fatalf("pairing requests = %+v, want one request for user/chat", reqs)
1963 }
1964 }
1965
1966 func TestGatewayAdminRoleRequiredForFullAccessWhenConfigured(t *testing.T) {
1967 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
1968 gw := NewGateway(GatewayConfig{
1969 Allowlist: AllowlistConfig{
1970 Enabled: true,
1971 Users: map[Platform][]string{PlatformWeixin: []string{"user"}},
1972 Admins: map[Platform][]string{PlatformWeixin: []string{"admin"}},
1973 },
1974 }, nil, logger)
1975 adapter := newFakeAdapter(PlatformWeixin, "fake-weixin")
1976 msg := InboundMessage{
1977 Platform: PlatformWeixin,
1978 ChatType: ChatDM,
1979 ChatID: "chat",
1980 UserID: "user",
1981 Text: "/mode danger-full-access",
1982 }
1983 key := BuildSessionKey(msg.Session())
1984
1985 gw.handleSlashCommand(context.Background(), adapter, key, msg)
1986
1987 sent := adapter.sentMessages()
1988 if len(sent) != 1 || !strings.Contains(sent[0].Text, "没有执行此 bot 命令的权限") {
1989 t.Fatalf("sent = %#v, want permission denial", sent)
1990 }
1991 }
1992
1993 func TestGatewayIgnoresOutboundEchoMessageID(t *testing.T) {
1994 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
1995 gw := NewGateway(GatewayConfig{
1996 IgnoreSelfMessages: true,
1997 Allowlist: AllowlistConfig{AllowAll: true},
1998 }, nil, logger)
1999 adapter := newFakeAdapter(PlatformFeishu, "fake-feishu")
2000 msg := InboundMessage{
2001 Platform: PlatformFeishu,
2002 ConnectionID: "feishu-feishu",
2003 ChatType: ChatDM,
2004 ChatID: "chat",
2005 UserID: "user",
2006 MessageID: "incoming",
2007 Text: "/status",
2008 }
2009 if err := gw.sendText(context.Background(), adapter, msg, "reply"); err != nil {
2010 t.Fatalf("sendText: %v", err)
2011 }
2012 echo := msg
2013 echo.MessageID = "fake_msg_1"
2014
2015 gw.handleMessage(context.Background(), AdapterBinding{ID: "feishu-feishu", Platform: PlatformFeishu, Adapter: adapter}, echo)
2016
2017 if sent := adapter.sentMessages(); len(sent) != 1 {
2018 t.Fatalf("sent count = %d, want only original outbound echo registration", len(sent))
2019 }
2020 }
2021
2022 func TestGatewayIgnoresConfiguredSelfUserID(t *testing.T) {
2023 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
2024 gw := NewGateway(GatewayConfig{
2025 IgnoreSelfMessages: true,
2026 SelfUserIDs: map[Platform][]string{PlatformWeixin: []string{"bot-user"}},
2027 Allowlist: AllowlistConfig{AllowAll: true},
2028 }, nil, logger)
2029 adapter := newFakeAdapter(PlatformWeixin, "fake-weixin")
2030 msg := InboundMessage{
2031 Platform: PlatformWeixin,
2032 ConnectionID: "weixin-weixin",
2033 ChatType: ChatDM,
2034 ChatID: "chat",
2035 UserID: "bot-user",
2036 MessageID: "self-message",
2037 Text: "/status",
2038 }
2039
2040 gw.handleMessage(context.Background(), AdapterBinding{ID: "weixin-weixin", Platform: PlatformWeixin, Adapter: adapter}, msg)
2041
2042 if sent := adapter.sentMessages(); len(sent) != 0 {
2043 t.Fatalf("sent count = %d, want self message ignored", len(sent))
2044 }
2045 }
2046
2047 func TestGatewayAdapterHealthTracksSend(t *testing.T) {
2048 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
2049 adapter := newFakeAdapter(PlatformFeishu, "fake-feishu")
2050 gw := NewGatewayWithAdapterBindings(GatewayConfig{
2051 Enabled: map[Platform]bool{PlatformFeishu: true},
2052 Allowlist: AllowlistConfig{AllowAll: true},
2053 }, []AdapterBinding{{ID: "feishu-lark", Platform: PlatformFeishu, Domain: "lark", Adapter: adapter}}, logger)
2054 if err := gw.Start(context.Background()); err != nil {
2055 t.Fatalf("Start: %v", err)
2056 }
2057 defer gw.Stop()
2058
2059 if _, err := gw.SendToAdapter(context.Background(), "feishu-lark", "lark", OutboundMessage{ChatID: "chat", ChatType: ChatDM, Text: "hello"}); err != nil {
2060 t.Fatalf("SendToAdapter: %v", err)
2061 }
2062
2063 health := gw.AdapterHealth()
2064 if len(health) != 1 {
2065 t.Fatalf("health count = %d, want 1", len(health))
2066 }
2067 if health[0].ID != "feishu-lark" || health[0].Status != "running" || health[0].Sends != 1 || health[0].SendErrors != 0 {
2068 t.Fatalf("health = %+v, want running send count", health[0])
2069 }
2070 }
2071
2072 func TestGatewayControlServerStatusAndSend(t *testing.T) {
2073 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
2074 adapter := newFakeAdapter(PlatformFeishu, "fake-feishu")
2075 gw := NewGatewayWithAdapterBindings(GatewayConfig{
2076 Enabled: map[Platform]bool{PlatformFeishu: true},
2077 Allowlist: AllowlistConfig{AllowAll: true},
2078 ControlEnabled: true,
2079 ControlAddr: "127.0.0.1:0",
2080 ControlToken: "secret",
2081 }, []AdapterBinding{{ID: "feishu-lark", Platform: PlatformFeishu, Domain: "lark", Adapter: adapter}}, logger)
2082 if err := gw.Start(context.Background()); err != nil {
2083 t.Fatalf("Start: %v", err)
2084 }
2085 defer gw.Stop()
2086
2087 statusURL := "http://" + gw.ControlAddr() + "/status"
2088 resp, err := http.Get(statusURL)
2089 if err != nil {
2090 t.Fatalf("GET /status without token: %v", err)
2091 }
2092 _ = resp.Body.Close()
2093 if resp.StatusCode != http.StatusUnauthorized {
2094 t.Fatalf("GET /status without token status = %d, want 401", resp.StatusCode)
2095 }
2096
2097 req, err := http.NewRequest(http.MethodGet, statusURL, nil)
2098 if err != nil {
2099 t.Fatalf("new status request: %v", err)
2100 }
2101 req.Header.Set("Authorization", "Bearer secret")
2102 resp, err = http.DefaultClient.Do(req)
2103 if err != nil {
2104 t.Fatalf("GET /status: %v", err)
2105 }
2106 defer resp.Body.Close()
2107 if resp.StatusCode != http.StatusOK {
2108 t.Fatalf("GET /status status = %d, want 200", resp.StatusCode)
2109 }
2110 var status controlStatusResponse
2111 if err := json.NewDecoder(resp.Body).Decode(&status); err != nil {
2112 t.Fatalf("decode status: %v", err)
2113 }
2114 if status.Status != "running" || len(status.Adapters) != 1 || status.Adapters[0].ID != "feishu-lark" {
2115 t.Fatalf("status = %+v, want running feishu-lark", status)
2116 }
2117
2118 req, err = http.NewRequest(http.MethodPost, "http://"+gw.ControlAddr()+"/send", strings.NewReader(`{"connection_id":"feishu-lark","domain":"lark","chat_id":"chat","chat_type":"dm","text":"hello"}`))
2119 if err != nil {
2120 t.Fatalf("new send request: %v", err)
2121 }
2122 req.Header.Set("Authorization", "Bearer secret")
2123 req.Header.Set("Content-Type", "application/json")
2124 resp, err = http.DefaultClient.Do(req)
2125 if err != nil {
2126 t.Fatalf("POST /send: %v", err)
2127 }
2128 _ = resp.Body.Close()
2129 if resp.StatusCode != http.StatusOK {
2130 t.Fatalf("POST /send status = %d, want 200", resp.StatusCode)
2131 }
2132 if sent := adapter.sentMessages(); len(sent) != 1 || sent[0].Text != "hello" {
2133 t.Fatalf("sent = %+v, want hello", sent)
2134 }
2135 if health := gw.AdapterHealth(); len(health) != 1 || health[0].Sends != 1 {
2136 t.Fatalf("health = %+v, want one send", health)
2137 }
2138
2139 req, err = http.NewRequest(http.MethodGet, "http://"+gw.ControlAddr()+"/metrics", nil)
2140 if err != nil {
2141 t.Fatalf("new metrics request: %v", err)
2142 }
2143 req.Header.Set("Authorization", "Bearer secret")
2144 resp, err = http.DefaultClient.Do(req)
2145 if err != nil {
2146 t.Fatalf("GET /metrics: %v", err)
2147 }
2148 metricsBody, _ := io.ReadAll(resp.Body)
2149 _ = resp.Body.Close()
2150 if resp.StatusCode != http.StatusOK || !strings.Contains(string(metricsBody), "reasonix_bot_adapter_sends_total") {
2151 t.Fatalf("GET /metrics status=%d body=%q, want adapter metrics", resp.StatusCode, string(metricsBody))
2152 }
2153 }
2154
2155 func TestControlSendReportsAndTracksPartialDelivery(t *testing.T) {
2156 adapter := &resultAdapter{
2157 fakeAdapter: newFakeAdapter(PlatformFeishu, "partial-feishu"),
2158 result: SendResult{
2159 MessageID: "media-2",
2160 MessageIDs: []string{"text-1", "media-1", "media-2"},
2161 },
2162 err: errors.New("media-3 failed"),
2163 }
2164 gw := NewGatewayWithAdapterBindings(GatewayConfig{
2165 IgnoreSelfMessages: true,
2166 }, []AdapterBinding{{ID: "feishu-lark", Platform: PlatformFeishu, Domain: "lark", Adapter: adapter}}, discardLogger())
2167 req := httptest.NewRequest(http.MethodPost, "/send", strings.NewReader(`{"connection_id":"feishu-lark","domain":"lark","chat_id":"chat","text":"hello"}`))
2168 recorder := httptest.NewRecorder()
2169
2170 gw.handleControlSend(recorder, req)
2171
2172 if recorder.Code != http.StatusMultiStatus {
2173 t.Fatalf("POST /send status = %d, want %d", recorder.Code, http.StatusMultiStatus)
2174 }
2175 var response controlSendResponse
2176 if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
2177 t.Fatalf("decode partial response: %v", err)
2178 }
2179 if !response.Partial || response.Error != "media-3 failed" || len(response.MessageIDs) != 3 {
2180 t.Fatalf("partial response = %+v", response)
2181 }
2182 for _, messageID := range response.MessageIDs {
2183 if !gw.isSelfMessage(InboundMessage{
2184 Platform: PlatformFeishu,
2185 ConnectionID: "feishu-lark",
2186 Domain: "lark",
2187 ChatID: "chat",
2188 MessageID: messageID,
2189 }) {
2190 t.Fatalf("delivered message %q was not registered for echo suppression", messageID)
2191 }
2192 }
2193 }
2194
2195 func TestGatewayAddsPendingReactionWhenAdapterSupportsIt(t *testing.T) {
2196 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
2197 gw := NewGateway(GatewayConfig{}, nil, logger)
2198 fa := &fakeReactionAdapter{fakeAdapter: newFakeAdapter(PlatformFeishu, "fake-feishu")}
2199
2200 gw.addPendingReaction(context.Background(), PlatformFeishu, fa, InboundMessage{MessageID: "om_123"})
2201
2202 if len(fa.reactions) != 1 || fa.reactions[0] != "om_123" {
2203 t.Fatalf("reactions = %#v, want [om_123]", fa.reactions)
2204 }
2205 }
2206
2207 func TestGatewayStoresQueuedReactionCleanupBeforeControllerExists(t *testing.T) {
2208 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
2209 gw := NewGateway(GatewayConfig{}, nil, logger)
2210 fa := &fakeReactionAdapter{fakeAdapter: newFakeAdapter(PlatformFeishu, "fake-feishu")}
2211 first := InboundMessage{
2212 Platform: PlatformFeishu,
2213 ConnectionID: "feishu-feishu",
2214 Domain: "feishu",
2215 ChatType: ChatDM,
2216 ChatID: "chat",
2217 UserID: "user",
2218 Text: "first",
2219 MessageID: "om_first",
2220 }
2221 key := BuildSessionKey(first.Session())
2222 if acquired, merged := gw.sessions.TryAcquire(key, first); !acquired || merged {
2223 t.Fatalf("first TryAcquire = (%v, %v), want acquired without merge", acquired, merged)
2224 }
2225
2226 queued := first
2227 queued.Text = "queued"
2228 queued.MessageID = "om_queued"
2229 cleanup := gw.addPendingReaction(context.Background(), PlatformFeishu, fa, queued)
2230 if acquired, merged := gw.sessions.TryAcquire(key, queued); acquired || !merged {
2231 t.Fatalf("queued TryAcquire = (%v, %v), want merged while active", acquired, merged)
2232 }
2233 gw.storeReactionCleanup(key, cleanup)
2234 if _, ok := gw.controllers[key]; ok {
2235 t.Fatal("test setup expected no controller state yet")
2236 }
2237 if cleaned := fa.cleanupMessages(); len(cleaned) != 0 {
2238 t.Fatalf("cleanup messages before flush = %#v, want none", cleaned)
2239 }
2240
2241 gw.flushReactionCleanups(key, nil)
2242 cleaned := fa.cleanupMessages()
2243 if len(cleaned) != 1 || cleaned[0] != "om_queued" {
2244 t.Fatalf("cleanup messages = %#v, want [om_queued]", cleaned)
2245 }
2246 }
2247
2248 func TestGatewaySessionOptionsUseChannelOverride(t *testing.T) {
2249 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
2250 gw := NewGateway(GatewayConfig{
2251 Model: "global-model",
2252 WorkspaceRoot: "/global",
2253 Channels: map[Platform]ChannelConfig{
2254 PlatformFeishu: {Model: "feishu-model", WorkspaceRoot: "/feishu"},
2255 PlatformWeixin: {WorkspaceRoot: "/weixin"},
2256 },
2257 }, nil, logger)
2258
2259 model, root, mode := gw.sessionOptionsForMessage(InboundMessage{Platform: PlatformFeishu})
2260 if model != "feishu-model" || root != "/feishu" {
2261 t.Fatalf("feishu options = %q,%q; want channel override", model, root)
2262 }
2263 if mode != control.ToolApprovalWorkspaceWrite {
2264 t.Fatalf("feishu tool approval mode = %q, want workspace-write", mode)
2265 }
2266
2267 model, root, mode = gw.sessionOptionsForMessage(InboundMessage{Platform: PlatformWeixin})
2268 if model != "global-model" || root != "/weixin" {
2269 t.Fatalf("weixin options = %q,%q; want global model and channel root", model, root)
2270 }
2271 if mode != control.ToolApprovalWorkspaceWrite {
2272 t.Fatalf("weixin tool approval mode = %q, want workspace-write", mode)
2273 }
2274
2275 model, root, mode = gw.sessionOptionsForMessage(InboundMessage{Platform: PlatformQQ})
2276 if model != "global-model" || root != "/global" {
2277 t.Fatalf("qq options = %q,%q; want global defaults", model, root)
2278 }
2279 if mode != control.ToolApprovalWorkspaceWrite {
2280 t.Fatalf("qq tool approval mode = %q, want workspace-write", mode)
2281 }
2282 }
2283
2284 func TestGatewaySessionOptionsPreferConnectionOverride(t *testing.T) {
2285 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
2286 gw := NewGateway(GatewayConfig{
2287 Model: "global-model",
2288 WorkspaceRoot: "/global",
2289 Channels: map[Platform]ChannelConfig{
2290 PlatformFeishu: {Model: "feishu-model", WorkspaceRoot: "/feishu"},
2291 },
2292 ConnectionChannels: map[string]ChannelConfig{
2293 "feishu-lark": {Model: "lark-model", WorkspaceRoot: "/lark"},
2294 },
2295 }, nil, logger)
2296
2297 model, root, mode := gw.sessionOptionsForMessage(InboundMessage{Platform: PlatformFeishu, ConnectionID: "feishu-lark"})
2298 if model != "lark-model" || root != "/lark" {
2299 t.Fatalf("lark options = %q,%q; want connection override", model, root)
2300 }
2301 if mode != control.ToolApprovalWorkspaceWrite {
2302 t.Fatalf("lark tool approval mode = %q, want workspace-write", mode)
2303 }
2304 }
2305
2306 func TestGatewaySessionOptionsPreferConnectionSessionMappingWorkspace(t *testing.T) {
2307 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
2308 gw := NewGateway(GatewayConfig{
2309 Model: "global-model",
2310 WorkspaceRoot: "/global",
2311 ConnectionChannels: map[string]ChannelConfig{
2312 "weixin-main": {
2313 WorkspaceRoot: "/connection",
2314 SessionMappings: []SessionMapping{
2315 {RemoteID: "group-1", ChatType: string(ChatGroup), UserID: "other", Scope: "project", WorkspaceRoot: "/other"},
2316 {RemoteID: "group-1", ChatType: string(ChatGroup), UserID: "user-1", Scope: "project", WorkspaceRoot: "/mapped"},
2317 },
2318 },
2319 },
2320 }, nil, logger)
2321
2322 model, root, mode := gw.sessionOptionsForMessage(InboundMessage{
2323 Platform: PlatformWeixin,
2324 ConnectionID: "weixin-main",
2325 ChatType: ChatGroup,
2326 ChatID: "group-1",
2327 UserID: "user-1",
2328 })
2329 if model != "global-model" || root != "/mapped" || mode != control.ToolApprovalWorkspaceWrite {
2330 t.Fatalf("mapped options = %q,%q,%q; want global model, mapped workspace, workspace-write", model, root, mode)
2331 }
2332
2333 _, root, _ = gw.sessionOptionsForMessage(InboundMessage{
2334 Platform: PlatformWeixin,
2335 ConnectionID: "weixin-main",
2336 ChatType: ChatGroup,
2337 ChatID: "group-1",
2338 UserID: "new-user",
2339 })
2340 if root != "/connection" {
2341 t.Fatalf("unmapped group user workspace = %q, want connection default", root)
2342 }
2343 }
2344
2345 func TestGatewaySessionOptionsAllowSessionMappingGlobalWorkspace(t *testing.T) {
2346 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
2347 gw := NewGateway(GatewayConfig{
2348 WorkspaceRoot: "/global-default",
2349 ConnectionChannels: map[string]ChannelConfig{
2350 "weixin-main": {
2351 WorkspaceRoot: "/connection",
2352 SessionMappings: []SessionMapping{{RemoteID: "dm-1", Scope: "global"}},
2353 },
2354 },
2355 }, nil, logger)
2356
2357 _, root, _ := gw.sessionOptionsForMessage(InboundMessage{
2358 Platform: PlatformWeixin,
2359 ConnectionID: "weixin-main",
2360 ChatType: ChatDM,
2361 ChatID: "dm-1",
2362 })
2363 if root != "" {
2364 t.Fatalf("global mapping workspace = %q, want empty global workspace", root)
2365 }
2366 }
2367
2368 func TestSessionStateMatchesRuntimeRejectsWorkspaceOrModelMismatch(t *testing.T) {
2369 ctrl := control.New(control.Options{WorkspaceRoot: "/old"})
2370 defer ctrl.Close()
2371 state := &sessionState{ctrl: ctrl, model: "model-a"}
2372
2373 if !sessionStateMatchesRuntime(state, sessionRuntimeProfile{model: "model-a", workspaceRoot: "/old"}) {
2374 t.Fatal("session should match the controller workspace and model")
2375 }
2376 if sessionStateMatchesRuntime(state, sessionRuntimeProfile{model: "model-a", workspaceRoot: "/new"}) {
2377 t.Fatal("session matched a different workspace root")
2378 }
2379 if sessionStateMatchesRuntime(state, sessionRuntimeProfile{model: "model-b", workspaceRoot: "/old"}) {
2380 t.Fatal("session matched a different model")
2381 }
2382 }
2383
2384 func TestSessionStateMatchesRuntimeRejectsAttachedSessionPathMismatch(t *testing.T) {
2385 root := t.TempDir()
2386 pathA := filepath.Join(root, "a.jsonl")
2387 pathB := filepath.Join(root, "b.jsonl")
2388 ctrl := control.New(control.Options{WorkspaceRoot: root, SessionPath: pathA})
2389 defer ctrl.Close()
2390 state := &sessionState{ctrl: ctrl, model: "model-a", workspaceRoot: root, sessionPath: pathA}
2391
2392 if !sessionStateMatchesRuntime(state, sessionRuntimeProfile{model: "model-a", workspaceRoot: root, sessionPath: pathA}) {
2393 t.Fatal("attached session should match the same pinned path")
2394 }
2395 if sessionStateMatchesRuntime(state, sessionRuntimeProfile{model: "model-a", workspaceRoot: root, sessionPath: pathB}) {
2396 t.Fatal("attached session matched a different path")
2397 }
2398 if sessionStateMatchesRuntime(state, sessionRuntimeProfile{model: "model-a", workspaceRoot: root}) {
2399 t.Fatal("attached session matched an unpinned profile")
2400 }
2401 }
2402
2403 func TestGatewaySessionOptionsPreferRemoteRouteOverride(t *testing.T) {
2404 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
2405 gw := NewGateway(GatewayConfig{
2406 Model: "global-model",
2407 WorkspaceRoot: "/global",
2408 ToolApprovalMode: "ask",
2409 ConnectionChannels: map[string]ChannelConfig{
2410 "feishu-lark": {Model: "lark-model", WorkspaceRoot: "/lark", ToolApprovalMode: "auto"},
2411 },
2412 Routes: []RouteConfig{{
2413 ConnectionID: "feishu-lark",
2414 ChatType: ChatGroup,
2415 ChatID: "group-1",
2416 Channel: ChannelConfig{Model: "route-model", WorkspaceRoot: "/route", ToolApprovalMode: "yolo"},
2417 }},
2418 }, nil, logger)
2419
2420 model, root, mode := gw.sessionOptionsForMessage(InboundMessage{Platform: PlatformFeishu, ConnectionID: "feishu-lark", ChatType: ChatGroup, ChatID: "group-1"})
2421 if model != "route-model" || root != "/route" || mode != control.ToolApprovalWorkspaceWrite {
2422 t.Fatalf("route options = %q,%q,%q; want route override", model, root, mode)
2423 }
2424 model, root, mode = gw.sessionOptionsForMessage(InboundMessage{Platform: PlatformFeishu, ConnectionID: "feishu-lark", ChatType: ChatGroup, ChatID: "group-2"})
2425 if model != "lark-model" || root != "/lark" || mode != control.ToolApprovalWorkspaceWrite {
2426 t.Fatalf("non-matching options = %q,%q,%q; want connection override", model, root, mode)
2427 }
2428 }
2429
2430 func TestBotSessionDirUsesProjectWorkspaceRoot(t *testing.T) {
2431 root := t.TempDir()
2432 got := botSessionDir(root)
2433 if got == "" || got == botSessionDir("") {
2434 t.Fatalf("project session dir = %q, want project-specific dir", got)
2435 }
2436 }
2437
2438 // A persisted session_mappings binding must resolve to the mapped session file
2439 // at session-profile time — before this, the binding was display-only and every
2440 // gateway restart opened a fresh session for the chat (#6917, #6934).
2441 func TestSessionProfileConsumesPersistedMapping(t *testing.T) {
2442 dir := t.TempDir()
2443 mapped := filepath.Join(dir, "chat.jsonl")
2444 if err := os.WriteFile(mapped, []byte(`{"role":"user","content":"hi"}`+"\n"), 0o644); err != nil {
2445 t.Fatal(err)
2446 }
2447 gw := &BotGateway{
2448 cfg: GatewayConfig{
2449 ConnectionChannels: map[string]ChannelConfig{
2450 "conn-1": {SessionMappings: []SessionMapping{{
2451 RemoteID: "chat-42",
2452 SessionID: "path:" + mapped,
2453 }}},
2454 },
2455 },
2456 logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
2457 sessionOverrides: map[string]sessionRuntimeOverride{},
2458 }
2459 msg := InboundMessage{ConnectionID: "conn-1", ChatID: "chat-42", ChatType: ChatDM}
2460
2461 profile := gw.sessionProfileForMessage(msg)
2462 if canonicalBotPath(profile.sessionPath) != canonicalBotPath(mapped) {
2463 t.Fatalf("profile.sessionPath = %q, want mapped %q", profile.sessionPath, mapped)
2464 }
2465 if !profile.sessionPathOptional {
2466 t.Fatal("mapping-derived path must be optional (degradable), not an attach-style hard binding")
2467 }
2468
2469 // A missing legacy target falls back to the deterministic per-chat v3
2470 // identity instead of manufacturing another transcript path.
2471 if err := os.Remove(mapped); err != nil {
2472 t.Fatal(err)
2473 }
2474 profile = gw.sessionProfileForMessage(msg)
2475 if profile.sessionRef.SessionID == "" || profile.sessionPath != "" {
2476 t.Fatalf("missing mapped file fallback = ref %+v path %q", profile.sessionRef, profile.sessionPath)
2477 }
2478 if !profile.sessionRefOptional {
2479 t.Fatal("stable v3 fallback must stay optional (degradable)")
2480 }
2481
2482 // An /attach override outranks the mapping.
2483 gw.sessionOverrides[BuildSessionKey(msg.Session())] = sessionRuntimeOverride{sessionPath: filepath.Join(dir, "attached.jsonl")}
2484 profile = gw.sessionProfileForMessage(msg)
2485 if profile.sessionPathOptional {
2486 t.Fatal("attach override must stay a hard binding")
2487 }
2488 }
2489
2490 // Without any persisted mapping or /attach binding, the session profile must
2491 // resolve to a deterministic per-chat v3 identity so the same chat reuses one
2492 // conversation across restarts instead of spawning a fresh session
2493 // per message (the chat-side analogue of dsh-dingtalk-channel's `ding-<chatId>`).
2494 func TestSessionProfileFallsBackToStableChatPath(t *testing.T) {
2495 dir := t.TempDir()
2496 gw := &BotGateway{
2497 cfg: GatewayConfig{
2498 WorkspaceRoot: dir,
2499 Channels: map[Platform]ChannelConfig{},
2500 ConnectionChannels: map[string]ChannelConfig{
2501 "conn-1": {WorkspaceRoot: dir},
2502 },
2503 },
2504 logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
2505 sessionOverrides: map[string]sessionRuntimeOverride{},
2506 }
2507 dm := InboundMessage{Platform: PlatformFeishu, ConnectionID: "conn-1", ChatID: "oc_abc", ChatType: ChatDM}
2508 dmProfile := gw.sessionProfileForMessage(dm)
2509 if dmProfile.sessionRef.SessionID == "" || dmProfile.sessionPath != "" {
2510 t.Fatalf("DM without a mapping resolved to ref %+v path %q", dmProfile.sessionRef, dmProfile.sessionPath)
2511 }
2512 if !dmProfile.sessionRefOptional {
2513 t.Fatal("stable fallback identity must be optional (degradable), like a mapping")
2514 }
2515 // Same chat, repeated messages: identical identity.
2516 if again := gw.sessionProfileForMessage(dm); again.sessionRef.SessionID != dmProfile.sessionRef.SessionID {
2517 t.Fatalf("same DM chat must map to one stable identity: first=%q second=%q", dmProfile.sessionRef.SessionID, again.sessionRef.SessionID)
2518 }
2519 // Different chat: different identity.
2520 other := InboundMessage{Platform: PlatformFeishu, ConnectionID: "conn-1", ChatID: "oc_xyz", ChatType: ChatDM}
2521 otherProfile := gw.sessionProfileForMessage(other)
2522 if otherProfile.sessionRef.SessionID == dmProfile.sessionRef.SessionID {
2523 t.Fatalf("different chats must map to different identities, both=%q", otherProfile.sessionRef.SessionID)
2524 }
2525 // Group chat scopes per sender, mirroring BuildSessionKey.
2526 groupA := InboundMessage{Platform: PlatformFeishu, ConnectionID: "conn-1", ChatID: "oc_grp", ChatType: ChatGroup, UserID: "ou_a"}
2527 groupB := InboundMessage{Platform: PlatformFeishu, ConnectionID: "conn-1", ChatID: "oc_grp", ChatType: ChatGroup, UserID: "ou_b"}
2528 pa := gw.sessionProfileForMessage(groupA)
2529 pb := gw.sessionProfileForMessage(groupB)
2530 if pa.sessionRef.SessionID == "" || pb.sessionRef.SessionID == "" {
2531 t.Fatal("group messages must resolve to stable session identities")
2532 }
2533 if pa.sessionRef.SessionID == pb.sessionRef.SessionID {
2534 t.Fatalf("different group senders must map to different identities, both=%q", pa.sessionRef.SessionID)
2535 }
2536 if again := gw.sessionProfileForMessage(groupA); again.sessionRef.SessionID != pa.sessionRef.SessionID {
2537 t.Fatalf("same group sender must map to one stable identity: first=%q second=%q", pa.sessionRef.SessionID, again.sessionRef.SessionID)
2538 }
2539 if !strings.HasPrefix(dmProfile.sessionRef.SessionID, "bot-") {
2540 t.Fatalf("stable identity should be bot-scoped, got %q", dmProfile.sessionRef.SessionID)
2541 }
2542 }
2543
2544 // A state that degraded off its unavailable mapped session must not be torn
2545 // down by the next message re-resolving the mapping — that would spawn a new
2546 // session file per message.
2547 func TestDegradedMappingStateStaysStable(t *testing.T) {
2548 s := &sessionState{mappingDegraded: true, sessionPath: "", ctrl: stubPathController{}}
2549 profile := sessionRuntimeProfile{sessionPath: "/some/mapped.jsonl", sessionPathOptional: true}
2550 if !sessionStateMatchesRuntime(s, profile) {
2551 t.Fatal("degraded state must keep matching an optional mapped profile")
2552 }
2553 hard := sessionRuntimeProfile{sessionPath: "/some/mapped.jsonl"}
2554 if sessionStateMatchesRuntime(s, hard) {
2555 t.Fatal("an explicit attach must still force a rebuild")
2556 }
2557 }
2558
2559 type stubPathController struct{ stubBotController }
2560
2561 func (stubPathController) SessionPath() string { return "" }
2562 func (stubPathController) WorkspaceRoot() string { return "" }
2563
2564 // testSenderAdapter 是实现了 TestSender 的假适配器,用于 TestSendToAdapter 测试。
2565 type testSenderAdapter struct {
2566 *fakeAdapter
2567 gotText string
2568 }
2569
2570 func (a *testSenderAdapter) TestSend(_ context.Context, text string) (SendResult, error) {
2571 a.gotText = text
2572 return SendResult{MessageID: "test-1"}, nil
2573 }
2574
2575 func TestGatewayTestSendToAdapter(t *testing.T) {
2576 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
2577 ts := &testSenderAdapter{fakeAdapter: newFakeAdapter(PlatformDingtalk, "dingtalk")}
2578 gw := NewGatewayWithAdapterBindings(GatewayConfig{}, []AdapterBinding{{
2579 ID: "dingtalk",
2580 Domain: "dingtalk",
2581 Platform: PlatformDingtalk,
2582 Adapter: ts,
2583 }}, logger)
2584
2585 result, err := gw.TestSendToAdapter(context.Background(), "dingtalk", "dingtalk", "hello")
2586 if err != nil {
2587 t.Fatalf("TestSendToAdapter: %v", err)
2588 }
2589 if result.MessageID != "test-1" {
2590 t.Fatalf("message id = %q, want test-1", result.MessageID)
2591 }
2592 if ts.gotText != "hello" {
2593 t.Fatalf("test text = %q, want hello", ts.gotText)
2594 }
2595
2596 if _, err := gw.TestSendToAdapter(context.Background(), "missing", "", "hi"); err == nil {
2597 t.Fatal("TestSendToAdapter for unknown conn should fail")
2598 }
2599 }
2600
2601 func TestGatewayTestSendToAdapterUnsupported(t *testing.T) {
2602 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
2603 gw := NewGatewayWithAdapterBindings(GatewayConfig{}, []AdapterBinding{{
2604 ID: "feishu-lark",
2605 Domain: "lark",
2606 Platform: PlatformFeishu,
2607 Adapter: newFakeAdapter(PlatformFeishu, "feishu"),
2608 }}, logger)
2609 if _, err := gw.TestSendToAdapter(context.Background(), "feishu-lark", "lark", "hi"); err == nil {
2610 t.Fatal("TestSendToAdapter on non-TestSender adapter should fail")
2611 }
2612 }
2613
2614 func TestParseModelSelector(t *testing.T) {
2615 cases := []struct {
2616 text string
2617 model string
2618 provider string
2619 statusOnly bool
2620 ok bool
2621 }{
2622 {"/model", "", "", true, true},
2623 {"/model deepseek-v4-flash", "deepseek-v4-flash", "", false, true},
2624 {"/model deepseek-v4-flash --provider deepseek", "deepseek-v4-flash", "deepseek", false, true},
2625 {"/model deepseek-v4-flash -p deepseek", "deepseek-v4-flash", "deepseek", false, true},
2626 {"/model --provider deepseek deepseek-v4-flash", "deepseek-v4-flash", "deepseek", false, true},
2627 {"/model deepseek-v4-flash --provider 17an", "deepseek-v4-flash", "17an", false, true},
2628 {"not a model command", "", "", false, false},
2629 {"/model --provider deepseek", "", "deepseek", false, true},
2630 }
2631 for _, c := range cases {
2632 model, provider, statusOnly, ok := parseModelSelector(c.text)
2633 if model != c.model || provider != c.provider || statusOnly != c.statusOnly || ok != c.ok {
2634 t.Errorf("parseModelSelector(%q) = (%q, %q, %v, %v), want (%q, %q, %v, %v)",
2635 c.text, model, provider, statusOnly, ok, c.model, c.provider, c.statusOnly, c.ok)
2636 }
2637 }
2638 }
2639
2640 func TestHandleModelCommand(t *testing.T) {
2641 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
2642 gw := NewGatewayWithAdapterBindings(GatewayConfig{Model: "deepseek/deepseek-v4-flash"}, []AdapterBinding{}, logger)
2643 msg := InboundMessage{Platform: PlatformWeixin, ConnectionID: "weixin-weixin", Domain: "weixin", ChatType: ChatDM, ChatID: "chat", UserID: "user"}
2644 key := BuildSessionKey(msg.Session())
2645 overrideModel := func() string { gw.mu.Lock(); defer gw.mu.Unlock(); return gw.sessionOverrides[key].channel.Model }
2646 if got := gw.handleModelCommand(context.Background(), msg, "/model"); !strings.Contains(got, "deepseek-v4-flash") {
2647 t.Fatalf("/model status = %q, want global default", got)
2648 }
2649 gw.mu.Lock()
2650 gw.cfg.Channels = map[Platform]ChannelConfig{PlatformWeixin: {Model: "17an/deepseek-v4-flash"}}
2651 gw.mu.Unlock()
2652 if got := gw.handleModelCommand(context.Background(), msg, "/model"); !strings.Contains(got, "17an/deepseek-v4-flash") {
2653 t.Fatalf("/model status with channel model = %q, want channel model", got)
2654 }
2655 if got := gw.handleModelCommand(context.Background(), msg, "/model deepseek-v4-pro"); !strings.Contains(got, "deepseek-v4-pro") {
2656 t.Fatalf("/model switch = %q", got)
2657 }
2658 if m := overrideModel(); m != "deepseek-v4-pro" {
2659 t.Fatalf("override model = %q, want deepseek-v4-pro", m)
2660 }
2661 if got := gw.handleModelCommand(context.Background(), msg, "/model deepseek-v4-flash --provider 17an"); !strings.Contains(got, "17an") || !strings.Contains(got, "deepseek-v4-flash") {
2662 t.Fatalf("/model with provider = %q", got)
2663 }
2664 if m := overrideModel(); m != "17an/deepseek-v4-flash" {
2665 t.Fatalf("override model = %q, want 17an/deepseek-v4-flash", m)
2666 }
2667 if got := gw.handleModelCommand(context.Background(), msg, "/model --provider deepseek"); !strings.Contains(got, "用法") {
2668 t.Fatalf("provider-only /model = %q, want usage message", got)
2669 }
2670 if m := overrideModel(); m != "17an/deepseek-v4-flash" {
2671 t.Fatalf("provider-only /model mutated override to %q, want unchanged", m)
2672 }
2673 }
2674
2675 // TestHandleModelCommandRejectsUnresolvableModel: /model 切换前必须预校验
2676 // 模型;无效模型直接拒绝且不写入覆盖(失败原子性,保留当前 controller)。
2677 func TestHandleModelCommandRejectsUnresolvableModel(t *testing.T) {
2678 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
2679 gw := NewGatewayWithAdapterBindings(GatewayConfig{
2680 Model: "deepseek/deepseek-v4-flash",
2681 ModelResolver: func(ref string) error {
2682 if strings.TrimSpace(ref) == "deepseek-v4-flash" {
2683 return nil
2684 }
2685 return fmt.Errorf("未配置该模型(provider/model 不存在)")
2686 },
2687 }, []AdapterBinding{}, logger)
2688 msg := InboundMessage{Platform: PlatformWeixin, ConnectionID: "weixin-weixin", Domain: "weixin", ChatType: ChatDM, ChatID: "chat", UserID: "user"}
2689 key := BuildSessionKey(msg.Session())
2690 overrideModel := func() string { gw.mu.Lock(); defer gw.mu.Unlock(); return gw.sessionOverrides[key].channel.Model }
2691
2692 // 有效模型 → 写入覆盖。
2693 if got := gw.handleModelCommand(context.Background(), msg, "/model deepseek-v4-flash"); !strings.Contains(got, "deepseek-v4-flash") {
2694 t.Fatalf("/model switch = %q, want success", got)
2695 }
2696 if m := overrideModel(); m != "deepseek-v4-flash" {
2697 t.Fatalf("override model = %q, want deepseek-v4-flash", m)
2698 }
2699 // 无效模型 → 拒绝且 override 保持原值(controller 未被销毁)。
2700 if got := gw.handleModelCommand(context.Background(), msg, "/model nope-model"); !strings.Contains(got, "不可用") {
2701 t.Fatalf("/model invalid = %q, want rejection message", got)
2702 }
2703 if m := overrideModel(); m != "deepseek-v4-flash" {
2704 t.Fatalf("invalid /model mutated override to %q, want unchanged", m)
2705 }
2706 }
2707
2707 lines GO