diff --git a/src/web/public/panels-ui.js b/src/web/public/panels-ui.js index c8967801..7a0ccca9 100644 --- a/src/web/public/panels-ui.js +++ b/src/web/public/panels-ui.js @@ -5485,19 +5485,19 @@ Object.assign(CodemanApp.prototype, { }, /** - * `duration` defaults to 0 (sticky, no auto-dismiss) for `error` toasts and - * 3000ms for everything else — an error worth a distinct visual style is - * also worth reading before it vanishes, which a fixed 3s auto-dismiss - * does not guarantee: "Session started on the native backend — could not - * apply the custom endpoint: " is exactly the kind of - * message that needs a moment to read, not a glance. Every toast gets an - * explicit close button regardless of duration, since a sticky one with no - * way to dismiss it would just pile up. A caller can still override either - * default via `opts.duration` (e.g. a deliberately brief success toast, or - * a non-error one that should also stay put). + * `duration` defaults to 3000ms for every toast type. A message worth + * reading rather than glancing at (e.g. "Session started on the native + * backend — could not apply the custom endpoint: ") + * passes an explicit `opts.duration: 0` at its own call site instead of + * widening the default: this used to default every `error` toast to + * sticky, and with no cap on `.toast-container` and no eviction, a + * repeatedly failing path (a flapping SSE reconnect, a poll loop) stacked + * sticky toasts off the bottom of the viewport where they could not be + * read or dismissed. Every toast still gets an explicit close button + * regardless of duration. */ showToast(message, type = 'info', opts = {}) { - const { duration = type === 'error' ? 0 : 3000, action } = opts; + const { duration = 3000, action } = opts; const toast = document.createElement('div'); toast.className = `toast toast-${type}`; diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index bc8d71de..eff06f6d 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -962,7 +962,9 @@ Object.assign(CodemanApp.prototype, { if (!ok || !data || data.success === false) { switchingToast?.dismiss(); const detail = data?.error ? `: ${data.error}` : res ? ` (HTTP ${res.status})` : ' (request failed)'; - this.showToast(`Session started on the native backend — could not apply the custom endpoint${detail}`, 'error'); + this.showToast(`Session started on the native backend — could not apply the custom endpoint${detail}`, 'error', { + duration: 0, + }); return; } diff --git a/src/web/routes/custom-model-routes.ts b/src/web/routes/custom-model-routes.ts index d55aebfd..3dac13c9 100644 --- a/src/web/routes/custom-model-routes.ts +++ b/src/web/routes/custom-model-routes.ts @@ -108,6 +108,18 @@ function applyStoredApiKey(incoming: CustomModelHost, existing: CustomModelHost) return incoming.apiKey ? incoming : { ...incoming, apiKey: existing.apiKey }; } +/** + * `modelContextLengths`/`modelSizesGB` are server-populated by discovery, never + * user-entered, and PUT replaces the whole record — so merge them back in from the + * stored host rather than trust whatever the editor's body carried (or omitted). + * The editor only ever sends `models`/`lastDiscoveredAt` verbatim from its cached + * copy; requiring it to also round-trip these two is exactly the kind of thing a + * future caller forgets, same class of bug `applyStoredApiKey` exists to prevent. + */ +function applyDiscoveredFields(incoming: CustomModelHost, existing: CustomModelHost): CustomModelHost { + return { ...incoming, modelContextLengths: existing.modelContextLengths, modelSizesGB: existing.modelSizesGB }; +} + function authHeaders(host: Pick): Record { const headers: Record = {}; const apiKey = host.apiKey?.trim(); @@ -705,7 +717,7 @@ export function registerCustomModelRoutes(app: FastifyInstance): void { const hosts = await readCustomModelHosts(CODEMAN_CONFIG_DIR); const index = hosts.findIndex((item) => item.id === id); if (index === -1) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Model endpoint not found'); - const host = applyStoredApiKey(incoming, hosts[index]); + const host = applyDiscoveredFields(applyStoredApiKey(incoming, hosts[index]), hosts[index]); const next = [...hosts]; next[index] = host; await writeCustomModelHosts(CODEMAN_CONFIG_DIR, next); diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index f47eebfc..62b07ff7 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -3576,6 +3576,12 @@ export function registerSessionRoutes( // a lighter version of them, since this is the same server-side authority reached a // different way, not a separate, less-checked path. let qsCustomModelEnvOverrides = qsGatedEnvOverrides; + // Only the INJECTED keys (never the caller's envOverrides merged in) — this is what + // setCustomModel() bookkeeping must be given below. The Session constructor already + // applies qsCustomModelEnvOverrides (the full merged set) directly; re-merging that + // full set into setCustomModel() would put CLAUDE_CODE_EFFORT_LEVEL back after the + // constructor stripped it (see setCustomModel()'s own doc comment in session.ts). + let qsCustomModelAppliedEnvOverrides: Record | undefined; let qsCustomModelLaunchModel: string | undefined; let qsCustomModelSessionId: string | undefined; let qsCustomModelSwapInProgress = false; @@ -3670,6 +3676,7 @@ export function registerSessionRoutes( } qsCustomModelEnvOverrides = { ...qsGatedEnvOverrides, ...cmApplied.envOverrides }; + qsCustomModelAppliedEnvOverrides = cmApplied.envOverrides; qsCustomModelLaunchModel = cmApplied.launchModel; qsCustomModelBookkeeping = { endpointId: cmEndpoint.id, @@ -3689,7 +3696,7 @@ export function registerSessionRoutes( } } - const session = new Session({ + const qsSessionOptions: ConstructorParameters[0] = { id: qsCustomModelSessionId, workingDir: resolvedCasePath, name: sessionName ? sessionName.slice(0, MAX_SESSION_NAME_LENGTH) : '', @@ -3705,23 +3712,10 @@ export function registerSessionRoutes( codexConfig: mode === 'codex' ? qsGatedCodexConfig : undefined, geminiConfig: mode === 'gemini' ? qsGatedGeminiConfig : undefined, antigravityConfig: mode === 'antigravity' ? qsGatedAntigravityConfig : undefined, - piConfig: - mode === 'pi' - ? qsCustomModelLaunchModel !== undefined - ? { ...(qsGatedPiConfig ?? {}), model: qsCustomModelLaunchModel } - : qsGatedPiConfig - : undefined, - grokConfig: - mode === 'grok' - ? qsCustomModelLaunchModel !== undefined - ? { ...(qsGatedGrokConfig ?? {}), model: qsCustomModelLaunchModel } - : qsGatedGrokConfig - : undefined, + piConfig: mode === 'pi' ? qsGatedPiConfig : undefined, + grokConfig: mode === 'grok' ? qsGatedGrokConfig : undefined, deepSeekConfig: mode === 'deepseek' ? qsGatedDeepSeekConfig : undefined, - ompConfig: - mode === 'omp' && qsCustomModelLaunchModel !== undefined - ? { ...(qsResolvedOmpConfig ?? {}), model: qsCustomModelLaunchModel } - : qsResolvedOmpConfig, + ompConfig: qsResolvedOmpConfig, envOverrides: qsCustomModelEnvOverrides, effort, remote, @@ -3729,7 +3723,25 @@ export function registerSessionRoutes( resumeSessionId: dockerResumeId, tmuxHistoryLimit: qsTerminalHistoryConfig.tmuxHistoryLimit, parentSessionId: qsParentSessionId, - }); + }; + // Force the custom-model selection's launchModel (pi/omp `custom/`, grok's + // `[model.]` block name) onto whichever config field the registry says the + // CLI's `model` launch param lives in — mirrors Session._withCustomModelLaunchModel, + // which the restart-in-place path already uses, rather than a hardcoded per-CLI + // branch here that a CLI landing its injection recipe later would silently miss. + if (qsCustomModelLaunchModel !== undefined) { + const qsCustomModelField = getCli(mode)?.launch.legacyConfigField; + if (qsCustomModelField) { + const qsSessionOptionsBag = qsSessionOptions as unknown as Record; + qsSessionOptionsBag[qsCustomModelField] = { + ...((qsSessionOptionsBag[qsCustomModelField] as Record) ?? {}), + model: qsCustomModelLaunchModel, + }; + } else { + qsSessionOptions.model = qsCustomModelLaunchModel; + } + } + const session = new Session(qsSessionOptions); // Records the selection for session.customModel/getCustomModelForPersist() and future // clear/switch calls — the actual env vars and launch-model config are already part of @@ -3737,7 +3749,7 @@ export function registerSessionRoutes( // this is bookkeeping only, never a restart: setCustomModel() is synchronous state, no // tmux IO of its own (see its own doc comment in session.ts). if (qsCustomModelBookkeeping) { - session.setCustomModel(qsCustomModelBookkeeping, qsCustomModelEnvOverrides); + session.setCustomModel(qsCustomModelBookkeeping, qsCustomModelAppliedEnvOverrides); } // Auto-detect completion phrase from CLAUDE.md BEFORE broadcasting diff --git a/src/web/server.ts b/src/web/server.ts index 52e5b225..cfb49ead 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -2373,6 +2373,7 @@ export class WebServer extends EventEmitter { 'scheduled:', 'team:', 'case:', + 'custom-model:', ]; if (SESSION_PREFIXES.some((p) => event.startsWith(p))) { const d = (data ?? {}) as { sessionId?: string; id?: string; session?: { id?: string } }; diff --git a/test/cli-registry-no-id-branching.test.ts b/test/cli-registry-no-id-branching.test.ts index b5ba1b3e..8d1bc4de 100644 --- a/test/cli-registry-no-id-branching.test.ts +++ b/test/cli-registry-no-id-branching.test.ts @@ -80,8 +80,6 @@ const ALLOWED_BRANCHES: Record = { "web/routes/session-routes.ts::mode === 'pi'": 'legacy Config plumbing', "web/routes/session-routes.ts::mode === 'grok'": 'legacy Config plumbing', "web/routes/session-routes.ts::mode === 'deepseek'": 'legacy Config plumbing', - "web/routes/session-routes.ts::mode === 'omp'": - 'legacy Config plumbing (custom-model launchModel merge onto ompConfig, same selection resolveOmpConfigForCreate already makes internally)', "web/server.ts::mode === 'opencode'": 'legacy Config plumbing (session recovery)', "web/server.ts::mode === 'codex'": 'legacy Config plumbing (session recovery)', "web/server.ts::mode === 'gemini'": 'legacy Config plumbing (session recovery)',