| 1 | package acp |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "testing" |
| 6 | |
| 7 | "reasonix/internal/control" |
| 8 | "reasonix/internal/event" |
| 9 | "reasonix/internal/provider" |
| 10 | ) |
| 11 | |
| 12 | func TestEnrichStateWithExtensionModels(t *testing.T) { |
| 13 | base := SessionConfigState{ |
| 14 | Model: "openai/x", |
| 15 | Models: &SessionModelState{ |
| 16 | CurrentModelID: "openai/x", |
| 17 | AvailableModels: []ModelInfo{{ModelID: "openai/x", Name: "openai/x"}}, |
| 18 | }, |
| 19 | ConfigOptions: []SessionConfigOption{{ |
| 20 | ID: "model", Name: "Model", Category: "model", Type: "select", |
| 21 | CurrentValue: "openai/x", |
| 22 | Options: []SessionConfigSelectOption{{Value: "openai/x", Name: "openai/x"}}, |
| 23 | }}, |
| 24 | } |
| 25 | catalog := []provider.Descriptor{ |
| 26 | {Ref: "plugin/demo/fake/echo", DisplayName: "Demo Echo"}, |
| 27 | {Ref: "openai/x"}, // config-owned: skipped (not plugin-namespaced) |
| 28 | {Ref: "plugin/demo/fake/echo"}, // duplicate: deduped |
| 29 | {Ref: "plugin//fake/echo"}, // malformed owner: skipped |
| 30 | } |
| 31 | |
| 32 | out := enrichStateWithExtensionModels(base, catalog) |
| 33 | |
| 34 | var infos []string |
| 35 | for _, m := range out.Models.AvailableModels { |
| 36 | infos = append(infos, m.ModelID) |
| 37 | } |
| 38 | if len(infos) != 2 || infos[0] != "openai/x" || infos[1] != "plugin/demo/fake/echo" { |
| 39 | t.Fatalf("AvailableModels = %v", infos) |
| 40 | } |
| 41 | found := false |
| 42 | for _, m := range out.Models.AvailableModels { |
| 43 | if m.ModelID == "plugin/demo/fake/echo" && (m.Name != "Demo Echo" || m.Description != "Demo Echo") { |
| 44 | t.Fatalf("display name not propagated: %+v", m) |
| 45 | } |
| 46 | if m.ModelID == "plugin/demo/fake/echo" { |
| 47 | found = true |
| 48 | } |
| 49 | } |
| 50 | if !found { |
| 51 | t.Fatal("plugin model missing from legacy model list") |
| 52 | } |
| 53 | opts := out.ConfigOptions[0].Options |
| 54 | if len(opts) != 2 || opts[1].Value != "plugin/demo/fake/echo" || opts[1].Name != "Demo Echo" { |
| 55 | t.Fatalf("model select options = %+v", opts) |
| 56 | } |
| 57 | |
| 58 | // Idempotent: enriching twice must not duplicate. |
| 59 | twice := enrichStateWithExtensionModels(out, catalog) |
| 60 | if len(twice.Models.AvailableModels) != 2 || len(twice.ConfigOptions[0].Options) != 2 { |
| 61 | t.Fatalf("enrichment is not idempotent: %+v", twice.Models.AvailableModels) |
| 62 | } |
| 63 | |
| 64 | // Nil catalog / nil Models are no-ops. |
| 65 | empty := enrichStateWithExtensionModels(SessionConfigState{Model: "openai/x"}, nil) |
| 66 | if empty.Models != nil { |
| 67 | t.Fatal("nil catalog mutated the state") |
| 68 | } |
| 69 | } |
| 70 | |
| 71 | // A session whose current model is a builtin must still SEE installed |
| 72 | // extension models on a config-state read (the reviewer P1 case). |
| 73 | func TestConfigStateForSessionIncludesExtensionModels(t *testing.T) { |
| 74 | ctrl := control.New(control.Options{ |
| 75 | Sink: event.Discard, |
| 76 | ProviderResolver: &provider.StaticResolver{ |
| 77 | Descriptors: []provider.Descriptor{ |
| 78 | {Ref: "openai/x", DisplayName: "openai"}, |
| 79 | {Ref: "plugin/demo/fake/echo", DisplayName: "Demo Echo"}, |
| 80 | }, |
| 81 | }, |
| 82 | }) |
| 83 | defer ctrl.Close() |
| 84 | |
| 85 | svc := &service{} |
| 86 | sess := &acpSession{id: "sess-1", cwd: "/tmp", ctrl: ctrl} |
| 87 | state, err := svc.configStateForSession(context.Background(), sess) |
| 88 | if err != nil { |
| 89 | t.Fatalf("configStateForSession: %v", err) |
| 90 | } |
| 91 | if state.Models == nil { |
| 92 | t.Fatal("state.Models is nil") |
| 93 | } |
| 94 | var hasPlugin bool |
| 95 | for _, m := range state.Models.AvailableModels { |
| 96 | if m.ModelID == "plugin/demo/fake/echo" { |
| 97 | hasPlugin = true |
| 98 | } |
| 99 | } |
| 100 | if !hasPlugin { |
| 101 | t.Fatalf("extension model not discoverable on config-state read: %+v", state.Models.AvailableModels) |
| 102 | } |
| 103 | } |
| 104 |