返回 DeepSeek-Reasonix
bot_bridge_test.go
根目录 / desktop / bot_bridge_test.go
1 package main
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7 "io"
8 "log/slog"
9 "runtime"
10 "strings"
11 "testing"
12 "time"
13
14 "reasonix/internal/bot"
15 "reasonix/internal/event"
16 )
17
18 type bridgeNotifyCall struct {
19 connectionID string
20 domain string
21 msg bot.OutboundMessage
22 }
23
24 type bridgeTestEnv struct {
25 hub *botBridgeHub
26 notified chan bridgeNotifyCall
27 approves chan [2]string // [tabID, id+":"+allow]
28 answers chan [2]string // [tabID, id]
29 driven chan [2]string // [tabID, text]
30 announced chan [2]string // [tabID, text]
31 persisted chan []bot.DesktopWatchRoute
32 driveErr error
33 persistErr error
34 }
35
36 func tabsToSessions(tabs []TabMeta) []bot.DesktopSessionInfo {
37 out := make([]bot.DesktopSessionInfo, 0, len(tabs))
38 for _, t := range tabs {
39 out = append(out, bot.DesktopSessionInfo{
40 TabID: t.ID,
41 Label: t.Label,
42 Workspace: t.WorkspaceName,
43 Topic: t.TopicTitle,
44 Ready: t.Ready,
45 Running: t.Running,
46 PendingPrompt: t.PendingPrompt,
47 })
48 }
49 return out
50 }
51
52 func newBridgeTestEnv(tabs []TabMeta) *bridgeTestEnv {
53 return newBridgeTestEnvSessions(tabsToSessions(tabs))
54 }
55
56 func newBridgeTestEnvSessions(sessions []bot.DesktopSessionInfo) *bridgeTestEnv {
57 env := &bridgeTestEnv{
58 notified: make(chan bridgeNotifyCall, 16),
59 approves: make(chan [2]string, 16),
60 answers: make(chan [2]string, 16),
61 driven: make(chan [2]string, 16),
62 announced: make(chan [2]string, 16),
63 persisted: make(chan []bot.DesktopWatchRoute, 16),
64 }
65 env.hub = newBotBridgeHub(botBridgeDeps{
66 sessions: func() []bot.DesktopSessionInfo { return sessions },
67 approveTab: func(tabID, id string, allow, session, persist bool) {
68 env.approves <- [2]string{tabID, fmt.Sprintf("%s:%t", id, allow)}
69 },
70 answerTab: func(tabID, id string, answers []QuestionAnswer) {
71 env.answers <- [2]string{tabID, id}
72 },
73 notify: func(ctx context.Context, connectionID, domain string, msg bot.OutboundMessage) (bot.SendResult, error) {
74 env.notified <- bridgeNotifyCall{connectionID: connectionID, domain: domain, msg: msg}
75 return bot.SendResult{MessageID: "sent-1"}, nil
76 },
77 drive: func(tabID, text string, route bot.DesktopWatchRoute) error {
78 if env.driveErr != nil {
79 return env.driveErr
80 }
81 env.driven <- [2]string{tabID, text}
82 return nil
83 },
84 announce: func(tabID, text string) {
85 env.announced <- [2]string{tabID, text}
86 },
87 persistWatchers: func(routes []bot.DesktopWatchRoute) error {
88 env.persisted <- routes
89 return env.persistErr
90 },
91 logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
92 })
93 return env
94 }
95
96 func (env *bridgeTestEnv) waitNotification(t *testing.T) bridgeNotifyCall {
97 t.Helper()
98 select {
99 case call := <-env.notified:
100 return call
101 case <-time.After(2 * time.Second):
102 t.Fatal("timed out waiting for bridge notification")
103 return bridgeNotifyCall{}
104 }
105 }
106
107 func (env *bridgeTestEnv) expectNoNotification(t *testing.T) {
108 t.Helper()
109 select {
110 case call := <-env.notified:
111 t.Fatalf("unexpected notification: %+v", call)
112 case <-time.After(100 * time.Millisecond):
113 }
114 }
115
116 func testWatchRoute() bot.DesktopWatchRoute {
117 return bot.DesktopWatchRoute{
118 ConnectionID: "feishu-main",
119 Domain: "feishu",
120 Platform: bot.PlatformFeishu,
121 ChatType: bot.ChatDM,
122 ChatID: "chat-god",
123 }
124 }
125
126 func testGroupRoute() bot.DesktopWatchRoute {
127 r := testWatchRoute()
128 r.ChatType = bot.ChatGroup
129 r.ChatID = "group-god"
130 return r
131 }
132
133 // newBotBridgeHub 启动一个 run worker;Close 必须让它退出,否则每个 App 实例
134 // 都泄漏一个 goroutine(转储曾观测到上千个)。enqueue 在 Close 后仍不得 panic:
135 // observe 可能在 controller 事件 goroutine 上与关停并发。
136 func TestBridgeCloseStopsRunGoroutine(t *testing.T) {
137 before := runtime.NumGoroutine()
138 env := newBridgeTestEnv(nil)
139 env.hub.SetWatch(testWatchRoute(), true)
140 env.hub.Close()
141 env.hub.Close() // 幂等
142 // 有订阅者时 observe 会真正 enqueue;Close 后不得 panic。
143 env.hub.observe("tab-1", event.Event{Kind: event.TurnDone})
144
145 deadline := time.Now().Add(2 * time.Second)
146 for time.Now().Before(deadline) {
147 if runtime.NumGoroutine() <= before {
148 return
149 }
150 time.Sleep(10 * time.Millisecond)
151 }
152 t.Fatalf("hub run goroutine still alive after Close: %d goroutines > baseline %d", runtime.NumGoroutine(), before)
153 }
154
155 func TestBridgeTakeoverRejectsGroupChat(t *testing.T) {
156 env := newBridgeTestEnvSessions([]bot.DesktopSessionInfo{{TabID: "tab-1", Label: "会话一", Ready: true}})
157 if _, err := env.hub.Takeover(testGroupRoute(), "tab-1"); err == nil {
158 t.Fatal("takeover from a group chat must be rejected (non-admin members could otherwise drive it)")
159 }
160 if _, err := env.hub.Takeover(testWatchRoute(), "tab-1"); err != nil {
161 t.Fatalf("DM takeover should work: %v", err)
162 }
163 }
164
165 func TestBridgeTakeoverSwitchAnnouncesReleaseToOldTab(t *testing.T) {
166 env := newBridgeTestEnvSessions([]bot.DesktopSessionInfo{
167 {TabID: "tab-a", Label: "A", Ready: true},
168 {TabID: "tab-b", Label: "B", Ready: true},
169 })
170 route := testWatchRoute()
171 if _, err := env.hub.Takeover(route, "tab-a"); err != nil {
172 t.Fatalf("takeover A: %v", err)
173 }
174 if got := <-env.announced; got[0] != "tab-a" {
175 t.Fatalf("first announce = %v, want tab-a takeover", got)
176 }
177 if _, err := env.hub.Takeover(route, "tab-b"); err != nil {
178 t.Fatalf("switch to B: %v", err)
179 }
180 seen := map[string]bool{}
181 for range 2 {
182 select {
183 case got := <-env.announced:
184 seen[got[0]] = true
185 case <-time.After(time.Second):
186 t.Fatalf("missing announce after switch; seen=%v", seen)
187 }
188 }
189 if !seen["tab-a"] || !seen["tab-b"] {
190 t.Fatalf("switch should announce release to tab-a and takeover to tab-b; seen=%v", seen)
191 }
192 }
193
194 func TestBridgeDriveInputBusyReturnsBusyMessage(t *testing.T) {
195 env := newBridgeTestEnvSessions([]bot.DesktopSessionInfo{{TabID: "tab-1", Label: "会话一", Ready: true}})
196 route := testWatchRoute()
197 if _, err := env.hub.Takeover(route, "tab-1"); err != nil {
198 t.Fatalf("Takeover: %v", err)
199 }
200 <-env.announced
201 env.driveErr = errDriveBusy
202 _, err := env.hub.DriveInput(route, "hi")
203 if err == nil || !strings.Contains(err.Error(), "正在执行中") {
204 t.Fatalf("busy drive should surface a clean busy message, got %v", err)
205 }
206 }
207
208 func TestBridgeApprovalRedactsSubjectInGroup(t *testing.T) {
209 env := newBridgeTestEnvSessions([]bot.DesktopSessionInfo{{TabID: "tab-1", Label: "会话一"}})
210 env.hub.SetWatch(testGroupRoute(), true)
211 <-env.persisted
212 env.hub.observe("tab-1", event.Event{Kind: event.ApprovalRequest, Approval: event.Approval{ID: "a1", Tool: "bash", Subject: "rm -rf /secret"}})
213 call := env.waitNotification(t)
214 if strings.Contains(call.msg.Text, "rm -rf /secret") {
215 t.Fatalf("group notification leaked the command line: %q", call.msg.Text)
216 }
217 if call.msg.Card != nil {
218 for _, el := range call.msg.Card.Elements {
219 if strings.Contains(el.Content, "rm -rf /secret") {
220 t.Fatal("group card leaked the command line")
221 }
222 }
223 }
224 }
225
226 func TestBridgeApprovalShowsSubjectInDM(t *testing.T) {
227 env := newBridgeTestEnvSessions([]bot.DesktopSessionInfo{{TabID: "tab-1", Label: "会话一"}})
228 env.hub.SetWatch(testWatchRoute(), true)
229 <-env.persisted
230 env.hub.observe("tab-1", event.Event{Kind: event.ApprovalRequest, Approval: event.Approval{ID: "a1", Tool: "bash", Subject: "rm -rf build"}})
231 call := env.waitNotification(t)
232 if !strings.Contains(call.msg.Text, "rm -rf build") {
233 t.Fatalf("DM notification should show the command line: %q", call.msg.Text)
234 }
235 }
236
237 func TestBridgeAskRedactsPromptAndOptionsInGroup(t *testing.T) {
238 env := newBridgeTestEnvSessions([]bot.DesktopSessionInfo{{TabID: "tab-1", Label: "会话一"}})
239 if err := env.hub.SetWatch(testGroupRoute(), true); err != nil {
240 t.Fatalf("SetWatch: %v", err)
241 }
242 <-env.persisted
243 env.hub.observe("tab-1", event.Event{Kind: event.AskRequest, Ask: event.Ask{
244 ID: "redacted-ask",
245 Questions: []event.AskQuestion{{
246 ID: "q1", Prompt: "INTERNAL_ONLY_PROMPT", Options: []event.AskOption{{Label: "CHOICE_INTERNAL"}},
247 }},
248 }})
249 call := env.waitNotification(t)
250 if strings.Contains(call.msg.Text, "INTERNAL_ONLY_PROMPT") || strings.Contains(call.msg.Text, "CHOICE_INTERNAL") {
251 t.Fatalf("group notification leaked ask details: %q", call.msg.Text)
252 }
253 if call.msg.Card != nil {
254 t.Fatal("group ask notification must not include option buttons")
255 }
256 }
257
258 func TestBridgeSessionsIncludePendingIDs(t *testing.T) {
259 env := newBridgeTestEnvSessions([]bot.DesktopSessionInfo{{TabID: "tab-1", Label: "会话一"}})
260 env.hub.observe("tab-1", event.Event{Kind: event.ApprovalRequest, Approval: event.Approval{ID: "a1", Tool: "bash"}})
261 env.hub.observe("tab-1", event.Event{Kind: event.AskRequest, Ask: event.Ask{ID: "q1", Questions: []event.AskQuestion{{ID: "x", Prompt: "?"}}}})
262 found := map[string]bool{}
263 for _, s := range env.hub.Sessions() {
264 if s.TabID != "tab-1" {
265 continue
266 }
267 for _, p := range s.Pending {
268 found[p.ID] = true
269 }
270 }
271 if !found["a1"] || !found["q1"] {
272 t.Fatalf("Sessions() should surface pending approval and ask ids; got %v", found)
273 }
274 }
275
276 func TestBridgePersistDropsStaleSnapshot(t *testing.T) {
277 env := newBridgeTestEnvSessions(nil)
278 // Two subscribes: the second (newer seq) must be the persisted result even
279 // though we invoke the seed-restore afterward.
280 env.hub.SetWatch(testWatchRoute(), true)
281 <-env.persisted
282 env.hub.SetWatch(testGroupRoute(), true)
283 routes := <-env.persisted
284 if len(routes) != 2 {
285 t.Fatalf("persisted routes = %d, want both subscriptions", len(routes))
286 }
287 }
288
289 func TestBridgeApprovalNotifiesWatchersAndRoutesApproval(t *testing.T) {
290 env := newBridgeTestEnv([]TabMeta{{ID: "tab-1", Label: "修复登录"}})
291 env.hub.SetWatch(testWatchRoute(), true)
292
293 env.hub.observe("tab-1", event.Event{Kind: event.ApprovalRequest, Approval: event.Approval{
294 ID: "appr-1", Tool: "bash", Subject: "rm -rf build",
295 }})
296
297 call := env.waitNotification(t)
298 if call.connectionID != "feishu-main" || call.msg.ChatID != "chat-god" {
299 t.Fatalf("notification routed to %s/%s, want feishu-main/chat-god", call.connectionID, call.msg.ChatID)
300 }
301 for _, want := range []string{"修复登录", "bash", "rm -rf build", "/desktop approve appr-1"} {
302 if !strings.Contains(call.msg.Text, want) {
303 t.Fatalf("notification text = %q, want it to contain %q", call.msg.Text, want)
304 }
305 }
306 if call.msg.Card == nil {
307 t.Fatal("approval notification should carry an interactive card")
308 }
309
310 feedback, err := env.hub.Approve("appr-1", true)
311 if err != nil {
312 t.Fatalf("Approve: %v", err)
313 }
314 if !strings.Contains(feedback, "先到者为准") {
315 t.Fatalf("feedback = %q, want first-wins note", feedback)
316 }
317 select {
318 case got := <-env.approves:
319 if got[0] != "tab-1" || got[1] != "appr-1:true" {
320 t.Fatalf("approve routed as %v, want tab-1/appr-1:true", got)
321 }
322 case <-time.After(time.Second):
323 t.Fatal("approve was not routed to the tab")
324 }
325
326 // 同一 ID 第二次应答:pending 已清,返回未找到。
327 if _, err := env.hub.Approve("appr-1", false); err == nil {
328 t.Fatal("second Approve on the same id should fail")
329 }
330 }
331
332 // The desktop replays a waiting prompt whenever it reconciles a tab. A replay
333 // rebuilds a card; it is not a new request, so a watcher is pinged once (#9156).
334 func TestBridgeReplayedApprovalPushesOnce(t *testing.T) {
335 env := newBridgeTestEnv([]TabMeta{{ID: "tab-1", Label: "会话"}})
336 env.hub.SetWatch(testWatchRoute(), true)
337 approval := event.Approval{ID: "appr-9", Tool: "bash", Subject: "go test ./..."}
338
339 env.hub.observe("tab-1", event.Event{Kind: event.ApprovalRequest, Approval: approval})
340 env.waitNotification(t)
341 env.hub.observe("tab-1", event.Event{Kind: event.ApprovalRequest, Approval: approval, Replayed: true})
342 env.expectNoNotification(t)
343
344 if _, err := env.hub.Approve("appr-9", true); err != nil {
345 t.Fatalf("Approve after a replay: %v", err)
346 }
347 }
348
349 func TestBridgePendingRecordedWithoutWatchers(t *testing.T) {
350 env := newBridgeTestEnv([]TabMeta{{ID: "tab-1", Label: "会话"}})
351
352 env.hub.observe("tab-1", event.Event{Kind: event.ApprovalRequest, Approval: event.Approval{ID: "appr-2", Tool: "bash"}})
353 env.expectNoNotification(t)
354
355 if _, err := env.hub.Approve("appr-2", false); err != nil {
356 t.Fatalf("Approve without watchers should still work: %v", err)
357 }
358 select {
359 case got := <-env.approves:
360 if got[1] != "appr-2:false" {
361 t.Fatalf("deny routed as %v", got)
362 }
363 case <-time.After(time.Second):
364 t.Fatal("deny was not routed")
365 }
366 }
367
368 func TestBridgeTurnDoneClearsPendingAndNotifies(t *testing.T) {
369 env := newBridgeTestEnv([]TabMeta{{ID: "tab-1", Label: "会话一"}})
370 env.hub.SetWatch(testWatchRoute(), true)
371
372 env.hub.observe("tab-1", event.Event{Kind: event.ApprovalRequest, Approval: event.Approval{ID: "appr-3", Tool: "bash"}})
373 env.waitNotification(t)
374
375 env.hub.observe("tab-1", event.Event{Kind: event.TurnDone})
376 call := env.waitNotification(t)
377 if !strings.Contains(call.msg.Text, "✅") || !strings.Contains(call.msg.Text, "会话一") {
378 t.Fatalf("turn-done text = %q", call.msg.Text)
379 }
380
381 if _, err := env.hub.Approve("appr-3", true); err == nil {
382 t.Fatal("pending approval should be cleared by TurnDone")
383 }
384 }
385
386 func TestBridgeSuppressesCanceledTurnAndErrorsNotify(t *testing.T) {
387 env := newBridgeTestEnv([]TabMeta{{ID: "tab-1", Label: "会话一"}})
388 env.hub.SetWatch(testWatchRoute(), true)
389
390 env.hub.observe("tab-1", event.Event{Kind: event.TurnDone, Err: errors.New("context canceled")})
391 env.expectNoNotification(t)
392
393 env.hub.observe("tab-1", event.Event{Kind: event.TurnDone, Err: errors.New("boom")})
394 call := env.waitNotification(t)
395 if !strings.Contains(call.msg.Text, "❌") || !strings.Contains(call.msg.Text, "boom") {
396 t.Fatalf("error text = %q", call.msg.Text)
397 }
398 }
399
400 func TestBridgeRecoveryPauseNotifiesAsControlledPause(t *testing.T) {
401 env := newBridgeTestEnv([]TabMeta{{ID: "tab-1", Label: "会话一"}})
402 env.hub.SetWatch(testWatchRoute(), true)
403
404 env.hub.observe("tab-1", event.Event{
405 Kind: event.TurnDone,
406 Err: errors.New("automatic recovery paused"),
407 Outcome: event.TurnOutcomeRecoveryPaused,
408 })
409 call := env.waitNotification(t)
410 if strings.Contains(call.msg.Text, "❌") || !strings.Contains(call.msg.Text, "已暂停自动重试") || !strings.Contains(call.msg.Text, "继续") {
411 t.Fatalf("recovery pause text = %q, want a neutral actionable pause notice", call.msg.Text)
412 }
413 }
414
415 func TestBridgeAskAnswerRoundTrip(t *testing.T) {
416 env := newBridgeTestEnv([]TabMeta{{ID: "tab-2", Label: "问答会话"}})
417 env.hub.SetWatch(testWatchRoute(), true)
418
419 env.hub.observe("tab-2", event.Event{Kind: event.AskRequest, Ask: event.Ask{
420 ID: "ask-1",
421 Questions: []event.AskQuestion{{
422 ID: "q1",
423 Prompt: "选一个方案",
424 Options: []event.AskOption{{Label: "A"}, {Label: "B"}},
425 }},
426 }})
427 call := env.waitNotification(t)
428 if !strings.Contains(call.msg.Text, "/desktop answer ask-1") {
429 t.Fatalf("ask notification = %q, want answer hint", call.msg.Text)
430 }
431 if call.msg.Card == nil {
432 t.Fatal("single-choice ask should carry option buttons")
433 }
434
435 questions, ok := env.hub.AskQuestions("ask-1")
436 if !ok || len(questions) != 1 {
437 t.Fatalf("AskQuestions = %v/%v", questions, ok)
438 }
439 if _, err := env.hub.Answer("ask-1", []event.AskAnswer{{QuestionID: "q1", Selected: []string{"B"}}}); err != nil {
440 t.Fatalf("Answer: %v", err)
441 }
442 select {
443 case got := <-env.answers:
444 if got[0] != "tab-2" || got[1] != "ask-1" {
445 t.Fatalf("answer routed as %v", got)
446 }
447 case <-time.After(time.Second):
448 t.Fatal("answer was not routed")
449 }
450 }
451
452 func TestBridgeWatchLifecycleStopsNotifications(t *testing.T) {
453 env := newBridgeTestEnv(nil)
454 route := testWatchRoute()
455
456 env.hub.SetWatch(route, true)
457 if !env.hub.Watching(route) {
458 t.Fatal("route should be watching after SetWatch(true)")
459 }
460 env.hub.SetWatch(route, false)
461 if env.hub.Watching(route) {
462 t.Fatal("route should not be watching after SetWatch(false)")
463 }
464
465 env.hub.observe("tab-x", event.Event{Kind: event.TurnDone})
466 env.expectNoNotification(t)
467 }
468
469 func TestBridgeSetWatchPersistsAndSeedRestores(t *testing.T) {
470 env := newBridgeTestEnv(nil)
471 route := testWatchRoute()
472
473 env.hub.SetWatch(route, true)
474 select {
475 case routes := <-env.persisted:
476 if len(routes) != 1 || routes[0].Key() != route.Key() {
477 t.Fatalf("persisted = %+v, want the subscribed route", routes)
478 }
479 case <-time.After(time.Second):
480 t.Fatal("SetWatch did not persist watchers")
481 }
482
483 // 模拟重启:全新 hub 从配置种子恢复。
484 env2 := newBridgeTestEnv(nil)
485 env2.hub.seedWatchers([]bot.DesktopWatchRoute{route}, env2.hub.watcherVersion())
486 if !env2.hub.Watching(route) {
487 t.Fatal("seeded hub should be watching the persisted route")
488 }
489 env2.hub.observe("tab-x", event.Event{Kind: event.TurnDone})
490 if call := env2.waitNotification(t); !strings.Contains(call.msg.Text, "✅") {
491 t.Fatalf("seeded watcher did not receive notifications: %q", call.msg.Text)
492 }
493 }
494
495 func TestBridgeSeedDoesNotOverwriteNewerRuntimeWatch(t *testing.T) {
496 env := newBridgeTestEnv(nil)
497 route := testWatchRoute()
498 staleVersion := env.hub.watcherVersion()
499 if err := env.hub.SetWatch(route, true); err != nil {
500 t.Fatalf("SetWatch: %v", err)
501 }
502 <-env.persisted
503
504 // Simulate a runtime refresh carrying a config snapshot loaded before the
505 // watch command persisted. It must not erase the newer in-process route.
506 env.hub.seedWatchers(nil, staleVersion)
507 if !env.hub.Watching(route) {
508 t.Fatal("stale config seed overwrote the newer runtime subscription")
509 }
510 }
511
512 func TestBridgeSeedPreservesWatchAfterPersistFailure(t *testing.T) {
513 env := newBridgeTestEnv(nil)
514 env.persistErr = errors.New("disk unavailable")
515 route := testWatchRoute()
516 if err := env.hub.SetWatch(route, true); err == nil {
517 t.Fatal("SetWatch should report the persistence failure")
518 }
519 <-env.persisted
520
521 env.hub.seedWatchers(nil, env.hub.watcherVersion())
522 if !env.hub.Watching(route) {
523 t.Fatal("disk snapshot erased a runtime watch whose persistence failed")
524 }
525 }
526
527 func TestBridgeSeedAppliesFreshExternalConfig(t *testing.T) {
528 env := newBridgeTestEnv(nil)
529 route := testWatchRoute()
530 version := env.hub.watcherVersion()
531 env.hub.seedWatchers([]bot.DesktopWatchRoute{route}, version)
532 if !env.hub.Watching(route) {
533 t.Fatal("initial config seed did not apply")
534 }
535
536 env.hub.seedWatchers(nil, version)
537 if env.hub.Watching(route) {
538 t.Fatal("fresh external config update did not replace the watcher set")
539 }
540 }
541
542 func TestBridgeApprovalRoutesToDetachedSession(t *testing.T) {
543 env := newBridgeTestEnvSessions([]bot.DesktopSessionInfo{
544 {TabID: "tab-bg", Label: "后台任务", Detached: true, Ready: true},
545 })
546
547 env.hub.observe("tab-bg", event.Event{Kind: event.ApprovalRequest, Approval: event.Approval{ID: "appr-bg", Tool: "bash"}})
548 if _, err := env.hub.Approve("appr-bg", true); err != nil {
549 t.Fatalf("Approve on detached session: %v", err)
550 }
551 select {
552 case got := <-env.approves:
553 if got[0] != "tab-bg" {
554 t.Fatalf("approve routed to %v, want tab-bg", got)
555 }
556 case <-time.After(time.Second):
557 t.Fatal("detached approval was not routed")
558 }
559 }
560
561 func TestBridgeTakeoverLifecycle(t *testing.T) {
562 env := newBridgeTestEnvSessions([]bot.DesktopSessionInfo{
563 {TabID: "tab-1", Label: "会话一", Ready: true},
564 {TabID: "tab-bg", Label: "后台", Detached: true},
565 })
566 route := testWatchRoute()
567
568 // 后台会话拒绝接管。
569 if _, err := env.hub.Takeover(route, "tab-bg"); err == nil {
570 t.Fatal("takeover of a detached session should fail")
571 }
572
573 feedback, err := env.hub.Takeover(route, "tab-1")
574 if err != nil {
575 t.Fatalf("Takeover: %v", err)
576 }
577 if !strings.Contains(feedback, "已接管") {
578 t.Fatalf("feedback = %q", feedback)
579 }
580 if env.hub.TakeoverTab(route) != "tab-1" {
581 t.Fatalf("TakeoverTab = %q, want tab-1", env.hub.TakeoverTab(route))
582 }
583 select {
584 case got := <-env.announced:
585 if got[0] != "tab-1" || !strings.Contains(got[1], "接管") {
586 t.Fatalf("announce = %v", got)
587 }
588 case <-time.After(time.Second):
589 t.Fatal("takeover was not announced to the desktop transcript")
590 }
591
592 // 驱动输入路由到 tab。
593 if _, err := env.hub.DriveInput(route, "跑一下测试"); err != nil {
594 t.Fatalf("DriveInput: %v", err)
595 }
596 select {
597 case got := <-env.driven:
598 if got[0] != "tab-1" || got[1] != "跑一下测试" {
599 t.Fatalf("driven = %v", got)
600 }
601 case <-time.After(time.Second):
602 t.Fatal("drive input was not routed")
603 }
604
605 // 另一个聊天抢同一会话被拒。
606 other := route
607 other.ChatID = "chat-other"
608 if _, err := env.hub.Takeover(other, "tab-1"); err == nil {
609 t.Fatal("takeover by another chat should be rejected while held")
610 }
611
612 // 释放。
613 if _, err := env.hub.Release(route); err != nil {
614 t.Fatalf("Release: %v", err)
615 }
616 if env.hub.TakeoverTab(route) != "" {
617 t.Fatal("binding should be cleared after release")
618 }
619 if _, err := env.hub.Release(route); err == nil {
620 t.Fatal("second release should report no binding")
621 }
622 }
623
624 func TestBridgeDriveInputRejectsRunningSession(t *testing.T) {
625 env := newBridgeTestEnvSessions([]bot.DesktopSessionInfo{
626 {TabID: "tab-1", Label: "会话一", Ready: true, Running: true},
627 })
628 route := testWatchRoute()
629 if _, err := env.hub.Takeover(route, "tab-1"); err != nil {
630 t.Fatalf("Takeover: %v", err)
631 }
632 <-env.announced
633 if _, err := env.hub.DriveInput(route, "hello"); err == nil || !strings.Contains(err.Error(), "正在执行中") {
634 t.Fatalf("DriveInput on running session = %v, want busy rejection", err)
635 }
636 }
637
638 func TestBridgeReclaimFromDesktopNotifiesController(t *testing.T) {
639 env := newBridgeTestEnvSessions([]bot.DesktopSessionInfo{
640 {TabID: "tab-1", Label: "会话一", Ready: true},
641 })
642 route := testWatchRoute()
643 if _, err := env.hub.Takeover(route, "tab-1"); err != nil {
644 t.Fatalf("Takeover: %v", err)
645 }
646 <-env.announced
647
648 env.hub.reclaimFromDesktop("tab-1")
649 if env.hub.TakeoverTab(route) != "" {
650 t.Fatal("reclaim should clear the binding")
651 }
652 call := env.waitNotification(t)
653 if !strings.Contains(call.msg.Text, "收回") || call.msg.ChatID != route.ChatID {
654 t.Fatalf("reclaim notification = %+v", call)
655 }
656
657 // 未接管 tab 的 reclaim 是 no-op。
658 env.hub.reclaimFromDesktop("tab-1")
659 env.expectNoNotification(t)
660 }
661
661 lines GO