返回 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 func TestRemoteRemembererKeepsDistinctGroupUsers(t *testing.T) {
25 isolateUserConfig(t)
26 cfg := config.Default()
27 cfg.Bot.Connections = []config.BotConnectionConfig{
28 {ID: "feishu-lark", Provider: "feishu", Domain: "lark", Label: "Lark", Enabled: true, Status: "connected"},
29 }
30 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
31 t.Fatalf("save config: %v", err)
32 }
33
34 remember := NewRemoteRememberer(slog.New(slog.NewTextHandler(io.Discard, nil)))
35 remember(bot.InboundMessage{
36 Platform: bot.PlatformFeishu,
37 ConnectionID: "feishu-lark",
38 Domain: "lark",
39 ChatType: bot.ChatGroup,
40 ChatID: "oc-group-1",
41 UserID: "ou-user-1",
42 })
43 remember(bot.InboundMessage{
44 Platform: bot.PlatformFeishu,
45 ConnectionID: "feishu-lark",
46 Domain: "lark",
47 ChatType: bot.ChatGroup,
48 ChatID: "oc-group-1",
49 UserID: "ou-user-2",
50 })
51
52 got := config.LoadForEdit(config.UserConfigPath())
53 if users := got.Bot.Allowlist.FeishuUsers; len(users) != 2 || users[0] != "ou-user-1" || users[1] != "ou-user-2" {
54 t.Fatalf("feishu users = %+v, want both group users", users)
55 }
56 if groups := got.Bot.Allowlist.FeishuGroups; len(groups) != 1 || groups[0] != "oc-group-1" {
57 t.Fatalf("feishu groups = %+v, want group once", groups)
58 }
59 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" {
60 t.Fatalf("session mappings = %+v, want distinct group-user mappings", mappings)
61 }
62 }
63
64 func TestRememberInboundSessionFillsExistingMappingSessionID(t *testing.T) {
65 isolateUserConfig(t)
66 cfg := config.Default()
67 cfg.Bot.Connections = []config.BotConnectionConfig{
68 {ID: "weixin-weixin", Provider: "weixin", Domain: "weixin", Label: "微信", Enabled: true, Status: "connected"},
69 }
70 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
71 t.Fatalf("save config: %v", err)
72 }
73
74 msg := bot.InboundMessage{
75 Platform: bot.PlatformWeixin,
76 ConnectionID: "weixin-weixin",
77 Domain: "weixin",
78 ChatType: bot.ChatDM,
79 ChatID: "wx-chat-1",
80 UserID: "wx-user-1",
81 }
82 if err := RememberInbound(msg); err != nil {
83 t.Fatalf("remember inbound: %v", err)
84 }
85 if err := RememberInboundSession(msg, "path:/sessions/20260614-120000.000000000-deepseek.jsonl"); err != nil {
86 t.Fatalf("remember inbound session: %v", err)
87 }
88
89 got := config.LoadForEdit(config.UserConfigPath())
90 mappings := got.Bot.Connections[0].SessionMappings
91 if len(mappings) != 1 {
92 t.Fatalf("mappings = %+v, want one mapping", mappings)
93 }
94 if mappings[0].RemoteID != "wx-chat-1" || mappings[0].SessionID != "path:/sessions/20260614-120000.000000000-deepseek.jsonl" || mappings[0].SessionSource != "auto" {
95 t.Fatalf("mapping = %+v, want remote chat with session id", mappings[0])
96 }
97 }
98
99 func TestRememberInboundSessionCreatesMappingWithSessionID(t *testing.T) {
100 isolateUserConfig(t)
101 cfg := config.Default()
102 cfg.Bot.Connections = []config.BotConnectionConfig{
103 {ID: "feishu-lark", Provider: "feishu", Domain: "lark", Label: "Lark", Enabled: true, Status: "connected"},
104 }
105 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
106 t.Fatalf("save config: %v", err)
107 }
108
109 if err := RememberInboundSession(bot.InboundMessage{
110 Platform: bot.PlatformFeishu,
111 ConnectionID: "feishu-lark",
112 Domain: "lark",
113 ChatType: bot.ChatDM,
114 ChatID: "oc-chat-1",
115 UserID: "ou-user-1",
116 }, "path:/sessions/topic-bot.jsonl"); err != nil {
117 t.Fatalf("remember inbound session: %v", err)
118 }
119
120 got := config.LoadForEdit(config.UserConfigPath())
121 mappings := got.Bot.Connections[0].SessionMappings
122 if len(mappings) != 1 || mappings[0].RemoteID != "oc-chat-1" || mappings[0].SessionID != "path:/sessions/topic-bot.jsonl" || mappings[0].SessionSource != "auto" {
123 t.Fatalf("mappings = %+v, want mapping with session id", mappings)
124 }
125 }
126
127 func TestRememberInboundSessionKeepsDistinctGroupUsers(t *testing.T) {
128 isolateUserConfig(t)
129 cfg := config.Default()
130 cfg.Bot.Connections = []config.BotConnectionConfig{
131 {ID: "feishu-lark", Provider: "feishu", Domain: "lark", Label: "Lark", Enabled: true, Status: "connected"},
132 }
133 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
134 t.Fatalf("save config: %v", err)
135 }
136
137 msg1 := bot.InboundMessage{Platform: bot.PlatformFeishu, ConnectionID: "feishu-lark", Domain: "lark", ChatType: bot.ChatGroup, ChatID: "oc-group-1", UserID: "ou-user-1"}
138 msg2 := bot.InboundMessage{Platform: bot.PlatformFeishu, ConnectionID: "feishu-lark", Domain: "lark", ChatType: bot.ChatGroup, ChatID: "oc-group-1", UserID: "ou-user-2"}
139 if err := RememberInboundSession(msg1, "path:/sessions/user-1.jsonl"); err != nil {
140 t.Fatalf("remember user 1: %v", err)
141 }
142 if err := RememberInboundSession(msg2, "path:/sessions/user-2.jsonl"); err != nil {
143 t.Fatalf("remember user 2: %v", err)
144 }
145
146 got := config.LoadForEdit(config.UserConfigPath())
147 mappings := got.Bot.Connections[0].SessionMappings
148 if len(mappings) != 2 {
149 t.Fatalf("mappings = %+v, want two group-user mappings", mappings)
150 }
151 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" {
152 t.Fatalf("mappings = %+v, want user-specific session ids", mappings)
153 }
154 }
155
156 func TestRememberInboundSessionSharesThreadMappingAcrossUsers(t *testing.T) {
157 isolateUserConfig(t)
158 cfg := config.Default()
159 cfg.Bot.Connections = []config.BotConnectionConfig{
160 {ID: "feishu-lark", Provider: "feishu", Domain: "lark", Label: "Lark", Enabled: true, Status: "connected"},
161 }
162 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
163 t.Fatalf("save config: %v", err)
164 }
165
166 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"}
167 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"}
168 if err := RememberInboundSession(msg1, "path:/sessions/thread-old.jsonl"); err != nil {
169 t.Fatalf("remember user 1: %v", err)
170 }
171 if err := RememberInboundSession(msg2, "path:/sessions/thread-new.jsonl"); err != nil {
172 t.Fatalf("remember user 2: %v", err)
173 }
174
175 got := config.LoadForEdit(config.UserConfigPath())
176 mappings := got.Bot.Connections[0].SessionMappings
177 if len(mappings) != 1 {
178 t.Fatalf("mappings = %+v, want one shared thread mapping", mappings)
179 }
180 if mappings[0].ChatType != string(bot.ChatThread) || mappings[0].ThreadID != "thread-1" || mappings[0].UserID != "" || mappings[0].SessionID != "path:/sessions/thread-new.jsonl" {
181 t.Fatalf("mapping = %+v, want shared thread identity with latest auto session", mappings[0])
182 }
183 }
184
185 func TestRememberInboundSessionPreservesExplicitMappingTarget(t *testing.T) {
186 isolateUserConfig(t)
187 cfg := config.Default()
188 cfg.Bot.Connections = []config.BotConnectionConfig{{
189 ID: "weixin-weixin", Provider: "weixin", Domain: "weixin", Label: "微信", Enabled: true, Status: "connected",
190 SessionMappings: []config.BotConnectionSessionMapping{{RemoteID: "wx-chat-1", SessionID: "topic:manual-topic"}},
191 }}
192 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
193 t.Fatalf("save config: %v", err)
194 }
195
196 msg := bot.InboundMessage{Platform: bot.PlatformWeixin, ConnectionID: "weixin-weixin", Domain: "weixin", ChatType: bot.ChatDM, ChatID: "wx-chat-1", UserID: "wx-user-1"}
197 if err := RememberInboundSession(msg, "path:/sessions/auto.jsonl"); err != nil {
198 t.Fatalf("remember inbound session: %v", err)
199 }
200
201 got := config.LoadForEdit(config.UserConfigPath())
202 mapping := got.Bot.Connections[0].SessionMappings[0]
203 if mapping.SessionID != "topic:manual-topic" || mapping.SessionSource != "" {
204 t.Fatalf("mapping = %+v, want explicit topic preserved", mapping)
205 }
206 }
207
208 func TestRememberInboundSessionPreservesBareExplicitMappingTarget(t *testing.T) {
209 isolateUserConfig(t)
210 cfg := config.Default()
211 cfg.Bot.Connections = []config.BotConnectionConfig{{
212 ID: "weixin-weixin", Provider: "weixin", Domain: "weixin", Label: "微信", Enabled: true, Status: "connected",
213 SessionMappings: []config.BotConnectionSessionMapping{{RemoteID: "wx-chat-1", SessionID: "manual-topic"}},
214 }}
215 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
216 t.Fatalf("save config: %v", err)
217 }
218
219 msg := bot.InboundMessage{Platform: bot.PlatformWeixin, ConnectionID: "weixin-weixin", Domain: "weixin", ChatType: bot.ChatDM, ChatID: "wx-chat-1", UserID: "wx-user-1"}
220 if err := RememberInboundSession(msg, "path:/sessions/auto.jsonl"); err != nil {
221 t.Fatalf("remember inbound session: %v", err)
222 }
223
224 got := config.LoadForEdit(config.UserConfigPath())
225 mapping := got.Bot.Connections[0].SessionMappings[0]
226 if mapping.SessionID != "manual-topic" || mapping.SessionSource != "" {
227 t.Fatalf("mapping = %+v, want bare explicit target preserved", mapping)
228 }
229 }
230
231 func TestRememberInboundSessionUsesActualWorkspaceWhenConnectionIsGlobal(t *testing.T) {
232 isolateUserConfig(t)
233 workspace := filepath.Join(t.TempDir(), "workspace")
234 cfg := config.Default()
235 cfg.Bot.Connections = []config.BotConnectionConfig{
236 {ID: "weixin-weixin", Provider: "weixin", Domain: "weixin", Label: "微信", Enabled: true, Status: "connected"},
237 }
238 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
239 t.Fatalf("save config: %v", err)
240 }
241
242 msg := bot.InboundMessage{Platform: bot.PlatformWeixin, ConnectionID: "weixin-weixin", Domain: "weixin", ChatType: bot.ChatDM, ChatID: "wx-chat-1", UserID: "wx-user-1"}
243 if err := RememberInboundSessionWorkspace(msg, "path:/sessions/auto.jsonl", workspace); err != nil {
244 t.Fatalf("remember inbound session: %v", err)
245 }
246
247 got := config.LoadForEdit(config.UserConfigPath())
248 mapping := got.Bot.Connections[0].SessionMappings[0]
249 if mapping.Scope != "project" || mapping.WorkspaceRoot != workspace {
250 t.Fatalf("mapping = %+v, want actual workspace scope", mapping)
251 }
252 }
253
254 func TestRememberInboundSessionKeepsConfiguredWorkspaceOverActualWorkspace(t *testing.T) {
255 isolateUserConfig(t)
256 configured := filepath.Join(t.TempDir(), "configured")
257 actual := filepath.Join(t.TempDir(), "actual")
258 cfg := config.Default()
259 cfg.Bot.Connections = []config.BotConnectionConfig{{
260 ID: "weixin-weixin", Provider: "weixin", Domain: "weixin", Label: "微信", Enabled: true, Status: "connected", WorkspaceRoot: configured,
261 }}
262 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
263 t.Fatalf("save config: %v", err)
264 }
265
266 msg := bot.InboundMessage{Platform: bot.PlatformWeixin, ConnectionID: "weixin-weixin", Domain: "weixin", ChatType: bot.ChatDM, ChatID: "wx-chat-1", UserID: "wx-user-1"}
267 if err := RememberInboundSessionWorkspace(msg, "path:/sessions/auto.jsonl", actual); err != nil {
268 t.Fatalf("remember inbound session: %v", err)
269 }
270
271 got := config.LoadForEdit(config.UserConfigPath())
272 mapping := got.Bot.Connections[0].SessionMappings[0]
273 if mapping.Scope != "project" || mapping.WorkspaceRoot != configured {
274 t.Fatalf("mapping = %+v, want configured workspace scope", mapping)
275 }
276 }
277
278 func TestRememberInboundSessionUpdatesAutoMappingTarget(t *testing.T) {
279 isolateUserConfig(t)
280 cfg := config.Default()
281 cfg.Bot.Connections = []config.BotConnectionConfig{{
282 ID: "weixin-weixin", Provider: "weixin", Domain: "weixin", Label: "微信", Enabled: true, Status: "connected",
283 SessionMappings: []config.BotConnectionSessionMapping{{RemoteID: "wx-chat-1", SessionID: "path:/sessions/old.jsonl", SessionSource: "auto"}},
284 }}
285 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
286 t.Fatalf("save config: %v", err)
287 }
288
289 msg := bot.InboundMessage{Platform: bot.PlatformWeixin, ConnectionID: "weixin-weixin", Domain: "weixin", ChatType: bot.ChatDM, ChatID: "wx-chat-1", UserID: "wx-user-1"}
290 if err := RememberInboundSession(msg, "path:/sessions/new.jsonl"); err != nil {
291 t.Fatalf("remember inbound session: %v", err)
292 }
293
294 got := config.LoadForEdit(config.UserConfigPath())
295 mapping := got.Bot.Connections[0].SessionMappings[0]
296 if mapping.SessionID != "path:/sessions/new.jsonl" || mapping.SessionSource != "auto" {
297 t.Fatalf("mapping = %+v, want auto target updated", mapping)
298 }
299 }
300
301 func TestForgetAutoSessionMappingsForPathRemovesOnlyAutoPathTargets(t *testing.T) {
302 isolateUserConfig(t)
303 target := filepath.Join(t.TempDir(), "bot-channel.jsonl")
304 other := filepath.Join(t.TempDir(), "other-channel.jsonl")
305 cfg := config.Default()
306 cfg.Bot.Connections = []config.BotConnectionConfig{{
307 ID: "weixin-weixin", Provider: "weixin", Domain: "weixin", Label: "微信", Enabled: true, Status: "connected",
308 SessionMappings: []config.BotConnectionSessionMapping{
309 {RemoteID: "remove-path-prefix", SessionID: "path:" + target, SessionSource: "auto"},
310 {RemoteID: "remove-raw-path", SessionID: target, SessionSource: "auto"},
311 {RemoteID: "keep-explicit-path", SessionID: "path:" + target},
312 {RemoteID: "keep-other-auto", SessionID: "path:" + other, SessionSource: "auto"},
313 {RemoteID: "keep-topic-auto", SessionID: "topic:bot-topic", SessionSource: "auto"},
314 },
315 }}
316 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
317 t.Fatalf("save config: %v", err)
318 }
319
320 if err := ForgetAutoSessionMappingsForPath(target); err != nil {
321 t.Fatalf("forget auto session mappings: %v", err)
322 }
323
324 got := config.LoadForEdit(config.UserConfigPath())
325 mappings := got.Bot.Connections[0].SessionMappings
326 if len(mappings) != 3 {
327 t.Fatalf("mappings = %+v, want three preserved mappings", mappings)
328 }
329 remotes := map[string]bool{}
330 for _, mapping := range mappings {
331 remotes[mapping.RemoteID] = true
332 }
333 for _, remote := range []string{"keep-explicit-path", "keep-other-auto", "keep-topic-auto"} {
334 if !remotes[remote] {
335 t.Fatalf("mapping %q was not preserved: %+v", remote, mappings)
336 }
337 }
338 if got.Bot.Connections[0].UpdatedAt == "" {
339 t.Fatalf("connection UpdatedAt was not refreshed")
340 }
341 }
342
343 func TestConnectionChannelConfigsPreserveToolApprovalMode(t *testing.T) {
344 connections := []config.BotConnectionConfig{
345 {ID: "feishu-feishu", Provider: "feishu", Domain: "feishu", Enabled: true, ToolApprovalMode: "auto"},
346 {ID: "feishu-lark", Provider: "feishu", Domain: "lark", Enabled: true, ToolApprovalMode: "yolo"},
347 {ID: "weixin-weixin", Provider: "weixin", Domain: "weixin", Enabled: true, ToolApprovalMode: "ask"},
348 }
349
350 byConnection := ConnectionChannelConfigs(connections, true, true)
351 if got := byConnection["feishu-feishu"].ToolApprovalMode; got != "auto" {
352 t.Fatalf("feishu tool approval mode = %q, want auto", got)
353 }
354 if got := byConnection["feishu-lark"].ToolApprovalMode; got != "yolo" {
355 t.Fatalf("lark tool approval mode = %q, want yolo", got)
356 }
357 if got := byConnection["weixin-weixin"].ToolApprovalMode; got != "ask" {
358 t.Fatalf("weixin tool approval mode = %q, want explicit ask override", got)
359 }
360
361 byPlatform := ChannelConfigs(connections, true, true)
362 if got := byPlatform[bot.PlatformFeishu].ToolApprovalMode; got != "yolo" {
363 t.Fatalf("platform feishu tool approval mode = %q, want last enabled Feishu/Lark override", got)
364 }
365 }
366
367 func TestConnectionChannelConfigsCarrySessionMappingsOnlyPerConnection(t *testing.T) {
368 connections := []config.BotConnectionConfig{
369 {
370 ID: "weixin-weixin",
371 Provider: "weixin",
372 Domain: "weixin",
373 Enabled: true,
374 WorkspaceRoot: "/connection",
375 SessionMappings: []config.BotConnectionSessionMapping{{
376 RemoteID: "wx-group-1",
377 SessionID: "path:/tmp/reasonix-session.jsonl",
378 ChatType: string(bot.ChatGroup),
379 UserID: "wx-user-1",
380 Scope: "project",
381 WorkspaceRoot: "/mapped",
382 UpdatedAt: "2026-07-04T12:00:00Z",
383 }},
384 },
385 }
386
387 byConnection := ConnectionChannelConfigs(connections, true, true)
388 mappings := byConnection["weixin-weixin"].SessionMappings
389 if len(mappings) != 1 {
390 t.Fatalf("connection mappings = %+v, want one mapping", mappings)
391 }
392 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 == "" {
393 t.Fatalf("connection mapping = %+v, want copied routing fields", got)
394 }
395
396 byPlatform := ChannelConfigs(connections, true, true)
397 if got := byPlatform[bot.PlatformWeixin].SessionMappings; len(got) != 0 {
398 t.Fatalf("platform mappings = %+v, want none to avoid cross-connection routing", got)
399 }
400
401 noWorkspace := ConnectionChannelConfigs(connections, true, false)
402 if got := noWorkspace["weixin-weixin"].SessionMappings; len(got) != 0 {
403 t.Fatalf("connection mappings with includeWorkspaceRoot=false = %+v, want none", got)
404 }
405 }
406
407 func TestRouteConfigsPreserveRemoteOverrides(t *testing.T) {
408 routes := RouteConfigs([]config.BotRouteConfig{
409 {ConnectionID: "feishu-lark", Platform: "feishu", ChatType: "group", ChatID: "group-1", Model: "route-model", WorkspaceRoot: "/route", ToolApprovalMode: "full-access"},
410 {ConnectionID: "empty-route"},
411 }, true, true)
412 if len(routes) != 1 {
413 t.Fatalf("routes = %+v, want one non-empty route", routes)
414 }
415 got := routes[0]
416 if got.ConnectionID != "feishu-lark" || got.Platform != bot.PlatformFeishu || got.ChatType != bot.ChatGroup || got.ChatID != "group-1" {
417 t.Fatalf("route match fields = %+v, want trimmed remote match", got)
418 }
419 if got.Channel.Model != "route-model" || got.Channel.WorkspaceRoot != "/route" || got.Channel.ToolApprovalMode != "yolo" {
420 t.Fatalf("route channel = %+v, want normalized overrides", got.Channel)
421 }
422 }
423
424 func isolateUserConfig(t *testing.T) {
425 t.Helper()
426 home := t.TempDir()
427 t.Setenv("HOME", home)
428 t.Setenv("USERPROFILE", home)
429 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
430 t.Setenv("AppData", filepath.Join(home, "AppData"))
431 t.Chdir(t.TempDir())
432 }
433
433 lines GO