diff --git a/src/main/config/config-store.ts b/src/main/config/config-store.ts index 9b1dd9519..fa50d8aa7 100644 --- a/src/main/config/config-store.ts +++ b/src/main/config/config-store.ts @@ -1020,6 +1020,14 @@ export class ConfigStore { enableThinking: projected.enableThinking, isConfigured: toBoolean(raw.isConfigured, defaultConfig.isConfigured), }; + // Only set when the profile has a real value: electron-store rejects an explicit + // `undefined` for a present key (see normalizeProfile's own convention above). + if (typeof projected.contextWindow === 'number' && projected.contextWindow > 0) { + result.contextWindow = projected.contextWindow; + } + if (typeof projected.maxTokens === 'number' && projected.maxTokens > 0) { + result.maxTokens = projected.maxTokens; + } this.normalizeModelIds(result); return result; } @@ -1045,7 +1053,7 @@ export class ConfigStore { const activeConfigSet = nextConfigSets.find((set) => set.id === requestedActiveConfigSetId) || nextConfigSets[0]; const projected = this.projectFromConfigSet(activeConfigSet); - return { + const result: AppConfig = { ...base, provider: projected.provider, customProtocol: projected.customProtocol, @@ -1058,6 +1066,19 @@ export class ConfigStore { activeConfigSetId: activeConfigSet.id, configSets: nextConfigSets, }; + // Clear rather than leave stale: `...base` can carry the previous set's value, and + // electron-store rejects an explicit `undefined` for a present key. + if (typeof projected.contextWindow === 'number' && projected.contextWindow > 0) { + result.contextWindow = projected.contextWindow; + } else { + delete result.contextWindow; + } + if (typeof projected.maxTokens === 'number' && projected.maxTokens > 0) { + result.maxTokens = projected.maxTokens; + } else { + delete result.maxTokens; + } + return result; } private buildUniqueConfigSetName( diff --git a/src/main/index.ts b/src/main/index.ts index 0a2c0ff31..1c1f0d03e 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1911,6 +1911,8 @@ const buildAgentRuntimeSignature = (config: AppConfig): string => baseUrl: config.baseUrl, customProtocol: config.customProtocol, model: config.model, + contextWindow: config.contextWindow, + maxTokens: config.maxTokens, enableThinking: config.enableThinking, memoryEnabled: config.memoryEnabled, memoryRuntime: config.memoryRuntime, diff --git a/tests/config-store-context-window-projection.test.ts b/tests/config-store-context-window-projection.test.ts new file mode 100644 index 000000000..fb8d185d2 --- /dev/null +++ b/tests/config-store-context-window-projection.test.ts @@ -0,0 +1,177 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + seed: {} as Record, +})); + +vi.mock('electron-store', () => { + class MockStore> { + public store: Record; + public path = '/tmp/mock-config-store.json'; + + constructor(options: { defaults?: Record }) { + this.store = { + ...(options?.defaults || {}), + ...mocks.seed, + }; + } + + get(key: K): T[K] { + return this.store[key as string] as T[K]; + } + + set(key: string | Record, value?: unknown): void { + if (typeof key === 'string') { + this.store[key] = value; + return; + } + this.store = { + ...this.store, + ...key, + }; + } + + clear(): void { + this.store = {}; + } + } + + return { + default: MockStore, + }; +}); + +import { ConfigStore } from '../src/main/config/config-store'; + +describe('ConfigStore contextWindow/maxTokens projection', () => { + beforeEach(() => { + mocks.seed = {}; + }); + + it('projects a custom/Ollama profile contextWindow and maxTokens onto the flat config getAll() returns', () => { + const store = new ConfigStore(); + + store.update({ + provider: 'ollama', + apiKey: '', + baseUrl: 'http://localhost:11434/v1', + model: 'llama3.3', + profiles: { + ollama: { + apiKey: '', + baseUrl: 'http://localhost:11434/v1', + model: 'llama3.3', + contextWindow: 32000, + maxTokens: 8000, + }, + }, + }); + + const config = store.getAll(); + expect(config.contextWindow).toBe(32000); + expect(config.maxTokens).toBe(8000); + }); + + it('keeps contextWindow/maxTokens projected after switching config sets', () => { + const store = new ConfigStore(); + + store.update({ + provider: 'ollama', + apiKey: '', + baseUrl: 'http://localhost:11434/v1', + model: 'llama3.3', + profiles: { + ollama: { + apiKey: '', + baseUrl: 'http://localhost:11434/v1', + model: 'llama3.3', + contextWindow: 32000, + maxTokens: 8000, + }, + }, + }); + expect(store.getAll().contextWindow).toBe(32000); + expect(store.getAll().maxTokens).toBe(8000); + + const created = store.createSet({ name: 'Second set', mode: 'blank' }); + const secondSetId = created.configSets.find((set) => set.id !== 'default')!.id; + + store.update({ + provider: 'custom', + customProtocol: 'openai', + apiKey: 'sk-custom', + baseUrl: 'https://relay.example.com/v1', + model: 'my-model', + profiles: { + 'custom:openai': { + apiKey: 'sk-custom', + baseUrl: 'https://relay.example.com/v1', + model: 'my-model', + contextWindow: 64000, + maxTokens: 16000, + }, + }, + }); + expect(store.getAll().contextWindow).toBe(64000); + expect(store.getAll().maxTokens).toBe(16000); + + store.switchSet({ id: 'default' }); + const defaultSetView = store.getAll(); + expect(defaultSetView.contextWindow).toBe(32000); + expect(defaultSetView.maxTokens).toBe(8000); + + store.switchSet({ id: secondSetId }); + const secondSetView = store.getAll(); + expect(secondSetView.contextWindow).toBe(64000); + expect(secondSetView.maxTokens).toBe(16000); + }); + + it('does not crash on construction when the active profile has no contextWindow/maxTokens override', () => { + expect(() => new ConfigStore()).not.toThrow(); + const config = new ConfigStore().getAll(); + expect(config.contextWindow).toBeUndefined(); + expect(config.maxTokens).toBeUndefined(); + }); + + it('clears contextWindow/maxTokens when switching to a set whose active profile has no override', () => { + const store = new ConfigStore(); + + store.update({ + provider: 'ollama', + apiKey: '', + baseUrl: 'http://localhost:11434/v1', + model: 'llama3.3', + profiles: { + ollama: { + apiKey: '', + baseUrl: 'http://localhost:11434/v1', + model: 'llama3.3', + contextWindow: 32000, + maxTokens: 8000, + }, + }, + }); + expect(store.getAll().contextWindow).toBe(32000); + + const created = store.createSet({ name: 'No override set', mode: 'blank' }); + const secondSetId = created.configSets.find((set) => set.id !== 'default')!.id; + + expect(() => + store.update({ + provider: 'openrouter', + apiKey: 'sk-or', + model: 'anthropic/claude', + }) + ).not.toThrow(); + const secondSetView = store.getAll(); + expect(secondSetView.contextWindow).toBeUndefined(); + expect(secondSetView.maxTokens).toBeUndefined(); + + store.switchSet({ id: 'default' }); + expect(store.getAll().contextWindow).toBe(32000); + + store.switchSet({ id: secondSetId }); + expect(store.getAll().contextWindow).toBeUndefined(); + expect(store.getAll().maxTokens).toBeUndefined(); + }); +}); diff --git a/tests/index-agent-runtime-signature.test.ts b/tests/index-agent-runtime-signature.test.ts new file mode 100644 index 000000000..d39944f93 --- /dev/null +++ b/tests/index-agent-runtime-signature.test.ts @@ -0,0 +1,17 @@ +import { describe, it, expect } from 'vitest'; +import path from 'node:path'; +import fs from 'node:fs'; + +const indexPath = path.resolve(process.cwd(), 'src/main/index.ts'); + +describe('buildAgentRuntimeSignature', () => { + it('includes contextWindow and maxTokens so a numeric-only change reloads the running session', () => { + const source = fs.readFileSync(indexPath, 'utf8'); + const signatureBlock = source.match( + /const buildAgentRuntimeSignature[\s\S]*?\n \}\);/ + )?.[0] || ''; + + expect(signatureBlock).toContain('contextWindow: config.contextWindow'); + expect(signatureBlock).toContain('maxTokens: config.maxTokens'); + }); +});