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