返回 DeepSeek-Reasonix
extension_models_test.go
根目录 / desktop / extension_models_test.go
1 package main
2
3 import (
4 "context"
5 "errors"
6 "strings"
7 "testing"
8
9 "reasonix/internal/agent"
10 "reasonix/internal/boot"
11 "reasonix/internal/control"
12 "reasonix/internal/event"
13 "reasonix/internal/provider"
14 )
15
16 // extensionCatalogCtrl stubs only what the model discovery/switch paths read
17 // from a tab controller; everything else falls through the embedded nil
18 // SessionAPI and must not be called.
19 type extensionCatalogCtrl struct {
20 stubSessionAPI
21 catalog []provider.Descriptor
22 }
23
24 func (c *extensionCatalogCtrl) ProviderCatalog() []provider.Descriptor { return c.catalog }
25
26 // TestModelsForTabIncludesExtensionProviders: the desktop model switcher
27 // merges the tab controller's extension catalog into the config-backed list —
28 // plugin refs arrive fully namespaced, duplicates collapse, and the active
29 // plugin ref is marked current.
30 func TestModelsForTabIncludesExtensionProviders(t *testing.T) {
31 reloadRuntimeFixture(t) // one configured provider "old" with model old-model
32
33 app := NewApp()
34 tab := &WorkspaceTab{
35 ID: "tab_a",
36 Scope: "global",
37 Ready: true,
38 model: "plugin/demo/fake/x",
39 Ctrl: &extensionCatalogCtrl{catalog: []provider.Descriptor{
40 {Ref: "plugin/demo/fake/x", Model: "x"},
41 {Ref: "plugin/demo/fake/y", Model: "y"},
42 {Ref: "old/old-model"}, // claim-replaced duplicate must not repeat
43 // ProviderCatalog is merged, so it can also contain unnamespaced
44 // config-backed descriptors. They are not extension models and must
45 // never leak into a synthetic PLUGIN group in the desktop picker.
46 {Ref: "deepseek-responses", DisplayName: "deepseek-responses"},
47 {Ref: "siliconflow-cn", DisplayName: "siliconflow-cn"},
48 }},
49 disabledMCP: map[string]ServerView{},
50 }
51 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
52 app.tabOrder = []string{tab.ID}
53 app.activeTabID = tab.ID
54
55 models := app.ModelsForTab(tab.ID)
56 var pluginX, pluginY, oldCount *ModelInfo
57 oldSeen := 0
58 for i := range models {
59 switch models[i].Ref {
60 case "plugin/demo/fake/x":
61 pluginX = &models[i]
62 case "plugin/demo/fake/y":
63 pluginY = &models[i]
64 case "old/old-model":
65 oldSeen++
66 oldCount = &models[i]
67 }
68 }
69 if pluginX == nil || pluginY == nil {
70 t.Fatalf("ModelsForTab = %+v, want both plugin refs", models)
71 }
72 if pluginX.Provider != "plugin/demo" || pluginX.Model != "fake/x" {
73 t.Fatalf("plugin ref display = %+v, want provider plugin/demo model fake/x", pluginX)
74 }
75 if !pluginX.Current {
76 t.Fatalf("active plugin ref not marked current: %+v", pluginX)
77 }
78 if oldSeen != 1 || oldCount == nil {
79 t.Fatalf("config ref repeated %d times, want exactly one", oldSeen)
80 }
81 for _, info := range models {
82 if info.Ref == "deepseek-responses" || info.Ref == "siliconflow-cn" || info.Provider == "plugin" {
83 t.Fatalf("unnamespaced provider descriptor leaked into plugin models: %+v", info)
84 }
85 }
86
87 // A tab whose controller has no extension catalog (nil → no sidecar
88 // declared providers) enumerates config only, as before.
89 tab.Ctrl = &extensionCatalogCtrl{}
90 for _, info := range app.ModelsForTab(tab.ID) {
91 if strings.HasPrefix(info.Ref, "plugin/") {
92 t.Fatalf("empty catalog produced plugin ref %+v", info)
93 }
94 }
95 }
96
97 // TestSetModelForTabPluginRefReachesBoot: with a controller whose merged
98 // catalog declares the plugin ref, SetModelForTab's config gate accepts the
99 // ref and hands it to boot — which is the resolver of record (here it fails
100 // only because no sidecar is installed in this test's REASONIX_HOME). A
101 // non-plugin unknown ref must still fail at the desktop gate, before boot.
102 func TestSetModelForTabPluginRefReachesBoot(t *testing.T) {
103 dir := reloadRuntimeFixture(t)
104
105 oldExec := agent.New(nil, nil, agent.NewSession("old system prompt"), agent.Options{}, event.Discard)
106 app := NewApp()
107 app.ctx = context.Background()
108 app.readyHook = func() {}
109 oldCtrl := control.New(control.Options{
110 Executor: oldExec,
111 SessionDir: dir,
112 Label: "old",
113 Sink: event.Discard,
114 // The merged catalog this controller would expose with the demo
115 // sidecar live: plugin/demo/fake/x is a valid switch target.
116 ProviderResolver: &provider.StaticResolver{Descriptors: []provider.Descriptor{
117 {Ref: "plugin/demo/fake/x", Model: "x"},
118 }},
119 })
120 reloadRuntimeTab(t, app, dir, oldCtrl)
121
122 err := app.SetModelForTab("tab_a", "plugin/demo/fake/x")
123 if err == nil {
124 t.Fatal("SetModelForTab succeeded without an installed sidecar; expected boot to gate the plugin ref")
125 }
126 if !errors.Is(err, boot.ErrUnknownModel) {
127 t.Fatalf("SetModelForTab error = %v, want boot.ErrUnknownModel (the desktop gate must pass plugin refs through to boot)", err)
128 }
129
130 // The desktop config gate still rejects unknown NON-plugin refs itself.
131 err = app.SetModelForTab("tab_a", "ghost/never-existed")
132 if err == nil || !strings.Contains(err.Error(), `unknown model "ghost/never-existed"`) {
133 t.Fatalf("SetModelForTab non-plugin error = %v, want the desktop unknown-model gate", err)
134 }
135 if errors.Is(err, boot.ErrUnknownModel) {
136 t.Fatalf("non-plugin unknown ref reached boot: %v", err)
137 }
138 }
139
139 lines GO