返回 DeepSeek-Reasonix
gateway_runtime_switch_test.go
根目录 / internal / bot / gateway_runtime_switch_test.go
1 package bot
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7 "io"
8 "log/slog"
9 "path/filepath"
10 "strings"
11 "testing"
12
13 "reasonix/internal/agent"
14 "reasonix/internal/boot"
15 "reasonix/internal/control"
16 )
17
18 type failureAtomicBotController struct {
19 stubBotController
20 workspaceRoot string
21 sessionPath string
22 closed bool
23 turns int
24 }
25
26 func (c *failureAtomicBotController) RuntimeStatus() control.RuntimeStatus {
27 return control.RuntimeStatus{}
28 }
29 func (c *failureAtomicBotController) WorkspaceRoot() string { return c.workspaceRoot }
30 func (c *failureAtomicBotController) SessionPath() string { return c.sessionPath }
31 func (c *failureAtomicBotController) Close() { c.closed = true }
32 func (c *failureAtomicBotController) RunTurn(context.Context, string) error {
33 c.turns++
34 return nil
35 }
36
37 func TestModelSwitchBuildFailurePreservesControllerAndLease(t *testing.T) {
38 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
39 workspace := t.TempDir()
40 sessionPath := filepath.Join(workspace, "current.jsonl")
41 gw := NewGateway(GatewayConfig{Model: "provider/old", WorkspaceRoot: workspace}, nil, logger)
42 msg := InboundMessage{Platform: PlatformDingtalk, ChatType: ChatDM, ChatID: "chat", UserID: "user"}
43 key := BuildSessionKey(msg.Session())
44 ctrl := &failureAtomicBotController{workspaceRoot: workspace, sessionPath: sessionPath}
45 leases := control.NewSessionLeaseKeeper()
46 if err := leases.Rebind(sessionPath); err != nil {
47 t.Fatalf("acquire current session lease: %v", err)
48 }
49 state := &sessionState{
50 ctrl: ctrl,
51 leases: leases,
52 model: "provider/old",
53 workspaceRoot: workspace,
54 toolApprovalMode: control.ToolApprovalAsk,
55 sessionPath: sessionPath,
56 }
57 gw.controllers[key] = state
58 gw.sessionOverrides[key] = sessionRuntimeOverride{
59 channel: ChannelConfig{Model: "provider/old", WorkspaceRoot: workspace},
60 sessionPath: sessionPath,
61 }
62 gw.buildController = func(context.Context, boot.Options) (*control.Controller, error) {
63 return nil, fmt.Errorf("forced build failure")
64 }
65 t.Cleanup(gw.closeSessions)
66
67 got := gw.handleModelCommand(context.Background(), msg, "/model provider/new")
68 if !strings.Contains(got, "当前会话保持不变") {
69 t.Fatalf("model switch response = %q, want failure-atomic message", got)
70 }
71 if gw.controllers[key] != state || ctrl.closed {
72 t.Fatalf("old controller changed after build failure: installed=%v closed=%v", gw.controllers[key] == state, ctrl.closed)
73 }
74 if model := gw.sessionOverrides[key].channel.Model; model != "provider/old" {
75 t.Fatalf("runtime override model = %q, want provider/old", model)
76 }
77 if held := leases.HeldPath(); agent.CanonicalSessionPath(held) != agent.CanonicalSessionPath(sessionPath) {
78 t.Fatalf("held lease path = %q, want %q", held, sessionPath)
79 }
80 if competing, err := agent.TryAcquireSessionLease(sessionPath); !errors.Is(err, agent.ErrSessionLeaseHeld) {
81 if competing != nil {
82 competing.Release()
83 }
84 t.Fatalf("competing lease err = %v, want ErrSessionLeaseHeld", err)
85 }
86 if err := ctrl.RunTurn(context.Background(), "still usable"); err != nil || ctrl.turns != 1 {
87 t.Fatalf("old controller unusable after failed switch: turns=%d err=%v", ctrl.turns, err)
88 }
89 }
90
90 lines GO