Compare commits

...

10 Commits

Author SHA1 Message Date
Eva Ho 8568b3bdb2 fix test 2026-06-26 12:19:16 -04:00
Eva Ho d0902f502f launch: prompt before removing old byok setting 2026-06-26 11:58:31 -04:00
Eva Ho df01917b6d fix test 2026-06-23 15:46:11 -04:00
Eva Ho 65bb75a83a update vscode launch integration to use Ollama.ollama extension 2026-06-23 14:09:47 -04:00
Eva Ho 3cb0011b59 fix test 2026-06-23 09:44:23 -04:00
Eva Ho 552dc87ad7 Clean up legacy VS Code Ollama config during launch 2026-06-23 09:44:23 -04:00
Eva Ho 3f794446aa launch/vscode: Add VS Code extension launch flow 2026-06-23 09:44:23 -04:00
Eva Ho b217a2276c update vscode behaviour when installing extension 2026-06-23 09:44:23 -04:00
Eva Ho fa07c4e02b update vendor name 2026-06-23 09:44:23 -04:00
Eva Ho 799aed34aa wip 2026-06-23 09:44:23 -04:00
4 changed files with 762 additions and 238 deletions
+5 -5
View File
@@ -85,7 +85,7 @@ func TestIntegrationLookup(t *testing.T) {
}
func TestIntegrationRegistry(t *testing.T) {
expectedIntegrations := []string{"claude", "claude-desktop", "cline", "codex", "codex-app", "kimi", "droid", "opencode", "omp", "hermes", "hermes-desktop", "pool", "qwen"}
expectedIntegrations := []string{"claude", "claude-desktop", "cline", "codex", "codex-app", "kimi", "droid", "opencode", "omp", "hermes", "hermes-desktop", "pool", "qwen", "vscode"}
for _, name := range expectedIntegrations {
t.Run(name, func(t *testing.T) {
r, ok := integrations[name]
@@ -102,7 +102,7 @@ func TestIntegrationRegistry(t *testing.T) {
func TestHiddenIntegrationsExcludedFromVisibleLists(t *testing.T) {
for _, info := range ListIntegrationInfos() {
switch info.Name {
case "vscode", "kimi":
case "kimi":
t.Fatalf("hidden integration %q should not appear in ListIntegrationInfos", info.Name)
}
}
@@ -1848,9 +1848,9 @@ func TestListIntegrationInfos(t *testing.T) {
for _, info := range infos {
got = append(got, info.Name)
}
wantPrefix := []string{"claude", "codex-app", "hermes", "openclaw", "opencode", "hermes-desktop", "codex", "copilot", "omp"}
wantPrefix := []string{"claude", "codex-app", "vscode", "hermes", "openclaw", "opencode", "hermes-desktop", "codex", "copilot", "omp"}
if codexAppSupported() != nil {
wantPrefix = []string{"claude", "hermes", "openclaw", "opencode", "hermes-desktop", "codex", "copilot", "omp"}
wantPrefix = []string{"claude", "vscode", "hermes", "openclaw", "opencode", "hermes-desktop", "codex", "copilot", "omp"}
}
if len(got) < len(wantPrefix) {
t.Fatalf("expected at least %d integrations, got %v", len(wantPrefix), got)
@@ -1872,7 +1872,7 @@ func TestListIntegrationInfos(t *testing.T) {
})
t.Run("includes known integrations", func(t *testing.T) {
known := map[string]bool{"claude": false, "cline": false, "codex": false, "opencode": false, "omp": false}
known := map[string]bool{"claude": false, "cline": false, "codex": false, "opencode": false, "omp": false, "vscode": false}
if codexAppSupported() == nil {
known["codex-app"] = false
}
+1 -2
View File
@@ -33,7 +33,7 @@ type IntegrationInfo struct {
Description string
}
var launcherIntegrationOrder = []string{"claude", "codex-app", "hermes", "openclaw", "opencode", "hermes-desktop", "codex", "copilot", "omp", "cline", "droid", "pi", "pool", "qwen"}
var launcherIntegrationOrder = []string{"claude", "codex-app", "vscode", "hermes", "openclaw", "opencode", "hermes-desktop", "codex", "copilot", "omp", "cline", "droid", "pi", "pool", "qwen"}
var integrationSpecs = []*IntegrationSpec{
{
@@ -259,7 +259,6 @@ var integrationSpecs = []*IntegrationSpec{
Runner: &VSCode{},
Aliases: []string{"code"},
Description: "Microsoft's open-source AI code editor",
Hidden: true,
Install: IntegrationInstallSpec{
CheckInstalled: func() bool {
return (&VSCode{}).findBinary() != ""
+314 -119
View File
@@ -15,11 +15,12 @@ import (
_ "github.com/mattn/go-sqlite3"
"github.com/ollama/ollama/api"
"github.com/ollama/ollama/cmd/config"
"github.com/ollama/ollama/cmd/internal/fileutil"
"github.com/ollama/ollama/envconfig"
)
// VSCode implements Runner and Editor for Visual Studio Code integration.
// VSCode implements Runner and ManagedSingleModel for Visual Studio Code.
type VSCode struct{}
func (v *VSCode) String() string { return "Visual Studio Code" }
@@ -124,51 +125,69 @@ func (v *VSCode) Quit() {
const (
minCopilotChatVersion = "0.41.0"
minVSCodeVersion = "1.113"
vscodeOllamaVendor = "ollama"
vscodeOllamaName = "Ollama"
legacyVSCodeOllamaVendor = "ollama-vscode"
vscodeOllamaExtensionID = "Ollama.ollama"
legacyCopilotBYOKSetting = "github.copilot.chat.byok.ollamaEndpoint"
legacyLaunchConfiguredSetting = "ollama.launch.configured"
)
func (v *VSCode) Run(model string, _ []LaunchModel, args []string) error {
v.checkVSCodeVersion()
v.checkCopilotChatVersion()
extensionInstalled, err := v.ollamaExtensionInstalled()
if err != nil {
return err
}
running := v.IsRunning()
hasLegacyBYOK := v.hasLegacyCopilotBYOKSetting()
// Get all configured models (saved by the launcher framework before Run is called)
models := []string{model}
if cfg, err := loadStoredIntegrationConfig("vscode"); err == nil && len(cfg.Models) > 0 {
models = cfg.Models
setupConfirmed := false
if prompt := vscodeSetupPrompt(extensionInstalled, hasLegacyBYOK, running); prompt != "" {
ok, err := ConfirmPrompt(prompt)
if err != nil {
return err
}
if !ok {
return nil
}
setupConfirmed = true
}
if !extensionInstalled {
if err := v.installOllamaExtension(); err != nil {
return err
}
}
if hasLegacyBYOK {
if err := v.removeLegacyVSCodeSettings(); err != nil {
return err
}
}
// VS Code discovers models from ollama ls. Cloud models that pass Show
// (the server knows about them) but aren't in ls need to be pulled to
// register them so VS Code can find them.
if client, err := api.ClientFromEnvironment(); err == nil {
v.ensureModelsRegistered(context.Background(), client, models)
v.ensureModelsRegistered(context.Background(), client, []string{model})
}
// Warn if the default model doesn't support tool calling
if client, err := api.ClientFromEnvironment(); err == nil {
if resp, err := client.Show(context.Background(), &api.ShowRequest{Model: models[0]}); err == nil {
hasTools := false
for _, c := range resp.Capabilities {
if c == "tools" {
hasTools = true
break
}
if running {
restart := setupConfirmed
if !restart {
var err error
restart, err = ConfirmPrompt("Restart VS Code?")
if err != nil {
restart = false
}
if !hasTools {
fmt.Fprintf(os.Stderr, "Note: %s does not support tool calling and may not appear in the Copilot Chat model picker.\n", models[0])
}
}
}
v.printModelAccessTip()
if v.IsRunning() {
restart, err := ConfirmPrompt("Restart VS Code?")
if err != nil {
restart = false
}
if restart {
v.Quit()
if err := v.ShowInModelPicker(models); err != nil {
if err := v.ShowInModelPicker(model); err != nil {
fmt.Fprintf(os.Stderr, "%s Warning: could not update VS Code model picker: %v%s\n", ansiYellow, err, ansiReset)
}
v.FocusVSCode()
@@ -176,7 +195,7 @@ func (v *VSCode) Run(model string, _ []LaunchModel, args []string) error {
fmt.Fprintf(os.Stderr, "\nTo get the latest model configuration, restart VS Code when you're ready.\n")
}
} else {
if err := v.ShowInModelPicker(models); err != nil {
if err := v.ShowInModelPicker(model); err != nil {
fmt.Fprintf(os.Stderr, "%s Warning: could not update VS Code model picker: %v%s\n", ansiYellow, err, ansiReset)
}
v.FocusVSCode()
@@ -185,6 +204,28 @@ func (v *VSCode) Run(model string, _ []LaunchModel, args []string) error {
return nil
}
func (v *VSCode) Configure(model string) error {
if model == "" {
return nil
}
return v.writeProviderConfig()
}
func (v *VSCode) CurrentModel() string {
if cfg, err := loadStoredIntegrationConfig("vscode"); err == nil {
return primaryModelFromConfig(cfg)
}
return ""
}
func (v *VSCode) Onboard() error {
return config.MarkIntegrationOnboarded("vscode")
}
func (v *VSCode) RequiresInteractiveOnboarding() bool {
return false
}
// ensureModelsRegistered pulls models that the server knows about (Show succeeds)
// but aren't in ollama ls yet. This is needed for cloud models so that VS Code
// can discover them from the Ollama API.
@@ -225,25 +266,21 @@ func (v *VSCode) FocusVSCode() {
}
}
// printModelAccessTip shows instructions for finding Ollama models in VS Code.
func (v *VSCode) printModelAccessTip() {
fmt.Fprintf(os.Stderr, "\nTip: To use Ollama models, open Copilot Chat and click the model picker.\n")
fmt.Fprintf(os.Stderr, " If you don't see your models, click \"Other models\" to find them.\n\n")
}
func (v *VSCode) Paths() []string {
var paths []string
if p := v.chatLanguageModelsPath(); fileExists(p) {
return []string{p}
paths = append(paths, p)
}
return nil
}
func (v *VSCode) Edit(models []LaunchModel) error {
if len(models) == 0 {
if p := v.settingsPath(); fileExists(p) {
paths = append(paths, p)
}
if len(paths) == 0 {
return nil
}
return paths
}
// Write chatLanguageModels.json with Ollama vendor entry
func (v *VSCode) writeProviderConfig() error {
clmPath := v.chatLanguageModelsPath()
if err := os.MkdirAll(filepath.Dir(clmPath), 0o755); err != nil {
return err
@@ -254,18 +291,17 @@ func (v *VSCode) Edit(models []LaunchModel) error {
_ = json.Unmarshal(data, &entries)
}
// Remove any existing Ollama entries, preserve others
filtered := make([]map[string]any, 0, len(entries))
filtered := make([]map[string]any, 0, len(entries)+1)
for _, entry := range entries {
if vendor, _ := entry["vendor"].(string); vendor != "ollama" {
filtered = append(filtered, entry)
if isManagedOllamaProviderEntry(entry) {
continue
}
filtered = append(filtered, entry)
}
// Add new Ollama entry
filtered = append(filtered, map[string]any{
"vendor": "ollama",
"name": "Ollama",
"vendor": vscodeOllamaVendor,
"name": vscodeOllamaName,
"url": envconfig.Host().String(),
})
@@ -277,40 +313,26 @@ func (v *VSCode) Edit(models []LaunchModel) error {
return err
}
// Clean up legacy settings from older Ollama integrations
v.updateSettings()
return nil
return v.updateSettings()
}
func (v *VSCode) Models() []string {
if !v.hasOllamaVendor() {
return nil
func vscodeSetupPrompt(extensionInstalled, hasLegacyBYOK, running bool) string {
switch {
case !extensionInstalled && hasLegacyBYOK && running:
return "Install Ollama VS Code extension, remove old Copilot BYOK setting, and restart VS Code?"
case !extensionInstalled && hasLegacyBYOK:
return "Install Ollama VS Code extension and remove old Copilot BYOK setting?"
case !extensionInstalled && running:
return "Install Ollama VS Code extension and restart VS Code?"
case !extensionInstalled:
return "Install Ollama VS Code extension?"
case hasLegacyBYOK && running:
return "Remove old Copilot BYOK setting and restart VS Code?"
case hasLegacyBYOK:
return "Remove old Copilot BYOK setting?"
default:
return ""
}
if cfg, err := loadStoredIntegrationConfig("vscode"); err == nil {
return cfg.Models
}
return nil
}
// hasOllamaVendor checks if chatLanguageModels.json contains an Ollama vendor entry.
func (v *VSCode) hasOllamaVendor() bool {
data, err := os.ReadFile(v.chatLanguageModelsPath())
if err != nil {
return false
}
var entries []map[string]any
if err := json.Unmarshal(data, &entries); err != nil {
return false
}
for _, entry := range entries {
if vendor, _ := entry["vendor"].(string); vendor == "ollama" {
return true
}
}
return false
}
func (v *VSCode) chatLanguageModelsPath() string {
@@ -321,48 +343,120 @@ func (v *VSCode) settingsPath() string {
return v.vscodePath("settings.json")
}
// updateSettings cleans up legacy settings from older Ollama integrations.
func (v *VSCode) updateSettings() {
// updateSettings writes settings owned by the Ollama extension. User-facing
// legacy settings are removed only after an explicit launch prompt.
func (v *VSCode) updateSettings() error {
settingsPath := v.settingsPath()
data, err := os.ReadFile(settingsPath)
if err != nil {
return
if err := os.MkdirAll(filepath.Dir(settingsPath), 0o755); err != nil {
return err
}
var settings map[string]any
if err := json.Unmarshal(data, &settings); err != nil {
return
settings, err := v.readSettings()
if err != nil {
return err
}
changed := false
for _, key := range []string{"github.copilot.chat.byok.ollamaEndpoint", "ollama.launch.configured"} {
if _, ok := settings[legacyLaunchConfiguredSetting]; ok {
delete(settings, legacyLaunchConfiguredSetting)
changed = true
}
if current, _ := settings["ollama.endpoint"].(string); current != envconfig.Host().String() {
settings["ollama.endpoint"] = envconfig.Host().String()
changed = true
}
if !changed {
return nil
}
updated, err := json.MarshalIndent(settings, "", " ")
if err != nil {
return err
}
return fileutil.WriteWithBackup(settingsPath, updated, "vscode")
}
func (v *VSCode) readSettings() (map[string]any, error) {
settings := make(map[string]any)
data, err := os.ReadFile(v.settingsPath())
if err != nil {
if os.IsNotExist(err) {
return settings, nil
}
return nil, err
}
_ = json.Unmarshal(data, &settings)
return settings, nil
}
func (v *VSCode) hasLegacyCopilotBYOKSetting() bool {
settings, err := v.readSettings()
if err != nil {
return false
}
_, ok := settings[legacyCopilotBYOKSetting]
return ok
}
func (v *VSCode) removeLegacyVSCodeSettings() error {
settingsPath := v.settingsPath()
if err := os.MkdirAll(filepath.Dir(settingsPath), 0o755); err != nil {
return err
}
settings, err := v.readSettings()
if err != nil {
return err
}
changed := false
for _, key := range []string{legacyCopilotBYOKSetting, legacyLaunchConfiguredSetting} {
if _, ok := settings[key]; ok {
delete(settings, key)
changed = true
}
}
if !changed {
return
return nil
}
updated, err := json.MarshalIndent(settings, "", " ")
if err != nil {
return
return err
}
_ = fileutil.WriteWithBackup(settingsPath, updated, "vscode")
return fileutil.WriteWithBackup(settingsPath, updated, "vscode")
}
func isManagedOllamaProviderEntry(entry map[string]any) bool {
vendor, _ := entry["vendor"].(string)
if vendor == vscodeOllamaVendor || vendor == legacyVSCodeOllamaVendor {
return true
}
if vendor != "customendpoint" {
return false
}
name, _ := entry["name"].(string)
return strings.EqualFold(strings.TrimSpace(name), vscodeOllamaName)
}
func isManagedOllamaModelID(id string) bool {
for _, vendor := range []string{vscodeOllamaVendor, legacyVSCodeOllamaVendor} {
if strings.HasPrefix(id, vendor+"/") {
return true
}
}
return false
}
func (v *VSCode) statePath() string {
return v.vscodePath("globalStorage", "state.vscdb")
}
// ShowInModelPicker ensures the given models are visible in VS Code's Copilot
// Chat model picker. It sets the configured models to true in the picker
// preferences so they appear in the dropdown. Models use the VS Code identifier
// format "ollama/Ollama/<name>".
func (v *VSCode) ShowInModelPicker(models []string) error {
if len(models) == 0 {
// ShowInModelPicker selects the requested model in VS Code's Copilot Chat
// model picker. Model visibility is left to the extension, which discovers the
// full Ollama catalog.
func (v *VSCode) ShowInModelPicker(model string) error {
if model == "" {
return nil
}
@@ -394,19 +488,27 @@ func (v *VSCode) ShowInModelPicker(models []string) error {
_ = json.Unmarshal([]byte(prefsJSON), &prefs)
}
// Build name→ID map from VS Code's cached model list.
// VS Code uses numeric IDs like "ollama/Ollama/4", not "ollama/Ollama/kimi-k2.5:cloud".
// Build name→ID map from VS Code's cached model list. If VS Code has
// already resolved this provider, the cached identifier is the most exact
// representation of the model.
nameToID := make(map[string]string)
var cacheJSON string
if err := db.QueryRow("SELECT value FROM ItemTable WHERE key = 'chat.cachedLanguageModels.v2'").Scan(&cacheJSON); err == nil {
var cached []map[string]any
if json.Unmarshal([]byte(cacheJSON), &cached) == nil {
filtered := cached[:0]
cacheChanged := false
for _, entry := range cached {
if isStaleCachedOllamaModelEntry(entry) {
cacheChanged = true
continue
}
filtered = append(filtered, entry)
meta, _ := entry["metadata"].(map[string]any)
if meta == nil {
continue
}
if vendor, _ := meta["vendor"].(string); vendor == "ollama" {
if vendor, _ := meta["vendor"].(string); vendor == vscodeOllamaVendor {
name, _ := meta["name"].(string)
id, _ := entry["identifier"].(string)
if name != "" && id != "" {
@@ -414,21 +516,19 @@ func (v *VSCode) ShowInModelPicker(models []string) error {
}
}
}
if cacheChanged {
if data, err := json.Marshal(filtered); err == nil {
_, _ = db.Exec("INSERT OR REPLACE INTO ItemTable (key, value) VALUES ('chat.cachedLanguageModels.v2', ?)", string(data))
}
}
}
}
// Ollama config is authoritative: always show configured models,
// hide Ollama models that are no longer in the config.
configuredIDs := make(map[string]bool)
for _, m := range models {
for _, id := range v.modelVSCodeIDs(m, nameToID) {
prefs[id] = true
configuredIDs[id] = true
}
}
// The extension owns model discovery and picker visibility. Clear launch's
// old per-model overrides so previously hidden models can show up again.
for id := range prefs {
if strings.HasPrefix(id, "ollama/") && !configuredIDs[id] {
prefs[id] = false
if isManagedOllamaModelID(id) {
delete(prefs, id)
}
}
@@ -437,6 +537,11 @@ func (v *VSCode) ShowInModelPicker(models []string) error {
return err
}
selectedID := v.primaryModelVSCodeID(model, nameToID)
if err := v.selectChatModel(db, selectedID); err != nil {
return err
}
return nil
}
@@ -450,13 +555,56 @@ func (v *VSCode) modelVSCodeIDs(model string, nameToID map[string]string) []stri
ids = append(ids, id)
}
}
ids = append(ids, "ollama/Ollama/"+model)
if !strings.Contains(model, ":") {
ids = append(ids, "ollama/Ollama/"+model+":latest")
ids = append(ids, vscodeOllamaVendor+"/"+vscodeOllamaName+"/"+model+":latest")
ids = append(ids, vscodeOllamaVendor+"/"+vscodeOllamaName+"/"+model)
ids = append(ids, vscodeOllamaVendor+"/"+model+":latest")
ids = append(ids, vscodeOllamaVendor+"/"+model)
} else {
ids = append(ids, vscodeOllamaVendor+"/"+vscodeOllamaName+"/"+model)
ids = append(ids, vscodeOllamaVendor+"/"+model)
}
return ids
}
func (v *VSCode) primaryModelVSCodeID(model string, nameToID map[string]string) string {
return v.modelVSCodeIDs(model, nameToID)[0]
}
func isStaleCachedOllamaModelEntry(entry map[string]any) bool {
identifier, _ := entry["identifier"].(string)
return strings.HasPrefix(identifier, legacyVSCodeOllamaVendor+"/"+vscodeOllamaName+"/")
}
func (v *VSCode) selectChatModel(db *sql.DB, modelID string) error {
if _, err := db.Exec("INSERT OR REPLACE INTO ItemTable (key, value) VALUES ('chat.currentLanguageModel.panel', ?)", modelID); err != nil {
return err
}
if _, err := db.Exec("INSERT OR REPLACE INTO ItemTable (key, value) VALUES ('chat.currentLanguageModel.panel.isDefault', 'false')"); err != nil {
return err
}
recent := []string{}
var recentJSON string
if err := db.QueryRow("SELECT value FROM ItemTable WHERE key = 'chatModelRecentlyUsed'").Scan(&recentJSON); err == nil {
_ = json.Unmarshal([]byte(recentJSON), &recent)
}
updated := []string{modelID}
for _, id := range recent {
if id != modelID {
updated = append(updated, id)
}
}
if len(updated) > 20 {
updated = updated[:20]
}
data, _ := json.Marshal(updated)
_, err := db.Exec("INSERT OR REPLACE INTO ItemTable (key, value) VALUES ('chatModelRecentlyUsed', ?)", string(data))
return err
}
func (v *VSCode) vscodePath(parts ...string) string {
home, _ := os.UserHomeDir()
var base string
@@ -496,8 +644,10 @@ func (v *VSCode) checkVSCodeVersion() {
}
}
// checkCopilotChatVersion warns if the GitHub Copilot Chat extension is
// missing or older than minCopilotChatVersion.
// checkCopilotChatVersion warns if the explicitly installed GitHub Copilot
// Chat extension is older than the recommended version. Missing is not treated
// as an error because newer VS Code builds may provide chat without listing
// github.copilot-chat in --list-extensions output.
func (v *VSCode) checkCopilotChatVersion() {
codeCLI := v.findCodeCLI()
if codeCLI == "" {
@@ -511,8 +661,6 @@ func (v *VSCode) checkCopilotChatVersion() {
installed, version := parseCopilotChatVersion(string(out))
if !installed {
fmt.Fprintf(os.Stderr, "\n%sWarning: GitHub Copilot Chat extension is not installed%s\n", ansiYellow, ansiReset)
fmt.Fprintf(os.Stderr, "Install it in VS Code: Extensions → search \"GitHub Copilot Chat\" → Install\n\n")
return
}
if compareVersions(version, minCopilotChatVersion) < 0 {
@@ -521,6 +669,36 @@ func (v *VSCode) checkCopilotChatVersion() {
}
}
func (v *VSCode) ollamaExtensionInstalled() (bool, error) {
codeCLI := v.findCodeCLI()
if codeCLI == "" {
return false, fmt.Errorf("could not find VS Code CLI to install the Ollama extension")
}
out, err := exec.Command(codeCLI, "--list-extensions", "--show-versions").Output()
if err != nil {
return false, fmt.Errorf("could not list VS Code extensions: %w", err)
}
return hasVSCodeExtension(string(out), vscodeOllamaExtensionID), nil
}
func (v *VSCode) installOllamaExtension() error {
codeCLI := v.findCodeCLI()
if codeCLI == "" {
return fmt.Errorf("could not find VS Code CLI to install the Ollama extension")
}
fmt.Fprintf(os.Stderr, "Installing Ollama VS Code extension...\n")
if out, err := exec.Command(codeCLI, "--install-extension", vscodeOllamaExtensionID, "--force").CombinedOutput(); err != nil {
detail := strings.TrimSpace(string(out))
if detail != "" {
return fmt.Errorf("could not install Ollama VS Code extension: %w\n%s", err, detail)
}
return fmt.Errorf("could not install Ollama VS Code extension: %w", err)
}
return nil
}
// findCodeCLI returns the path to the VS Code CLI for querying extensions.
// On macOS, findBinary may return an .app bundle which can't run --list-extensions,
// so this resolves to the actual CLI binary inside the bundle.
@@ -556,6 +734,23 @@ func parseCopilotChatVersion(output string) (installed bool, version string) {
return false, ""
}
func hasVSCodeExtension(output, extensionID string) bool {
extensionID = strings.ToLower(extensionID)
for _, line := range strings.Split(output, "\n") {
id := strings.TrimSpace(line)
if id == "" {
continue
}
if beforeVersion, _, ok := strings.Cut(id, "@"); ok {
id = beforeVersion
}
if strings.ToLower(id) == extensionID {
return true
}
}
return false
}
// compareVersions compares two dot-separated version strings.
// Returns -1 if a < b, 0 if a == b, 1 if a > b.
func compareVersions(a, b string) int {
+442 -112
View File
@@ -10,6 +10,7 @@ import (
_ "github.com/mattn/go-sqlite3"
"github.com/ollama/ollama/cmd/internal/fileutil"
"github.com/ollama/ollama/envconfig"
)
func TestVSCodeIntegration(t *testing.T) {
@@ -25,81 +26,118 @@ func TestVSCodeIntegration(t *testing.T) {
var _ Runner = v
})
t.Run("implements Editor", func(t *testing.T) {
var _ Editor = v
t.Run("implements ManagedSingleModel", func(t *testing.T) {
var _ ManagedSingleModel = v
})
}
func TestVSCodeEdit(t *testing.T) {
func TestVSCodeConfigure(t *testing.T) {
v := &VSCode{}
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
t.Setenv("XDG_CONFIG_HOME", "")
clmPath := testVSCodePath(t, tmpDir, "chatLanguageModels.json")
settingsPath := testVSCodePath(t, tmpDir, "settings.json")
tests := []struct {
name string
setup string // initial chatLanguageModels.json content, empty means no file
models []string
validate func(t *testing.T, data []byte)
name string
setup string // initial chatLanguageModels.json content, empty means no file
model string
validate func(t *testing.T, clmData []byte, settingsData []byte)
wantSettings bool
}{
{
name: "fresh install",
models: []string{"llama3.2"},
validate: func(t *testing.T, data []byte) {
assertOllamaVendorConfigured(t, data)
name: "fresh install",
model: "llama3.2",
wantSettings: true,
validate: func(t *testing.T, clmData []byte, settingsData []byte) {
assertOllamaProviderConfigured(t, clmData)
assertSettingsConfigured(t, settingsData, nil)
},
},
{
name: "preserve other vendor entries",
setup: `[{"vendor": "azure", "name": "Azure", "url": "https://example.com"}]`,
models: []string{"llama3.2"},
validate: func(t *testing.T, data []byte) {
name: "preserve other vendor entries",
setup: `[{"vendor": "azure", "name": "Azure", "url": "https://example.com"}]`,
model: "llama3.2",
wantSettings: true,
validate: func(t *testing.T, clmData []byte, settingsData []byte) {
var entries []map[string]any
json.Unmarshal(data, &entries)
json.Unmarshal(clmData, &entries)
if len(entries) != 2 {
t.Errorf("expected 2 entries, got %d", len(entries))
}
// Check Azure entry preserved
found := false
for _, e := range entries {
if v, _ := e["vendor"].(string); v == "azure" {
found = true
foundAzure := false
for _, entry := range entries {
if vendor, _ := entry["vendor"].(string); vendor == "azure" {
foundAzure = true
}
}
if !found {
t.Error("azure vendor entry was not preserved")
if !foundAzure {
t.Fatalf("expected azure entry to be preserved, got %#v", entries)
}
assertOllamaVendorConfigured(t, data)
assertOllamaProviderConfigured(t, clmData)
assertSettingsConfigured(t, settingsData, nil)
},
},
{
name: "update existing ollama entry",
setup: `[{"vendor": "ollama", "name": "Ollama", "url": "http://old:11434"}]`,
models: []string{"llama3.2"},
validate: func(t *testing.T, data []byte) {
assertOllamaVendorConfigured(t, data)
name: "update existing ollama entry",
setup: `[{"vendor": "ollama", "name": "Ollama", "url": "http://old:11434"}]`,
model: "llama3.2",
wantSettings: true,
validate: func(t *testing.T, clmData []byte, settingsData []byte) {
assertOllamaProviderConfigured(t, clmData)
assertSettingsConfigured(t, settingsData, nil)
},
},
{
name: "empty models is no-op",
setup: `[{"vendor": "azure", "name": "Azure"}]`,
models: []string{},
validate: func(t *testing.T, data []byte) {
if string(data) != `[{"vendor": "azure", "name": "Azure"}]` {
t.Error("empty models should not modify file")
}
},
},
{
name: "corrupted JSON treated as empty",
setup: `{corrupted json`,
models: []string{"llama3.2"},
validate: func(t *testing.T, data []byte) {
name: "remove legacy, current, and old custom endpoint ollama entries",
setup: `[{"vendor": "ollama", "name": "Ollama", "url": "http://old:11434"},{"vendor": "ollama-vscode", "name": "Ollama", "url": "http://older:11434"},{"vendor": "customendpoint", "name": "Ollama", "url": "http://127.0.0.1:11434"},{"vendor": "azure", "name": "Azure"}]`,
model: "llama3.2",
wantSettings: true,
validate: func(t *testing.T, clmData []byte, settingsData []byte) {
var entries []map[string]any
if err := json.Unmarshal(data, &entries); err != nil {
t.Errorf("result is not valid JSON: %v", err)
if err := json.Unmarshal(clmData, &entries); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if len(entries) != 2 {
t.Fatalf("expected non-Ollama entry plus one configured provider, got %#v", entries)
}
managed := 0
for _, entry := range entries {
if isManagedOllamaProviderEntry(entry) {
managed++
}
}
if managed != 1 {
t.Fatalf("expected exactly one managed Ollama provider entry, got %#v", entries)
}
assertOllamaProviderConfigured(t, clmData)
assertSettingsConfigured(t, settingsData, nil)
},
},
{
name: "empty model is no-op",
setup: `[{"vendor": "azure", "name": "Azure"}]`,
model: "",
wantSettings: false,
validate: func(t *testing.T, clmData []byte, settingsData []byte) {
if string(clmData) != `[{"vendor": "azure", "name": "Azure"}]` {
t.Error("empty model should not modify file")
}
if len(settingsData) != 0 {
t.Fatalf("empty model should not create settings, got %q", string(settingsData))
}
},
},
{
name: "corrupted JSON treated as empty",
setup: `{corrupted json`,
model: "llama3.2",
wantSettings: true,
validate: func(t *testing.T, clmData []byte, settingsData []byte) {
assertOllamaProviderConfigured(t, clmData)
assertSettingsConfigured(t, settingsData, nil)
},
},
}
@@ -113,32 +151,73 @@ func TestVSCodeEdit(t *testing.T) {
os.WriteFile(clmPath, []byte(tt.setup), 0o644)
}
if err := v.Edit(launchModelsFromNames(tt.models)); err != nil {
if err := v.Configure(tt.model); err != nil {
t.Fatal(err)
}
data, _ := os.ReadFile(clmPath)
tt.validate(t, data)
clmData, _ := os.ReadFile(clmPath)
settingsData, _ := os.ReadFile(settingsPath)
if tt.wantSettings && len(settingsData) == 0 {
t.Fatalf("expected settings.json to be written")
}
tt.validate(t, clmData, settingsData)
})
}
}
func TestVSCodeEditCleansUpOldSettings(t *testing.T) {
func TestVSCodeConfigurePreservesCopilotBYOKSetting(t *testing.T) {
v := &VSCode{}
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
t.Setenv("XDG_CONFIG_HOME", "")
settingsPath := testVSCodePath(t, tmpDir, "settings.json")
// Create settings.json with old byok setting
os.MkdirAll(filepath.Dir(settingsPath), 0o755)
os.WriteFile(settingsPath, []byte(`{"github.copilot.chat.byok.ollamaEndpoint": "http://old:11434", "ollama.launch.configured": true, "editor.fontSize": 14}`), 0o644)
if err := v.Edit(testLaunchModels("llama3.2")); err != nil {
if err := v.Configure("llama3.2"); err != nil {
t.Fatal(err)
}
// Verify launch-owned settings were updated without removing Copilot BYOK.
data, err := os.ReadFile(settingsPath)
if err != nil {
t.Fatal(err)
}
var settings map[string]any
json.Unmarshal(data, &settings)
if settings["github.copilot.chat.byok.ollamaEndpoint"] != "http://old:11434" {
t.Error("github.copilot.chat.byok.ollamaEndpoint should be preserved until launch confirmation")
}
if _, ok := settings["ollama.launch.configured"]; ok {
t.Error("ollama.launch.configured should have been removed")
}
if settings["ollama.endpoint"] != envconfig.Host().String() {
t.Errorf("ollama.endpoint = %v, want %q", settings["ollama.endpoint"], envconfig.Host().String())
}
if settings["editor.fontSize"] != float64(14) {
t.Error("editor.fontSize should have been preserved")
}
}
func TestVSCodeRemoveLegacySettingsCleansUpOldSettings(t *testing.T) {
v := &VSCode{}
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
t.Setenv("XDG_CONFIG_HOME", "")
settingsPath := testVSCodePath(t, tmpDir, "settings.json")
os.MkdirAll(filepath.Dir(settingsPath), 0o755)
os.WriteFile(settingsPath, []byte(`{"github.copilot.chat.byok.ollamaEndpoint": "http://old:11434", "ollama.launch.configured": true, "editor.fontSize": 14}`), 0o644)
if !v.hasLegacyCopilotBYOKSetting() {
t.Fatal("expected legacy Copilot BYOK setting to be detected")
}
if err := v.removeLegacyVSCodeSettings(); err != nil {
t.Fatal(err)
}
// Verify old settings were removed
data, err := os.ReadFile(settingsPath)
if err != nil {
t.Fatal(err)
@@ -157,6 +236,93 @@ func TestVSCodeEditCleansUpOldSettings(t *testing.T) {
}
}
func TestVSCodeSetupPrompt(t *testing.T) {
tests := []struct {
name string
extensionInstalled bool
hasLegacyBYOK bool
running bool
want string
}{
{
name: "install cleanup and restart",
extensionInstalled: false,
hasLegacyBYOK: true,
running: true,
want: "Install Ollama VS Code extension, remove old Copilot BYOK setting, and restart VS Code?",
},
{
name: "install and cleanup",
extensionInstalled: false,
hasLegacyBYOK: true,
want: "Install Ollama VS Code extension and remove old Copilot BYOK setting?",
},
{
name: "install and restart",
extensionInstalled: false,
running: true,
want: "Install Ollama VS Code extension and restart VS Code?",
},
{
name: "install only",
extensionInstalled: false,
want: "Install Ollama VS Code extension?",
},
{
name: "cleanup and restart",
extensionInstalled: true,
hasLegacyBYOK: true,
running: true,
want: "Remove old Copilot BYOK setting and restart VS Code?",
},
{
name: "cleanup only",
extensionInstalled: true,
hasLegacyBYOK: true,
want: "Remove old Copilot BYOK setting?",
},
{
name: "no setup needed",
extensionInstalled: true,
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := vscodeSetupPrompt(tt.extensionInstalled, tt.hasLegacyBYOK, tt.running)
if got != tt.want {
t.Fatalf("vscodeSetupPrompt() = %q, want %q", got, tt.want)
}
})
}
}
func TestVSCodeLegacyBYOKDetectionAddsCleanupToSetupPrompt(t *testing.T) {
v := &VSCode{}
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
t.Setenv("XDG_CONFIG_HOME", "")
settingsPath := testVSCodePath(t, tmpDir, "settings.json")
if err := os.MkdirAll(filepath.Dir(settingsPath), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(settingsPath, []byte(`{"github.copilot.chat.byok.ollamaEndpoint":"http://127.0.0.1:11434"}`), 0o644); err != nil {
t.Fatal(err)
}
if !v.hasLegacyCopilotBYOKSetting() {
t.Fatal("expected legacy Copilot BYOK setting to be detected")
}
got := vscodeSetupPrompt(false, v.hasLegacyCopilotBYOKSetting(), true)
want := "Install Ollama VS Code extension, remove old Copilot BYOK setting, and restart VS Code?"
if got != want {
t.Fatalf("vscodeSetupPrompt() = %q, want %q", got, want)
}
}
func TestVSCodeEdit_CreatesDistinctBackupsForManagedFiles(t *testing.T) {
v := &VSCode{}
tmpDir := t.TempDir()
@@ -180,7 +346,7 @@ func TestVSCodeEdit_CreatesDistinctBackupsForManagedFiles(t *testing.T) {
t.Fatal(err)
}
if err := v.Edit(testLaunchModels("llama3.2")); err != nil {
if err := v.Configure("llama3.2"); err != nil {
t.Fatal(err)
}
@@ -209,9 +375,11 @@ func TestVSCodePaths(t *testing.T) {
setTestHome(t, tmpDir)
t.Setenv("XDG_CONFIG_HOME", "")
clmPath := testVSCodePath(t, tmpDir, "chatLanguageModels.json")
settingsPath := testVSCodePath(t, tmpDir, "settings.json")
t.Run("no file returns nil", func(t *testing.T) {
os.Remove(clmPath)
os.Remove(settingsPath)
if paths := v.Paths(); paths != nil {
t.Errorf("expected nil, got %v", paths)
}
@@ -225,6 +393,16 @@ func TestVSCodePaths(t *testing.T) {
t.Errorf("expected 1 path, got %d", len(paths))
}
})
t.Run("settings file is included", func(t *testing.T) {
os.Remove(clmPath)
os.MkdirAll(filepath.Dir(settingsPath), 0o755)
os.WriteFile(settingsPath, []byte(`{}`), 0o644)
if paths := v.Paths(); len(paths) != 1 || paths[0] != settingsPath {
t.Errorf("expected settings path, got %v", paths)
}
})
}
// testVSCodePath returns the expected VS Code config path for the given file in tests.
@@ -241,25 +419,52 @@ func testVSCodePath(t *testing.T, tmpDir, filename string) string {
}
}
func assertOllamaVendorConfigured(t *testing.T, data []byte) {
func assertOllamaProviderConfigured(t *testing.T, data []byte) {
t.Helper()
var entries []map[string]any
if err := json.Unmarshal(data, &entries); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
var managed []map[string]any
for _, entry := range entries {
if vendor, _ := entry["vendor"].(string); vendor == "ollama" {
if name, _ := entry["name"].(string); name != "Ollama" {
t.Errorf("expected name \"Ollama\", got %q", name)
}
if url, _ := entry["url"].(string); url == "" {
t.Error("url not set")
}
return
if isManagedOllamaProviderEntry(entry) {
managed = append(managed, entry)
}
}
if len(managed) != 1 {
t.Fatalf("expected exactly one managed Ollama provider, got %#v", entries)
}
entry := managed[0]
if vendor, _ := entry["vendor"].(string); vendor != vscodeOllamaVendor {
t.Fatalf("vendor = %q, want %q", vendor, vscodeOllamaVendor)
}
if name, _ := entry["name"].(string); name != vscodeOllamaName {
t.Fatalf("name = %q, want %q", name, vscodeOllamaName)
}
if url, _ := entry["url"].(string); url != envconfig.Host().String() {
t.Fatalf("url = %q, want %q", url, envconfig.Host().String())
}
}
func assertSettingsConfigured(t *testing.T, data []byte, extras map[string]any) {
t.Helper()
var settings map[string]any
if err := json.Unmarshal(data, &settings); err != nil {
t.Fatalf("invalid settings JSON: %v", err)
}
if settings["ollama.endpoint"] != envconfig.Host().String() {
t.Fatalf("ollama.endpoint = %v, want %q", settings["ollama.endpoint"], envconfig.Host().String())
}
if _, ok := settings["ollama.launch.configured"]; ok {
t.Fatal("ollama.launch.configured should have been removed")
}
for key, want := range extras {
if settings[key] != want {
t.Fatalf("%s = %v, want %v", key, settings[key], want)
}
}
t.Error("no ollama vendor entry found")
}
func TestShowInModelPicker(t *testing.T) {
@@ -310,7 +515,7 @@ func TestShowInModelPicker(t *testing.T) {
return prefs
}
t.Run("fresh DB creates table and shows models", func(t *testing.T) {
t.Run("fresh DB creates table and selects model", func(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
t.Setenv("XDG_CONFIG_HOME", "")
@@ -318,67 +523,59 @@ func TestShowInModelPicker(t *testing.T) {
t.Setenv("APPDATA", tmpDir)
}
err := v.ShowInModelPicker([]string{"llama3.2"})
err := v.ShowInModelPicker("llama3.2")
if err != nil {
t.Fatal(err)
}
dbPath := testVSCodePath(t, tmpDir, filepath.Join("globalStorage", "state.vscdb"))
prefs := readPrefs(t, dbPath)
if !prefs["ollama/Ollama/llama3.2"] {
t.Error("expected llama3.2 to be shown")
}
if !prefs["ollama/Ollama/llama3.2:latest"] {
t.Error("expected llama3.2:latest to be shown")
if len(prefs) != 0 {
t.Fatalf("expected no model picker overrides, got %#v", prefs)
}
assertSelectedChatModel(t, dbPath, vscodeOllamaVendor+"/"+vscodeOllamaName+"/llama3.2:latest")
})
t.Run("configured models are shown", func(t *testing.T) {
t.Run("selected model is stored", func(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
t.Setenv("XDG_CONFIG_HOME", "")
dbPath := setupDB(t, testVSCodePath(t, tmpDir, ""), nil, nil)
err := v.ShowInModelPicker([]string{"llama3.2", "qwen3:8b"})
err := v.ShowInModelPicker("llama3.2")
if err != nil {
t.Fatal(err)
}
prefs := readPrefs(t, dbPath)
if !prefs["ollama/Ollama/llama3.2"] {
t.Error("expected llama3.2 to be shown")
}
if !prefs["ollama/Ollama/qwen3:8b"] {
t.Error("expected qwen3:8b to be shown")
if len(prefs) != 0 {
t.Fatalf("expected no model picker overrides, got %#v", prefs)
}
assertSelectedChatModel(t, dbPath, vscodeOllamaVendor+"/"+vscodeOllamaName+"/llama3.2:latest")
})
t.Run("removed models are hidden", func(t *testing.T) {
t.Run("old launch-managed model visibility overrides are cleared", func(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
t.Setenv("XDG_CONFIG_HOME", "")
dbPath := setupDB(t, testVSCodePath(t, tmpDir, ""), map[string]bool{
"ollama/Ollama/llama3.2": true,
"ollama/Ollama/llama3.2:latest": true,
"ollama/Ollama/mistral": true,
"ollama/Ollama/mistral:latest": true,
vscodeOllamaVendor + "/llama3.2": true,
vscodeOllamaVendor + "/llama3.2:latest": true,
vscodeOllamaVendor + "/mistral": true,
vscodeOllamaVendor + "/mistral:latest": true,
legacyVSCodeOllamaVendor + "/llama3.2": true,
}, nil)
// Only configure llama3.2 — mistral should get hidden
err := v.ShowInModelPicker([]string{"llama3.2"})
err := v.ShowInModelPicker("llama3.2")
if err != nil {
t.Fatal(err)
}
prefs := readPrefs(t, dbPath)
if !prefs["ollama/Ollama/llama3.2"] {
t.Error("expected llama3.2 to stay shown")
}
if prefs["ollama/Ollama/mistral"] {
t.Error("expected mistral to be hidden")
}
if prefs["ollama/Ollama/mistral:latest"] {
t.Error("expected mistral:latest to be hidden")
for id := range prefs {
if isManagedOllamaModelID(id) {
t.Fatalf("expected managed Ollama overrides to be cleared, got %#v", prefs)
}
}
})
@@ -390,7 +587,7 @@ func TestShowInModelPicker(t *testing.T) {
"copilot/gpt-4o": true,
}, nil)
err := v.ShowInModelPicker([]string{"llama3.2"})
err := v.ShowInModelPicker("llama3.2")
if err != nil {
t.Fatal(err)
}
@@ -401,62 +598,178 @@ func TestShowInModelPicker(t *testing.T) {
}
})
t.Run("uses cached numeric IDs when available", func(t *testing.T) {
t.Run("uses cached IDs when available", func(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
t.Setenv("XDG_CONFIG_HOME", "")
cache := []map[string]any{
{
"identifier": "ollama/Ollama/4",
"metadata": map[string]any{"vendor": "ollama", "name": "llama3.2"},
"identifier": vscodeOllamaVendor + "/4",
"metadata": map[string]any{"vendor": vscodeOllamaVendor, "name": "llama3.2"},
},
}
dbPath := setupDB(t, testVSCodePath(t, tmpDir, ""), nil, cache)
err := v.ShowInModelPicker([]string{"llama3.2"})
err := v.ShowInModelPicker("llama3.2")
if err != nil {
t.Fatal(err)
}
prefs := readPrefs(t, dbPath)
if !prefs["ollama/Ollama/4"] {
t.Error("expected numeric ID ollama/Ollama/4 to be shown")
}
// Name-based fallback should also be set
if !prefs["ollama/Ollama/llama3.2"] {
t.Error("expected name-based ID to also be shown")
if len(prefs) != 0 {
t.Fatalf("expected no model picker overrides, got %#v", prefs)
}
assertSelectedChatModel(t, dbPath, vscodeOllamaVendor+"/4")
})
t.Run("empty models is no-op", func(t *testing.T) {
err := v.ShowInModelPicker([]string{})
t.Run("keeps current named cache entries and removes legacy named entries", func(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
t.Setenv("XDG_CONFIG_HOME", "")
cache := []map[string]any{
{
"identifier": vscodeOllamaVendor + "/qwen3.6-latest-8e5d0015",
"metadata": map[string]any{"vendor": vscodeOllamaVendor, "name": "qwen3.6:latest"},
},
{
"identifier": vscodeOllamaVendor + "/" + vscodeOllamaName + "/qwen3.6-latest-8e5d0015",
"metadata": map[string]any{"vendor": vscodeOllamaVendor, "name": "qwen3.6:latest", "detail": vscodeOllamaName},
},
{
"identifier": legacyVSCodeOllamaVendor + "/" + vscodeOllamaName + "/qwen3.6-latest-8e5d0015",
"metadata": map[string]any{"vendor": legacyVSCodeOllamaVendor, "name": "qwen3.6:latest", "detail": vscodeOllamaName},
},
}
dbPath := setupDB(t, testVSCodePath(t, tmpDir, ""), nil, cache)
err := v.ShowInModelPicker("qwen3.6:latest")
if err != nil {
t.Fatal(err)
}
assertSelectedChatModel(t, dbPath, vscodeOllamaVendor+"/"+vscodeOllamaName+"/qwen3.6-latest-8e5d0015")
assertNoStaleCachedOllamaEntries(t, dbPath)
assertCachedLanguageModelPresent(t, dbPath, vscodeOllamaVendor+"/"+vscodeOllamaName+"/qwen3.6-latest-8e5d0015")
})
t.Run("empty model is no-op", func(t *testing.T) {
err := v.ShowInModelPicker("")
if err != nil {
t.Fatal(err)
}
})
t.Run("previously hidden model is re-shown when configured", func(t *testing.T) {
t.Run("previously hidden selected model uses extension default visibility", func(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
t.Setenv("XDG_CONFIG_HOME", "")
dbPath := setupDB(t, testVSCodePath(t, tmpDir, ""), map[string]bool{
"ollama/Ollama/llama3.2": false,
"ollama/Ollama/llama3.2:latest": false,
vscodeOllamaVendor + "/llama3.2": false,
vscodeOllamaVendor + "/llama3.2:latest": false,
}, nil)
// Ollama config is authoritative — should override the hidden state
err := v.ShowInModelPicker([]string{"llama3.2"})
err := v.ShowInModelPicker("llama3.2")
if err != nil {
t.Fatal(err)
}
prefs := readPrefs(t, dbPath)
if !prefs["ollama/Ollama/llama3.2"] {
t.Error("expected llama3.2 to be re-shown")
for id := range prefs {
if isManagedOllamaModelID(id) {
t.Fatalf("expected managed Ollama overrides to be cleared, got %#v", prefs)
}
}
})
}
func assertSelectedChatModel(t *testing.T, dbPath, want string) {
t.Helper()
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
t.Fatal(err)
}
defer db.Close()
var selected string
if err := db.QueryRow("SELECT value FROM ItemTable WHERE key = 'chat.currentLanguageModel.panel'").Scan(&selected); err != nil {
t.Fatal(err)
}
if selected != want {
t.Fatalf("selected model = %q, want %q", selected, want)
}
var isDefault string
if err := db.QueryRow("SELECT value FROM ItemTable WHERE key = 'chat.currentLanguageModel.panel.isDefault'").Scan(&isDefault); err != nil {
t.Fatal(err)
}
if isDefault != "false" {
t.Fatalf("selected model default flag = %q, want false", isDefault)
}
var recentJSON string
if err := db.QueryRow("SELECT value FROM ItemTable WHERE key = 'chatModelRecentlyUsed'").Scan(&recentJSON); err != nil {
t.Fatal(err)
}
var recent []string
if err := json.Unmarshal([]byte(recentJSON), &recent); err != nil {
t.Fatal(err)
}
if len(recent) == 0 || recent[0] != want {
t.Fatalf("recent models = %#v, want %q first", recent, want)
}
}
func assertNoStaleCachedOllamaEntries(t *testing.T, dbPath string) {
t.Helper()
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
t.Fatal(err)
}
defer db.Close()
var cacheJSON string
if err := db.QueryRow("SELECT value FROM ItemTable WHERE key = 'chat.cachedLanguageModels.v2'").Scan(&cacheJSON); err != nil {
t.Fatal(err)
}
var cached []map[string]any
if err := json.Unmarshal([]byte(cacheJSON), &cached); err != nil {
t.Fatal(err)
}
for _, entry := range cached {
if isStaleCachedOllamaModelEntry(entry) {
t.Fatalf("stale cached Ollama entry still present: %#v", entry)
}
}
}
func assertCachedLanguageModelPresent(t *testing.T, dbPath, want string) {
t.Helper()
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
t.Fatal(err)
}
defer db.Close()
var cacheJSON string
if err := db.QueryRow("SELECT value FROM ItemTable WHERE key = 'chat.cachedLanguageModels.v2'").Scan(&cacheJSON); err != nil {
t.Fatal(err)
}
var cached []map[string]any
if err := json.Unmarshal([]byte(cacheJSON), &cached); err != nil {
t.Fatal(err)
}
for _, entry := range cached {
if id, _ := entry["identifier"].(string); id == want {
return
}
}
t.Fatalf("cached language model %q not found in %#v", want, cached)
}
func TestParseCopilotChatVersion(t *testing.T) {
tests := []struct {
name string
@@ -507,6 +820,23 @@ func TestParseCopilotChatVersion(t *testing.T) {
}
}
func TestHasVSCodeExtension(t *testing.T) {
output := "github.copilot-chat@0.41.0\nollama.ollama@0.0.1\nms-python.python\n"
if !hasVSCodeExtension(output, "ollama.ollama") {
t.Fatal("expected Ollama extension to be detected")
}
if !hasVSCodeExtension(output, "Ollama.Ollama") {
t.Fatal("expected extension detection to be case-insensitive")
}
if hasVSCodeExtension(output, "ollama.missing") {
t.Fatal("unexpected missing extension detected")
}
if !hasVSCodeExtension(output, "ms-python.python") {
t.Fatal("expected extension without version to be detected")
}
}
func TestCompareVersions(t *testing.T) {
tests := []struct {
a, b string