| 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 | control.SessionAPI |
| 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 | }}, |
| 44 | disabledMCP: map[string]ServerView{}, |
| 45 | } |
| 46 | app.tabs = map[string]*WorkspaceTab{tab.ID: tab} |
| 47 | app.tabOrder = []string{tab.ID} |
| 48 | app.activeTabID = tab.ID |
| 49 | |
| 50 | models := app.ModelsForTab(tab.ID) |
| 51 | var pluginX, pluginY, oldCount *ModelInfo |
| 52 | oldSeen := 0 |
| 53 | for i := range models { |
| 54 | switch models[i].Ref { |
| 55 | case "plugin/demo/fake/x": |
| 56 | pluginX = &models[i] |
| 57 | case "plugin/demo/fake/y": |
| 58 | pluginY = &models[i] |
| 59 | case "old/old-model": |
| 60 | oldSeen++ |
| 61 | oldCount = &models[i] |
| 62 | } |
| 63 | } |
| 64 | if pluginX == nil || pluginY == nil { |
| 65 | t.Fatalf("ModelsForTab = %+v, want both plugin refs", models) |
| 66 | } |
| 67 | if pluginX.Provider != "plugin/demo" || pluginX.Model != "fake/x" { |
| 68 | t.Fatalf("plugin ref display = %+v, want provider plugin/demo model fake/x", pluginX) |
| 69 | } |
| 70 | if !pluginX.Current { |
| 71 | t.Fatalf("active plugin ref not marked current: %+v", pluginX) |
| 72 | } |
| 73 | if oldSeen != 1 || oldCount == nil { |
| 74 | t.Fatalf("config ref repeated %d times, want exactly one", oldSeen) |
| 75 | } |
| 76 | |
| 77 | // A tab whose controller has no extension catalog (nil → no sidecar |
| 78 | // declared providers) enumerates config only, as before. |
| 79 | tab.Ctrl = &extensionCatalogCtrl{} |
| 80 | for _, info := range app.ModelsForTab(tab.ID) { |
| 81 | if strings.HasPrefix(info.Ref, "plugin/") { |
| 82 | t.Fatalf("empty catalog produced plugin ref %+v", info) |
| 83 | } |
| 84 | } |
| 85 | } |
| 86 | |
| 87 | // TestSetModelForTabPluginRefReachesBoot: with a controller whose merged |
| 88 | // catalog declares the plugin ref, SetModelForTab's config gate accepts the |
| 89 | // ref and hands it to boot — which is the resolver of record (here it fails |
| 90 | // only because no sidecar is installed in this test's REASONIX_HOME). A |
| 91 | // non-plugin unknown ref must still fail at the desktop gate, before boot. |
| 92 | func TestSetModelForTabPluginRefReachesBoot(t *testing.T) { |
| 93 | dir := reloadRuntimeFixture(t) |
| 94 | |
| 95 | oldExec := agent.New(nil, nil, agent.NewSession("old system prompt"), agent.Options{}, event.Discard) |
| 96 | app := NewApp() |
| 97 | app.ctx = context.Background() |
| 98 | app.readyHook = func() {} |
| 99 | oldCtrl := control.New(control.Options{ |
| 100 | Executor: oldExec, |
| 101 | SessionDir: dir, |
| 102 | Label: "old", |
| 103 | Sink: event.Discard, |
| 104 | // The merged catalog this controller would expose with the demo |
| 105 | // sidecar live: plugin/demo/fake/x is a valid switch target. |
| 106 | ProviderResolver: &provider.StaticResolver{Descriptors: []provider.Descriptor{ |
| 107 | {Ref: "plugin/demo/fake/x", Model: "x"}, |
| 108 | }}, |
| 109 | }) |
| 110 | reloadRuntimeTab(t, app, dir, oldCtrl) |
| 111 | |
| 112 | err := app.SetModelForTab("tab_a", "plugin/demo/fake/x") |
| 113 | if err == nil { |
| 114 | t.Fatal("SetModelForTab succeeded without an installed sidecar; expected boot to gate the plugin ref") |
| 115 | } |
| 116 | if !errors.Is(err, boot.ErrUnknownModel) { |
| 117 | t.Fatalf("SetModelForTab error = %v, want boot.ErrUnknownModel (the desktop gate must pass plugin refs through to boot)", err) |
| 118 | } |
| 119 | |
| 120 | // The desktop config gate still rejects unknown NON-plugin refs itself. |
| 121 | err = app.SetModelForTab("tab_a", "ghost/never-existed") |
| 122 | if err == nil || !strings.Contains(err.Error(), `unknown model "ghost/never-existed"`) { |
| 123 | t.Fatalf("SetModelForTab non-plugin error = %v, want the desktop unknown-model gate", err) |
| 124 | } |
| 125 | if errors.Is(err, boot.ErrUnknownModel) { |
| 126 | t.Fatalf("non-plugin unknown ref reached boot: %v", err) |
| 127 | } |
| 128 | } |
| 129 |