返回 DeepSeek-Reasonix
runtime_test.go
根目录 / internal / botruntime / runtime_test.go
1 package botruntime
2
3 import (
4 "io"
5 "log/slog"
6 "path/filepath"
7 "testing"
8
9 "reasonix/internal/bot"
10 "reasonix/internal/config"
11 )
12
13 func TestAllowlistUserCountIncludesRoles(t *testing.T) {
14 allowlist := config.BotAllowlist{
15 FeishuApprovers: []string{"ou-approver"},
16 FeishuAdmins: []string{"ou-admin"},
17 }
18
19 if got := AllowlistUserCount(allowlist); got != 2 {
20 t.Fatalf("AllowlistUserCount() = %d, want role users included", got)
21 }
22 }
23
24 // TestAllowlistUserCountIncludesDingtalk: 钉钉 allowlist 字段必须计入,
25 // 否则仅配置钉钉白名单时启动门禁误判为无访问控制而拒绝启动。
26 func TestAllowlistUserCountIncludesDingtalk(t *testing.T) {
27 allowlist := config.BotAllowlist{
28 DingtalkUsers: []string{"ding-user"}, DingtalkApprovers: []string{"ding-approver"},
29 DingtalkAdmins: []string{"ding-admin"},
30 }
31 if got := AllowlistUserCount(allowlist); got != 3 {
32 t.Fatalf("AllowlistUserCount() = %d, want dingtalk users included", got)
33 }
34 }
35
36 // TestBotConfigHasAccessControlDingtalk: 仅直配 [bot.dingtalk].access 时
37 // 必须识别为有访问控制(关闭 pairing、无全局 allowlist 的场景)。
38 func TestBotConfigHasAccessControlDingtalk(t *testing.T) {
39 bc := config.BotConfig{
40 Dingtalk: config.DingtalkBotConfig{
41 Access: config.BotAccessConfig{Users: []string{"ding-user"}},
42 },
43 }
44 if !BotConfigHasAccessControl(bc) {
45 t.Fatal("BotConfigHasAccessControl() = false, want true for direct dingtalk access")
46 }
47 }
48
49 // TestMergeLegacyDingtalkChannel: 直配 [bot.dingtalk](无 [[bot.connections]])
50 // 时,模型/权限/工作目录必须合成进 Channels 与 ConnectionChannels,否则 CLI
51 // bot 模式忽略这些运行选项(与桌面端 legacy 钉钉通道同路径)。
52 func TestMergeLegacyDingtalkChannel(t *testing.T) {
53 channels, connectionChannels := MergeLegacyDingtalkChannel(config.DingtalkBotConfig{
54 Model: "deepseek/deepseek-v4-flash",
55 ToolApprovalMode: "yolo",
56 WorkspaceRoot: "/tmp/work",
57 }, nil, nil)
58
59 plat := channels[bot.PlatformDingtalk]
60 if plat.Model != "deepseek/deepseek-v4-flash" {
61 t.Fatalf("channel model = %q, want deepseek/deepseek-v4-flash", plat.Model)
62 }
63 if plat.ToolApprovalMode != "workspace-write" {
64 t.Fatalf("channel tool_approval_mode = %q, want workspace-write", plat.ToolApprovalMode)
65 }
66 if plat.WorkspaceRoot != "/tmp/work" {
67 t.Fatalf("channel workspace_root = %q, want /tmp/work", plat.WorkspaceRoot)
68 }
69 conn := connectionChannels[string(bot.PlatformDingtalk)]
70 if conn.Model != "deepseek/deepseek-v4-flash" {
71 t.Fatalf("connection channel model = %q, want deepseek/deepseek-v4-flash", conn.Model)
72 }
73 }
74
75 func TestMergeLegacyDingtalkChannelEmptyIsNoop(t *testing.T) {
76 channels := map[bot.Platform]bot.ChannelConfig{
77 bot.PlatformFeishu: {Model: "keep"},
78 }
79 out, conn := MergeLegacyDingtalkChannel(config.DingtalkBotConfig{}, channels, nil)
80 if _, ok := out[bot.PlatformDingtalk]; ok {
81 t.Fatalf("empty legacy dingtalk config should not create a dingtalk channel")
82 }
83 if out[bot.PlatformFeishu].Model != "keep" {
84 t.Fatalf("existing channels were mutated")
85 }
86 if conn != nil {
87 t.Fatalf("connection channels should stay nil for empty config")
88 }
89 }
90
91 func TestRemoteRemembererKeepsDistinctGroupUsers(t *testing.T) {
92 isolateUserConfig(t)
93 cfg := config.Default()
94 cfg.Bot.Connections = []config.BotConnectionConfig{
95 {ID: "feishu-lark", Provider: "feishu", Domain: "lark", Label: "Lark", Enabled: true, Status: "connected"},
96 }
97 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
98 t.Fatalf("save config: %v", err)
99 }
100
101 remember := NewRemoteRememberer(slog.New(slog.NewTextHandler(io.Discard, nil)))
102 remember(bot.InboundMessage{
103 Platform: bot.PlatformFeishu,
104 ConnectionID: "feishu-lark",
105 Domain: "lark",
106 ChatType: bot.ChatGroup,
107 ChatID: "oc-group-1",
108 UserID: "ou-user-1",
109 })
110 remember(bot.InboundMessage{
111 Platform: bot.PlatformFeishu,
112 ConnectionID: "feishu-lark",
113 Domain: "lark",
114 ChatType: bot.ChatGroup,
115 ChatID: "oc-group-1",
116 UserID: "ou-user-2",
117 })
118
119 got := config.LoadForEdit(config.UserConfigPath())
120 if users := got.Bot.Allowlist.FeishuUsers; len(users) != 2 || users[0] != "ou-user-1" || users[1] != "ou-user-2" {
121 t.Fatalf("feishu users = %+v, want both group users", users)
122 }
123 if groups := got.Bot.Allowlist.FeishuGroups; len(groups) != 1 || groups[0] != "oc-group-1" {
124 t.Fatalf("feishu groups = %+v, want group once", groups)
125 }
126 if mappings := got.Bot.Connections[0].SessionMappings; len(mappings) != 2 || mappings[0].RemoteID != "oc-group-1" || mappings[0].UserID != "ou-user-1" || mappings[1].RemoteID != "oc-group-1" || mappings[1].UserID != "ou-user-2" {
127 t.Fatalf("session mappings = %+v, want distinct group-user mappings", mappings)
128 }
129 }
130
131 func TestRememberInboundSessionFillsExistingMappingSessionID(t *testing.T) {
132 isolateUserConfig(t)
133 cfg := config.Default()
134 cfg.Bot.Connections = []config.BotConnectionConfig{
135 {ID: "weixin-weixin", Provider: "weixin", Domain: "weixin", Label: "微信", Enabled: true, Status: "connected"},
136 }
137 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
138 t.Fatalf("save config: %v", err)
139 }
140
141 msg := bot.InboundMessage{
142 Platform: bot.PlatformWeixin,
143 ConnectionID: "weixin-weixin",
144 Domain: "weixin",
145 ChatType: bot.ChatDM,
146 ChatID: "wx-chat-1",
147 UserID: "wx-user-1",
148 }
149 if err := RememberInbound(msg); err != nil {
150 t.Fatalf("remember inbound: %v", err)
151 }
152 if err := RememberInboundSession(msg, "path:/sessions/20260614-120000.000000000-deepseek.jsonl"); err != nil {
153 t.Fatalf("remember inbound session: %v", err)
154 }
155
156 got := config.LoadForEdit(config.UserConfigPath())
157 mappings := got.Bot.Connections[0].SessionMappings
158 if len(mappings) != 1 {
159 t.Fatalf("mappings = %+v, want one mapping", mappings)
160 }
161 if mappings[0].RemoteID != "wx-chat-1" || mappings[0].SessionID != "path:/sessions/20260614-120000.000000000-deepseek.jsonl" || mappings[0].SessionSource != "auto" {
162 t.Fatalf("mapping = %+v, want remote chat with session id", mappings[0])
163 }
164 }
165
166 func TestRememberInboundSessionCreatesMappingWithSessionID(t *testing.T) {
167 isolateUserConfig(t)
168 cfg := config.Default()
169 cfg.Bot.Connections = []config.BotConnectionConfig{
170 {ID: "feishu-lark", Provider: "feishu", Domain: "lark", Label: "Lark", Enabled: true, Status: "connected"},
171 }
172 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
173 t.Fatalf("save config: %v", err)
174 }
175
176 if err := RememberInboundSession(bot.InboundMessage{
177 Platform: bot.PlatformFeishu,
178 ConnectionID: "feishu-lark",
179 Domain: "lark",
180 ChatType: bot.ChatDM,
181 ChatID: "oc-chat-1",
182 UserID: "ou-user-1",
183 }, "path:/sessions/topic-bot.jsonl"); err != nil {
184 t.Fatalf("remember inbound session: %v", err)
185 }
186
187 got := config.LoadForEdit(config.UserConfigPath())
188 mappings := got.Bot.Connections[0].SessionMappings
189 if len(mappings) != 1 || mappings[0].RemoteID != "oc-chat-1" || mappings[0].SessionID != "path:/sessions/topic-bot.jsonl" || mappings[0].SessionSource != "auto" {
190 t.Fatalf("mappings = %+v, want mapping with session id", mappings)
191 }
192 }
193
194 // TestRememberInboundSessionCreatesMappingWithSessionID: legacy 直配
195 // [bot.dingtalk](无 connection 记录)时,会话绑定必须持久化到
196 // Dingtalk.SessionMappings,重启后仍能恢复(#9116 review 阻塞项⑤)。
197 func TestRememberInboundSessionLegacyDingtalkMapping(t *testing.T) {
198 isolateUserConfig(t)
199 cfg := config.Default()
200 cfg.Bot.Dingtalk.Enabled = true
201 cfg.Bot.Dingtalk.Model = "deepseek/deepseek-v4-flash"
202 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
203 t.Fatalf("save config: %v", err)
204 }
205
206 if err := RememberInboundSession(bot.InboundMessage{
207 Platform: bot.PlatformDingtalk,
208 ChatType: bot.ChatDM,
209 ChatID: "cid-ding-1",
210 UserID: "user-1",
211 }, "path:/sessions/bot-ding-rotated.jsonl"); err != nil {
212 t.Fatalf("remember inbound session: %v", err)
213 }
214
215 got := config.LoadForEdit(config.UserConfigPath())
216 mappings := got.Bot.Dingtalk.SessionMappings
217 if len(mappings) != 1 || mappings[0].RemoteID != "cid-ding-1" || mappings[0].SessionID != "path:/sessions/bot-ding-rotated.jsonl" || mappings[0].SessionSource != "auto" {
218 t.Fatalf("legacy dingtalk mappings = %+v, want rotated session pinned", mappings)
219 }
220
221 // 有 connection 记录时不写 legacy(避免双写)。
222 cfg2 := config.Default()
223 cfg2.Bot.Dingtalk.Enabled = true
224 cfg2.Bot.Connections = []config.BotConnectionConfig{
225 {ID: "ding-conn", Provider: "dingtalk", Enabled: true, Status: "connected"},
226 }
227 if err := cfg2.SaveTo(config.UserConfigPath()); err != nil {
228 t.Fatalf("save config: %v", err)
229 }
230 if err := RememberInboundSession(bot.InboundMessage{
231 Platform: bot.PlatformDingtalk,
232 ConnectionID: "ding-conn",
233 ChatType: bot.ChatDM,
234 ChatID: "cid-ding-2",
235 UserID: "user-1",
236 }, "path:/sessions/conn-session.jsonl"); err != nil {
237 t.Fatalf("remember inbound session via connection: %v", err)
238 }
239 got2 := config.LoadForEdit(config.UserConfigPath())
240 if len(got2.Bot.Dingtalk.SessionMappings) != 0 {
241 t.Fatalf("legacy dingtalk mappings = %+v, want empty when connection exists", got2.Bot.Dingtalk.SessionMappings)
242 }
243 if len(got2.Bot.Connections[0].SessionMappings) != 1 {
244 t.Fatalf("connection mappings = %+v, want the rotated session", got2.Bot.Connections[0].SessionMappings)
245 }
246
247 // 禁用的 connection 不接管入站,也不能阻止 legacy 映射持久化。
248 cfg3 := config.Default()
249 cfg3.Bot.Dingtalk.Enabled = true
250 cfg3.Bot.Connections = []config.BotConnectionConfig{
251 {ID: "ding-disabled", Provider: "dingtalk", Enabled: false},
252 }
253 if err := cfg3.SaveTo(config.UserConfigPath()); err != nil {
254 t.Fatalf("save disabled connection config: %v", err)
255 }
256 if err := RememberInboundSession(bot.InboundMessage{
257 Platform: bot.PlatformDingtalk,
258 ChatType: bot.ChatDM,
259 ChatID: "cid-ding-disabled-conn",
260 UserID: "user-1",
261 }, "path:/sessions/legacy-with-disabled-conn.jsonl"); err != nil {
262 t.Fatalf("remember inbound session with disabled connection: %v", err)
263 }
264 got3 := config.LoadForEdit(config.UserConfigPath())
265 if mappings := got3.Bot.Dingtalk.SessionMappings; len(mappings) != 1 || mappings[0].RemoteID != "cid-ding-disabled-conn" {
266 t.Fatalf("legacy dingtalk mappings = %+v, want mapping when only disabled connection exists", mappings)
267 }
268 if mappings := got3.Bot.Connections[0].SessionMappings; len(mappings) != 0 {
269 t.Fatalf("disabled connection mappings = %+v, want none", mappings)
270 }
271 }
272
273 func TestRememberInboundSessionKeepsDistinctGroupUsers(t *testing.T) {
274 isolateUserConfig(t)
275 cfg := config.Default()
276 cfg.Bot.Connections = []config.BotConnectionConfig{
277 {ID: "feishu-lark", Provider: "feishu", Domain: "lark", Label: "Lark", Enabled: true, Status: "connected"},
278 }
279 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
280 t.Fatalf("save config: %v", err)
281 }
282
283 msg1 := bot.InboundMessage{Platform: bot.PlatformFeishu, ConnectionID: "feishu-lark", Domain: "lark", ChatType: bot.ChatGroup, ChatID: "oc-group-1", UserID: "ou-user-1"}
284 msg2 := bot.InboundMessage{Platform: bot.PlatformFeishu, ConnectionID: "feishu-lark", Domain: "lark", ChatType: bot.ChatGroup, ChatID: "oc-group-1", UserID: "ou-user-2"}
285 if err := RememberInboundSession(msg1, "path:/sessions/user-1.jsonl"); err != nil {
286 t.Fatalf("remember user 1: %v", err)
287 }
288 if err := RememberInboundSession(msg2, "path:/sessions/user-2.jsonl"); err != nil {
289 t.Fatalf("remember user 2: %v", err)
290 }
291
292 got := config.LoadForEdit(config.UserConfigPath())
293 mappings := got.Bot.Connections[0].SessionMappings
294 if len(mappings) != 2 {
295 t.Fatalf("mappings = %+v, want two group-user mappings", mappings)
296 }
297 if mappings[0].UserID != "ou-user-1" || mappings[0].SessionID != "path:/sessions/user-1.jsonl" || mappings[1].UserID != "ou-user-2" || mappings[1].SessionID != "path:/sessions/user-2.jsonl" {
298 t.Fatalf("mappings = %+v, want user-specific session ids", mappings)
299 }
300 }
301
302 func TestRememberInboundSessionSharesThreadMappingAcrossUsers(t *testing.T) {
303 isolateUserConfig(t)
304 cfg := config.Default()
305 cfg.Bot.Connections = []config.BotConnectionConfig{
306 {ID: "feishu-lark", Provider: "feishu", Domain: "lark", Label: "Lark", Enabled: true, Status: "connected"},
307 }
308 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
309 t.Fatalf("save config: %v", err)
310 }
311
312 msg1 := bot.InboundMessage{Platform: bot.PlatformFeishu, ConnectionID: "feishu-lark", Domain: "lark", ChatType: bot.ChatThread, ChatID: "oc-group-1", ThreadID: "thread-1", UserID: "ou-user-1"}
313 msg2 := bot.InboundMessage{Platform: bot.PlatformFeishu, ConnectionID: "feishu-lark", Domain: "lark", ChatType: bot.ChatThread, ChatID: "oc-group-1", ThreadID: "thread-1", UserID: "ou-user-2"}
314 if err := RememberInboundSession(msg1, "path:/sessions/thread-old.jsonl"); err != nil {
315 t.Fatalf("remember user 1: %v", err)
316 }
317 if err := RememberInboundSession(msg2, "path:/sessions/thread-new.jsonl"); err != nil {
318 t.Fatalf("remember user 2: %v", err)
319 }
320
321 got := config.LoadForEdit(config.UserConfigPath())
322 mappings := got.Bot.Connections[0].SessionMappings
323 if len(mappings) != 1 {
324 t.Fatalf("mappings = %+v, want one shared thread mapping", mappings)
325 }
326 if mappings[0].ChatType != string(bot.ChatThread) || mappings[0].ThreadID != "thread-1" || mappings[0].UserID != "" || mappings[0].SessionID != "path:/sessions/thread-new.jsonl" {
327 t.Fatalf("mapping = %+v, want shared thread identity with latest auto session", mappings[0])
328 }
329 }
330
331 func TestRememberInboundSessionPreservesExplicitMappingTarget(t *testing.T) {
332 isolateUserConfig(t)
333 cfg := config.Default()
334 cfg.Bot.Connections = []config.BotConnectionConfig{{
335 ID: "weixin-weixin", Provider: "weixin", Domain: "weixin", Label: "微信", Enabled: true, Status: "connected",
336 SessionMappings: []config.BotConnectionSessionMapping{{RemoteID: "wx-chat-1", SessionID: "topic:manual-topic"}},
337 }}
338 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
339 t.Fatalf("save config: %v", err)
340 }
341
342 msg := bot.InboundMessage{Platform: bot.PlatformWeixin, ConnectionID: "weixin-weixin", Domain: "weixin", ChatType: bot.ChatDM, ChatID: "wx-chat-1", UserID: "wx-user-1"}
343 if err := RememberInboundSession(msg, "path:/sessions/auto.jsonl"); err != nil {
344 t.Fatalf("remember inbound session: %v", err)
345 }
346
347 got := config.LoadForEdit(config.UserConfigPath())
348 mapping := got.Bot.Connections[0].SessionMappings[0]
349 if mapping.SessionID != "topic:manual-topic" || mapping.SessionSource != "" {
350 t.Fatalf("mapping = %+v, want explicit topic preserved", mapping)
351 }
352 }
353
354 func TestRememberInboundSessionPreservesBareExplicitMappingTarget(t *testing.T) {
355 isolateUserConfig(t)
356 cfg := config.Default()
357 cfg.Bot.Connections = []config.BotConnectionConfig{{
358 ID: "weixin-weixin", Provider: "weixin", Domain: "weixin", Label: "微信", Enabled: true, Status: "connected",
359 SessionMappings: []config.BotConnectionSessionMapping{{RemoteID: "wx-chat-1", SessionID: "manual-topic"}},
360 }}
361 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
362 t.Fatalf("save config: %v", err)
363 }
364
365 msg := bot.InboundMessage{Platform: bot.PlatformWeixin, ConnectionID: "weixin-weixin", Domain: "weixin", ChatType: bot.ChatDM, ChatID: "wx-chat-1", UserID: "wx-user-1"}
366 if err := RememberInboundSession(msg, "path:/sessions/auto.jsonl"); err != nil {
367 t.Fatalf("remember inbound session: %v", err)
368 }
369
370 got := config.LoadForEdit(config.UserConfigPath())
371 mapping := got.Bot.Connections[0].SessionMappings[0]
372 if mapping.SessionID != "manual-topic" || mapping.SessionSource != "" {
373 t.Fatalf("mapping = %+v, want bare explicit target preserved", mapping)
374 }
375 }
376
377 func TestRememberInboundSessionUsesActualWorkspaceWhenConnectionIsGlobal(t *testing.T) {
378 isolateUserConfig(t)
379 workspace := filepath.Join(t.TempDir(), "workspace")
380 cfg := config.Default()
381 cfg.Bot.Connections = []config.BotConnectionConfig{
382 {ID: "weixin-weixin", Provider: "weixin", Domain: "weixin", Label: "微信", Enabled: true, Status: "connected"},
383 }
384 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
385 t.Fatalf("save config: %v", err)
386 }
387
388 msg := bot.InboundMessage{Platform: bot.PlatformWeixin, ConnectionID: "weixin-weixin", Domain: "weixin", ChatType: bot.ChatDM, ChatID: "wx-chat-1", UserID: "wx-user-1"}
389 if err := RememberInboundSessionWorkspace(msg, "path:/sessions/auto.jsonl", workspace); err != nil {
390 t.Fatalf("remember inbound session: %v", err)
391 }
392
393 got := config.LoadForEdit(config.UserConfigPath())
394 mapping := got.Bot.Connections[0].SessionMappings[0]
395 if mapping.Scope != "project" || mapping.WorkspaceRoot != workspace {
396 t.Fatalf("mapping = %+v, want actual workspace scope", mapping)
397 }
398 }
399
400 func TestRememberInboundSessionKeepsConfiguredWorkspaceOverActualWorkspace(t *testing.T) {
401 isolateUserConfig(t)
402 configured := filepath.Join(t.TempDir(), "configured")
403 actual := filepath.Join(t.TempDir(), "actual")
404 cfg := config.Default()
405 cfg.Bot.Connections = []config.BotConnectionConfig{{
406 ID: "weixin-weixin", Provider: "weixin", Domain: "weixin", Label: "微信", Enabled: true, Status: "connected", WorkspaceRoot: configured,
407 }}
408 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
409 t.Fatalf("save config: %v", err)
410 }
411
412 msg := bot.InboundMessage{Platform: bot.PlatformWeixin, ConnectionID: "weixin-weixin", Domain: "weixin", ChatType: bot.ChatDM, ChatID: "wx-chat-1", UserID: "wx-user-1"}
413 if err := RememberInboundSessionWorkspace(msg, "path:/sessions/auto.jsonl", actual); err != nil {
414 t.Fatalf("remember inbound session: %v", err)
415 }
416
417 got := config.LoadForEdit(config.UserConfigPath())
418 mapping := got.Bot.Connections[0].SessionMappings[0]
419 if mapping.Scope != "project" || mapping.WorkspaceRoot != configured {
420 t.Fatalf("mapping = %+v, want configured workspace scope", mapping)
421 }
422 }
423
424 func TestRememberInboundSessionUpdatesAutoMappingTarget(t *testing.T) {
425 isolateUserConfig(t)
426 cfg := config.Default()
427 cfg.Bot.Connections = []config.BotConnectionConfig{{
428 ID: "weixin-weixin", Provider: "weixin", Domain: "weixin", Label: "微信", Enabled: true, Status: "connected",
429 SessionMappings: []config.BotConnectionSessionMapping{{RemoteID: "wx-chat-1", SessionID: "path:/sessions/old.jsonl", SessionSource: "auto"}},
430 }}
431 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
432 t.Fatalf("save config: %v", err)
433 }
434
435 msg := bot.InboundMessage{Platform: bot.PlatformWeixin, ConnectionID: "weixin-weixin", Domain: "weixin", ChatType: bot.ChatDM, ChatID: "wx-chat-1", UserID: "wx-user-1"}
436 if err := RememberInboundSession(msg, "path:/sessions/new.jsonl"); err != nil {
437 t.Fatalf("remember inbound session: %v", err)
438 }
439
440 got := config.LoadForEdit(config.UserConfigPath())
441 mapping := got.Bot.Connections[0].SessionMappings[0]
442 if mapping.SessionID != "path:/sessions/new.jsonl" || mapping.SessionSource != "auto" {
443 t.Fatalf("mapping = %+v, want auto target updated", mapping)
444 }
445 }
446
447 func TestForgetAutoSessionMappingsForPathRemovesOnlyAutoPathTargets(t *testing.T) {
448 isolateUserConfig(t)
449 target := filepath.Join(t.TempDir(), "bot-channel.jsonl")
450 other := filepath.Join(t.TempDir(), "other-channel.jsonl")
451 cfg := config.Default()
452 cfg.Bot.Connections = []config.BotConnectionConfig{{
453 ID: "weixin-weixin", Provider: "weixin", Domain: "weixin", Label: "微信", Enabled: true, Status: "connected",
454 SessionMappings: []config.BotConnectionSessionMapping{
455 {RemoteID: "remove-path-prefix", SessionID: "path:" + target, SessionSource: "auto"},
456 {RemoteID: "remove-raw-path", SessionID: target, SessionSource: "auto"},
457 {RemoteID: "keep-explicit-path", SessionID: "path:" + target},
458 {RemoteID: "keep-other-auto", SessionID: "path:" + other, SessionSource: "auto"},
459 {RemoteID: "keep-topic-auto", SessionID: "topic:bot-topic", SessionSource: "auto"},
460 },
461 }}
462 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
463 t.Fatalf("save config: %v", err)
464 }
465
466 if err := ForgetAutoSessionMappingsForPath(target); err != nil {
467 t.Fatalf("forget auto session mappings: %v", err)
468 }
469
470 got := config.LoadForEdit(config.UserConfigPath())
471 mappings := got.Bot.Connections[0].SessionMappings
472 if len(mappings) != 3 {
473 t.Fatalf("mappings = %+v, want three preserved mappings", mappings)
474 }
475 remotes := map[string]bool{}
476 for _, mapping := range mappings {
477 remotes[mapping.RemoteID] = true
478 }
479 for _, remote := range []string{"keep-explicit-path", "keep-other-auto", "keep-topic-auto"} {
480 if !remotes[remote] {
481 t.Fatalf("mapping %q was not preserved: %+v", remote, mappings)
482 }
483 }
484 if got.Bot.Connections[0].UpdatedAt == "" {
485 t.Fatalf("connection UpdatedAt was not refreshed")
486 }
487 }
488
489 func TestConnectionChannelConfigsPreserveToolApprovalMode(t *testing.T) {
490 connections := []config.BotConnectionConfig{
491 {ID: "feishu-feishu", Provider: "feishu", Domain: "feishu", Enabled: true, ToolApprovalMode: "auto"},
492 {ID: "feishu-lark", Provider: "feishu", Domain: "lark", Enabled: true, ToolApprovalMode: "yolo"},
493 {ID: "weixin-weixin", Provider: "weixin", Domain: "weixin", Enabled: true, ToolApprovalMode: "ask"},
494 }
495
496 byConnection := ConnectionChannelConfigs(connections, true, true)
497 if got := byConnection["feishu-feishu"].ToolApprovalMode; got != "workspace-write" {
498 t.Fatalf("feishu tool approval mode = %q, want workspace-write", got)
499 }
500 if got := byConnection["feishu-lark"].ToolApprovalMode; got != "workspace-write" {
501 t.Fatalf("lark tool approval mode = %q, want workspace-write", got)
502 }
503 if got := byConnection["weixin-weixin"].ToolApprovalMode; got != "read-only" {
504 t.Fatalf("weixin tool approval mode = %q, want explicit read-only override", got)
505 }
506
507 byPlatform := ChannelConfigs(connections, true, true)
508 if got := byPlatform[bot.PlatformFeishu].ToolApprovalMode; got != "workspace-write" {
509 t.Fatalf("platform feishu tool approval mode = %q, want last enabled Feishu/Lark override", got)
510 }
511 }
512
513 func TestConnectionChannelConfigsCarrySessionMappingsOnlyPerConnection(t *testing.T) {
514 connections := []config.BotConnectionConfig{
515 {
516 ID: "weixin-weixin",
517 Provider: "weixin",
518 Domain: "weixin",
519 Enabled: true,
520 WorkspaceRoot: "/connection",
521 SessionMappings: []config.BotConnectionSessionMapping{{
522 RemoteID: "wx-group-1",
523 SessionID: "path:/tmp/reasonix-session.jsonl",
524 ChatType: string(bot.ChatGroup),
525 UserID: "wx-user-1",
526 Scope: "project",
527 WorkspaceRoot: "/mapped",
528 UpdatedAt: "2026-07-04T12:00:00Z",
529 }},
530 },
531 }
532
533 byConnection := ConnectionChannelConfigs(connections, true, true)
534 mappings := byConnection["weixin-weixin"].SessionMappings
535 if len(mappings) != 1 {
536 t.Fatalf("connection mappings = %+v, want one mapping", mappings)
537 }
538 if got := mappings[0]; got.RemoteID != "wx-group-1" || got.SessionID != "path:/tmp/reasonix-session.jsonl" || got.ChatType != string(bot.ChatGroup) || got.UserID != "wx-user-1" || got.WorkspaceRoot != "/mapped" || got.UpdatedAt == "" {
539 t.Fatalf("connection mapping = %+v, want copied routing fields", got)
540 }
541
542 byPlatform := ChannelConfigs(connections, true, true)
543 if got := byPlatform[bot.PlatformWeixin].SessionMappings; len(got) != 0 {
544 t.Fatalf("platform mappings = %+v, want none to avoid cross-connection routing", got)
545 }
546
547 noWorkspace := ConnectionChannelConfigs(connections, true, false)
548 if got := noWorkspace["weixin-weixin"].SessionMappings; len(got) != 0 {
549 t.Fatalf("connection mappings with includeWorkspaceRoot=false = %+v, want none", got)
550 }
551 }
552
553 func TestRouteConfigsPreserveRemoteOverrides(t *testing.T) {
554 routes := RouteConfigs([]config.BotRouteConfig{
555 {ConnectionID: "feishu-lark", Platform: "feishu", ChatType: "group", ChatID: "group-1", Model: "route-model", WorkspaceRoot: "/route", ToolApprovalMode: "full-access"},
556 {ConnectionID: "empty-route"},
557 }, true, true)
558 if len(routes) != 1 {
559 t.Fatalf("routes = %+v, want one non-empty route", routes)
560 }
561 got := routes[0]
562 if got.ConnectionID != "feishu-lark" || got.Platform != bot.PlatformFeishu || got.ChatType != bot.ChatGroup || got.ChatID != "group-1" {
563 t.Fatalf("route match fields = %+v, want trimmed remote match", got)
564 }
565 if got.Channel.Model != "route-model" || got.Channel.WorkspaceRoot != "/route" || got.Channel.ToolApprovalMode != "danger-full-access" {
566 t.Fatalf("route channel = %+v, want normalized overrides", got.Channel)
567 }
568 }
569
570 func isolateUserConfig(t *testing.T) {
571 t.Helper()
572 home := t.TempDir()
573 t.Setenv("HOME", home)
574 t.Setenv("USERPROFILE", home)
575 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
576 t.Setenv("AppData", filepath.Join(home, "AppData"))
577 t.Chdir(t.TempDir())
578 }
579
579 lines GO