diff --git a/src/client/chat/controller.ts b/src/client/chat/controller.ts index 73eea92..ff6a231 100644 --- a/src/client/chat/controller.ts +++ b/src/client/chat/controller.ts @@ -43,6 +43,18 @@ export interface ChatUnavailable { canResume: boolean; } +/** + * What actually happened when this browser last asked to change the model. + * + * Mirrors the server's own honesty about it: a typed model name is never + * validated ahead of time, so this reports what was actually possible rather + * than assuming the best case. + */ +export interface ModelSwitchResult { + applied: 'live' | 'sent' | 'pending' | 'cleared'; + message: string; +} + /** * How long a page request may go unanswered before the control comes back. * @@ -66,6 +78,20 @@ export class ChatController { /** Set while the conversation is readable but has nothing running it. */ private unavailable: ChatUnavailable | null = null; + /** + * The model override this conversation is carrying, independent of what the + * runtime itself reports through the transcript. + * + * Null means "no override" — the surface then falls back to whatever the + * transcript's own `model` says, which is the runtime's default or the + * active profile. Kept here rather than folded into the transcript because + * it is a fact about the *session record*, not something any adapter emits + * as an event. + */ + private modelOverride: string | null = null; + /** What the server reported happened to the last model change requested. */ + private modelResult: ModelSwitchResult | null = null; + constructor( readonly sessionId: string, private readonly options: ChatControllerOptions, @@ -91,6 +117,8 @@ export class ChatController { if (!snapshot) return true; this.settlePage(); this.transcript.hydrate(snapshot); + this.modelOverride = + typeof message.modelOverride === 'string' ? message.modelOverride : null; this.options.onChange?.(); return true; } @@ -111,6 +139,25 @@ export class ChatController { // message rather than assumed, so the badge cannot claim a mode the // process is not running in. this.transcript.setBypassing(message.bypassPermissions === true); + this.modelOverride = + typeof message.modelOverride === 'string' ? message.modelOverride : null; + this.options.onChange?.(); + return true; + } + + case 'chat_model_result': { + const applied = (message.applied as ModelSwitchResult['applied']) || 'pending'; + // Only 'live'/'cleared' mean the session is actually running this model now. + // 'sent'/'pending' are best-effort or deferred-to-next-launch — adopting the + // label for those would claim a model is active when it is not yet, or may + // never be without a relaunch. + if (applied === 'live' || applied === 'cleared') { + this.modelOverride = typeof message.model === 'string' ? message.model : null; + } + this.modelResult = { + applied, + message: String(message.message || ''), + }; this.options.onChange?.(); return true; } @@ -266,6 +313,28 @@ export class ChatController { this.send({ type: 'chat_permission_response', requestId, optionId }); } + /** The model override in force for this conversation, or null if there is none. */ + get modelOverrideValue(): string | null { + return this.modelOverride; + } + + /** What happened the last time this browser asked to change the model. */ + get modelFeedback(): ModelSwitchResult | null { + return this.modelResult; + } + + /** + * Ask the server to switch this conversation's model, or clear the override + * with an empty string. + * + * Never validated here: the composer sends whatever was typed, and the + * server's reply — live, sent, or saved-for-next-time — is what actually + * tells the user what happened. + */ + setModel(model: string): void { + this.send({ type: 'chat_set_model', model }); + } + /** Tell the server this browser wants this conversation's live events. */ subscribe(): void { this.send({ type: 'chat_subscribe' }); @@ -332,6 +401,11 @@ export class ChatController { /** Drop everything, e.g. when the session is being restarted. */ reset(): void { this.settlePage(); + // Not a claim either way: the next snapshot or chat_started carries the + // record's real override, and showing a stale one in the meantime would + // be worse than showing nothing. + this.modelOverride = null; + this.modelResult = null; // `hydrate` clears the queue from the (absent) snapshot field, so the line // does not survive into a session that never accepted it. this.transcript.hydrate({ diff --git a/src/client/shell/chat/ChatView.tsx b/src/client/shell/chat/ChatView.tsx index ee31aed..71c64d0 100644 --- a/src/client/shell/chat/ChatView.tsx +++ b/src/client/shell/chat/ChatView.tsx @@ -239,6 +239,10 @@ export function ChatView({ (queuedId: string) => controller.cancelQueued(queuedId), [controller], ); + const setModel = React.useCallback( + (model: string) => controller.setModel(model), + [controller], + ); const upload = React.useCallback( (file: File) => uploadAttachment(controller.sessionId, file), [controller], @@ -710,7 +714,14 @@ export function ChatView({ branch={branch} turnLabel={currentTurn ? `turn ${currentTurn.index}` : undefined} usage={transcript.usage} - model={transcript.model} + // The conversation's own override wins over whatever the + // runtime last reported, once it is confirmed live (or cleared). + // A pending/sent switch is not yet running and is surfaced via + // modelFeedback instead, so this label never claims a model the + // session isn't actually on. + model={controller.modelOverrideValue ?? transcript.model} + onSetModel={setModel} + modelFeedback={controller.modelFeedback} bypassPermissions={bypassPermissions} terminalOpen={terminalOpen} /> diff --git a/src/client/shell/chat/Composer.tsx b/src/client/shell/chat/Composer.tsx index e8dfe95..999b8db 100644 --- a/src/client/shell/chat/Composer.tsx +++ b/src/client/shell/chat/Composer.tsx @@ -79,6 +79,14 @@ export interface ComposerProps { * entry of the menu as the current value is right only by accident. */ model?: string; + /** + * Change this conversation's model, independent of the runtime's own + * default. Always available — see ModelChip — regardless of whether the + * runtime advertises a model list or a live switch. + */ + onSetModel?: (model: string) => void; + /** What the server reported about the last `onSetModel` call, for the chip. */ + modelFeedback?: { applied: 'live' | 'sent' | 'pending' | 'cleared'; message: string } | null; /** Drives what the permission chip reports. */ bypassPermissions?: boolean; /** Changes what the hint row says about who owns Return. */ @@ -160,6 +168,8 @@ export function Composer({ turnLabel, usage, model, + onSetModel, + modelFeedback, bypassPermissions = false, terminalOpen = false, }: ComposerProps) { @@ -762,9 +772,8 @@ export function Composer({ onSend(`/model ${value}`, [])} + feedback={modelFeedback} + onPick={(value) => onSetModel?.(value)} /> @@ -1020,38 +1029,43 @@ function Chip({ } /** - * The model, and the one honest way to change it. + * The model, and the one always-honest way to change it. * - * There is no protocol message for "switch model" — every runtime that supports - * it does so through its own slash command. So the picker is live exactly when - * the runtime advertises both a model list and a `/model` command, and sends - * that command as an ordinary turn. Anything else would be a control that looks - * like it worked. + * There is no protocol message every runtime answers to for "switch model" — + * some can rewrite it live, most only take it through their own `/model` + * command or at spawn — so this control never claims to know in advance which + * of those a pick will get. It always accepts a choice, from the list when the + * runtime has one and from free text regardless, sends it, and reports back + * whatever the server says actually happened. A disabled control that told the + * user to start a new session was a worse answer than trying and being honest + * about the result. */ function ModelChip({ current, models, - commands, - disabled, onPick, + feedback, }: { /** What the session reported it is running, when it reported anything. */ current: string | undefined; models: ModelChoice[] | undefined; - commands: SlashCommand[]; - disabled: boolean; onPick: (value: string) => void; -}): React.JSX.Element | null { + /** What the server said happened to the last pick made here. */ + feedback?: { applied: 'live' | 'sent' | 'pending' | 'cleared'; message: string } | null; +}): React.JSX.Element { const [open, setOpen] = React.useState(false); + const [customValue, setCustomValue] = React.useState(''); const ref = React.useRef(null); - const switchable = Boolean(models && models.length) && commands.some((c) => c.name === 'model'); + const inputRef = React.useRef(null); // The session's own model wins. `models` is a menu in whatever order the // runtime listed it, and its first entry is the current one only by accident. const matched = models?.find((m) => m.value === current || m.name === current); - const label = matched?.name ?? current ?? models?.[0]?.name; + const label = matched?.name ?? current ?? 'model'; React.useEffect(() => { if (!open) return; + setCustomValue(''); + inputRef.current?.focus(); const onPointerDown = (event: MouseEvent): void => { if (!ref.current?.contains(event.target as Node)) setOpen(false); }; @@ -1066,29 +1080,25 @@ function ModelChip({ }; }, [open]); - // Nothing to report and nothing to offer. - if (!label) return null; + const pick = (value: string): void => { + setOpen(false); + onPick(value); + }; - if (!switchable) { - return ( - - {label} - - ); - } + const submitCustom = (): void => { + const value = customValue.trim(); + if (!value) return; + pick(value); + }; return (
+
+ + {(models ?? []).map((choice) => ( + ))} + + {!models?.length ? ( +
+ This runtime hasn't listed models — type one above. +
+ ) : null} + + ) : null} + + {!open && feedback ? ( +
+ {feedback.message}
) : null} diff --git a/src/server/chat/manager.ts b/src/server/chat/manager.ts index 8629052..0ab04a2 100644 --- a/src/server/chat/manager.ts +++ b/src/server/chat/manager.ts @@ -207,6 +207,13 @@ export class ChatSessionManager { await this.sessions.get(sessionId)?.interrupt(); } + /** Switch a live session's model. False when nothing is running, or the adapter cannot. */ + async setModel(sessionId: string, model: string): Promise { + const session = this.sessions.get(sessionId); + if (!session) return false; + return session.setModel(model); + } + /** Drop a turn that was typed ahead and has not run yet. */ cancelQueued(sessionId: string, queuedId: string): boolean { return this.sessions.get(sessionId)?.cancelQueued(queuedId) ?? false; diff --git a/src/server/chat/session.ts b/src/server/chat/session.ts index 0ce29d0..bf826b9 100644 --- a/src/server/chat/session.ts +++ b/src/server/chat/session.ts @@ -643,6 +643,21 @@ export class ChatSession { }); } + /** + * Switch the live process to a different model, for the adapters that can. + * + * Only Grok exposes this today — its model is a per-invocation flag it can + * rewrite for the next turn without a restart. Every other adapter's model + * is fixed at spawn, so this reports it could not and the caller falls back + * to the runtime's own `/model` command (best-effort) or to persisting the + * choice for the next session. + */ + async setModel(model: string): Promise { + if (!this.adapter?.alive || !this.adapter.setModel) return false; + await this.adapter.setModel(model); + return true; + } + snapshot(): Promise { return this.deps.store.snapshot(this.ref).then((snapshot) => ({ ...snapshot, diff --git a/src/server/services/database.ts b/src/server/services/database.ts index c453fc2..1bde8e1 100644 --- a/src/server/services/database.ts +++ b/src/server/services/database.ts @@ -384,6 +384,11 @@ export class AppDatabase { // permission on because a column was absent is not a mistake to leave // available. INTEGER because SQLite has no boolean. this.addColumnIfMissing('runtime_sessions', 'chat_bypass_permissions', 'INTEGER'); + + // The model this conversation overrides its runtime/profile default with. + // Nullable and null-by-default: every row written before this column + // existed has no override recorded, which is exactly what a null means. + this.addColumnIfMissing('runtime_sessions', 'chat_model_override', 'TEXT'); } /** diff --git a/src/server/services/session-store.ts b/src/server/services/session-store.ts index b1daceb..3a1eb44 100644 --- a/src/server/services/session-store.ts +++ b/src/server/services/session-store.ts @@ -35,6 +35,7 @@ interface RuntimeSessionRow { native_chat_session_id: string | null; owner_session_id: string | null; chat_bypass_permissions: number | null; + chat_model_override: string | null; } export class SessionStore { @@ -72,7 +73,8 @@ export class SessionStore { surface, native_chat_session_id, owner_session_id, - chat_bypass_permissions + chat_bypass_permissions, + chat_model_override ) VALUES ( @id, @@ -94,7 +96,8 @@ export class SessionStore { @surface, @native_chat_session_id, @owner_session_id, - @chat_bypass_permissions + @chat_bypass_permissions, + @chat_model_override ) `); @@ -157,6 +160,10 @@ export class SessionStore { // restart brings it back rather than quietly dropping to manual — and so // the header can state the mode of a conversation with nothing running. chat_bypass_permissions: session.chatBypassPermissions === true ? 1 : null, + // The conversation-scoped model override, if the user has set one. + // Persisted so a restart still prefers it over the profile default the + // next time a session starts for this conversation. + chat_model_override: session.chatModelOverride || null, })); replaceAll(rows); @@ -193,7 +200,8 @@ export class SessionStore { surface, native_chat_session_id, owner_session_id, - chat_bypass_permissions + chat_bypass_permissions, + chat_model_override FROM runtime_sessions ORDER BY created_at ASC `) @@ -238,6 +246,11 @@ export class SessionStore { // written before this column existed carries — reads as "asks first", // so a missing answer can never grant a standing permission. chatBypassPermissions: row.chat_bypass_permissions === 1 ? true : undefined, + // A stored empty string never happens — the write side always writes + // null for "no override" — but an empty string reading as "no + // override" rather than a call to switch to nothing is the safe + // direction regardless. + chatModelOverride: row.chat_model_override || undefined, }); } diff --git a/src/server/types.ts b/src/server/types.ts index 9280808..dde1320 100644 --- a/src/server/types.ts +++ b/src/server/types.ts @@ -105,6 +105,20 @@ export interface SessionRecord { * standing permission can never be inherited by another conversation. */ chatBypassPermissions?: boolean; + /** + * The model this conversation runs with, independent of the runtime's own + * default or the active profile. + * + * Set from a free-typed choice in the composer, not validated against + * anything: the acceptance test for a model name is whether the runtime + * accepts it, not whether it looks plausible before it is tried. Scoped to + * this record only — it is never written back as a profile or personal + * default, so it cannot leak into another conversation's launch. + * + * Absent means "no override", which reads as the profile default exactly + * like every row written before this existed. + */ + chatModelOverride?: string; terminalOptions: TerminalOptions | null; stopRequested: boolean; /** Identifies the current PTY run so late callbacks from a previous run are ignored. */ diff --git a/src/server/websocket/messages.ts b/src/server/websocket/messages.ts index 32a03f9..8892e03 100644 --- a/src/server/websocket/messages.ts +++ b/src/server/websocket/messages.ts @@ -88,6 +88,8 @@ export interface ChatManagerLike { snapshot(record: SessionRecord): Promise; send(sessionId: string, turn: { text: string; attachments?: unknown[] }): Promise; interrupt(sessionId: string): Promise; + /** Switch a live session's model. False when nothing is running, or the adapter cannot. */ + setModel(sessionId: string, model: string): Promise; cancelQueued(sessionId: string, queuedId: string): boolean; respondPermission(sessionId: string, requestId: string, optionId: string): boolean; stop(sessionId: string): Promise; @@ -118,6 +120,8 @@ interface IncomingMessage { agentKind?: string; /** Identifies one turn waiting in the send-ahead queue. */ queuedId?: string; + /** A conversation-scoped model to switch to, or null/empty to clear the override. */ + model?: string | null; } export class MessageProcessor { @@ -199,6 +203,10 @@ export class MessageProcessor { await this.handleChatInterrupt(wsInfo, data); break; + case 'chat_set_model': + await this.handleChatSetModel(wsInfo, data); + break; + case 'chat_queue_cancel': this.handleChatQueueCancel(wsInfo, data); break; @@ -466,7 +474,9 @@ export class MessageProcessor { // // Profile values are deliberately *not* read from `options`: everything in // safeOptions came from the browser, and the whole point of the profile is - // that it is server-side configuration the client cannot forge. + // that it is server-side configuration the client cannot forge. (`model` + // is the one exception below: a conversation's own override is allowed to + // beat it, but only for that conversation, never written back to the profile.) const profile = this.deps.resolveRuntimeProfile(agentKind, session.workingDir); if (profile) { if (profile.model) safeOptions.model = profile.model; @@ -474,6 +484,12 @@ export class MessageProcessor { if (profile.env && Object.keys(profile.env).length) safeOptions.env = profile.env; console.log(`Applying runtime profile "${profile.profileName}" to ${agentKind}`); } + // A model chosen for this conversation beats the profile default, but only + // for this one launch: it is never written back as a new profile or + // personal default. + if (session.chatModelOverride) { + safeOptions.model = session.chatModelOverride; + } // The scrollback recorder is born on the first output byte, before any // resize message can arrive, so the geometry the run starts at has to be @@ -975,7 +991,9 @@ export class MessageProcessor { const chat = await manager.start(session, { runtime: agentKind, workingDir: session.workingDir, - model: profile?.model, + // Conversation-scoped override beats the profile default, for this + // launch only — see chat_set_model. + model: session.chatModelOverride || profile?.model, extraArgs: profile?.extraArgs, env: profile?.env, bypassPermissions, @@ -1012,6 +1030,7 @@ export class MessageProcessor { workingDir: session.workingDir, capabilities: chat.currentCapabilities, bypassPermissions: chat.bypassing, + modelOverride: session.chatModelOverride || null, }, this.deps.claudeSessions, this.deps.webSocketConnections, @@ -1054,7 +1073,12 @@ export class MessageProcessor { try { const snapshot = await manager.snapshot(session); - sendToWebSocket(wsInfo.ws, { type: 'chat_snapshot', sessionId, snapshot }); + sendToWebSocket(wsInfo.ws, { + type: 'chat_snapshot', + sessionId, + snapshot, + modelOverride: session.chatModelOverride || null, + }); } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); sendToWebSocket(wsInfo.ws, { @@ -1137,6 +1161,111 @@ export class MessageProcessor { await manager.interrupt(session.id).catch(() => undefined); } + /** + * Change the model for one conversation, independent of the runtime's own + * default or the active profile. + * + * Never pre-validated: what a typed name actually does is only known by + * trying it, so this always persists the choice and then reports what + * happened, in order of how good an answer it is — live, best-effort sent as + * a turn, or merely saved for the next launch. It is deliberately never + * written back as a profile or personal default; the override belongs to + * this conversation and nothing else. + */ + private async handleChatSetModel( + wsInfo: WebSocketInfo, + data: IncomingMessage, + ): Promise { + const session = this.chatSessionFor(wsInfo, data.sessionId); + if (!session) return; + + const raw = typeof data.model === 'string' ? data.model.trim() : ''; + const model = raw || undefined; + session.chatModelOverride = model; + await this.deps.saveSessionsToDisk(); + + if (!model) { + sendToWebSocket(wsInfo.ws, { + type: 'chat_model_result', + sessionId: session.id, + model: null, + applied: 'cleared', + message: 'Cleared the model override. The next session for this conversation will use the runtime default.', + }); + return; + } + + const manager = this.deps.chatManager; + if (!manager) { + sendToWebSocket(wsInfo.ws, { + type: 'chat_model_result', + sessionId: session.id, + model, + applied: 'pending', + message: `Saved. ${model} will be used the next time a session starts for this conversation.`, + }); + return; + } + + try { + const applied = await manager.setModel(session.id, model); + if (applied) { + sendToWebSocket(wsInfo.ws, { + type: 'chat_model_result', + sessionId: session.id, + model, + applied: 'live', + message: `Switched to ${model} for this conversation.`, + }); + return; + } + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + sendToWebSocket(wsInfo.ws, { + type: 'chat_model_result', + sessionId: session.id, + model, + applied: 'pending', + message: `Saved, but switching live failed (${message}). ${model} will be used the next time a session starts.`, + }); + return; + } + + // The adapter cannot switch live. If the runtime advertises its own + // `/model` command, best-effort send it as an ordinary turn — the same + // mechanism the old switchable picker used — and say so honestly: the + // CLI's own reply is the real confirmation, not this message. + const snapshot = await manager.snapshot(session).catch(() => null) as + | { live?: boolean; capabilities?: { commands?: { name: string }[] } } + | null; + const live = snapshot?.live === true; + const hasModelCommand = snapshot?.capabilities?.commands?.some((c) => c.name === 'model') ?? false; + + if (live && hasModelCommand) { + try { + await manager.send(session.id, { text: `/model ${model}` }); + sendToWebSocket(wsInfo.ws, { + type: 'chat_model_result', + sessionId: session.id, + model, + applied: 'sent', + message: `Sent "/model ${model}" to the session — check the transcript to confirm it took.`, + }); + return; + } catch { + // Falls through to the saved-for-next-time answer below. + } + } + + sendToWebSocket(wsInfo.ws, { + type: 'chat_model_result', + sessionId: session.id, + model, + applied: 'pending', + message: `Saved. This runtime cannot change model mid-session — ${model} will be used the next time a new session starts for this conversation.`, + }); + } + /** * Withdraw a turn the user typed ahead. * diff --git a/test/chat-composer.test.js b/test/chat-composer.test.js index 9457c25..c3a1b46 100644 --- a/test/chat-composer.test.js +++ b/test/chat-composer.test.js @@ -205,6 +205,53 @@ describe('Composer', function () { assert.ok(/]*disabled=""/.test(html), 'the textarea itself must go inert'); }); + // ModelChip's own open/closed state is a plain click-toggled useState, unlike + // the slash-command popup above (whose "open" is derived from the draft, so + // static markup can render it open). A static render can only ever show it + // closed, so these assert what is true of every runtime regardless of + // whether it has a model list, a live switch, or neither — the always-on + // control and its labelling — rather than the popup's own contents. + describe('the model control', function () { + it('is always present and never disabled, even when the whole composer is', function () { + const html = render({ disabled: true, capabilities: caps({}) }); + assert.ok(html.includes('aria-label="Change model"'), 'a runtime with no model list or /model command still gets a control'); + assert.ok( + !/aria-label="Change model"[^>]*disabled/.test(html), + 'the model control must not be disabled by the composer being disabled — that is exactly the case where it saves for next session', + ); + }); + + it('labels the conversation’s own model over the menu’s first entry', function () { + const models = [ + { value: 'a', name: 'Model A' }, + { value: 'b', name: 'Model B' }, + ]; + const html = render({ model: 'b', capabilities: caps({ models }) }); + assert.ok(html.includes('>Model B<'), 'the session’s actual model is shown, not the first item in the menu'); + }); + + it('falls back to whatever was typed when it matches nothing in the menu', function () { + const html = render({ model: 'a-custom-name', capabilities: caps({}) }); + assert.ok(html.includes('>a-custom-name<'), 'a free-typed override with no matching menu entry is still the label'); + }); + + it('surfaces the server’s feedback after a pick, in each of the three shapes', function () { + const live = render({ model: 'grok-3-fast', modelFeedback: { applied: 'live', message: 'Switched to grok-3-fast for this conversation.' } }); + assert.ok(live.includes('Switched to grok-3-fast for this conversation.')); + + const sent = render({ model: 'claude-opus', modelFeedback: { applied: 'sent', message: 'Sent "/model claude-opus" to the session — check the transcript to confirm it took.' } }); + assert.ok(sent.includes('check the transcript to confirm it took')); + + const pending = render({ model: 'some-model', modelFeedback: { applied: 'pending', message: 'Saved. some-model will be used the next time a new session starts for this conversation.' } }); + assert.ok(pending.includes('will be used the next time a new session starts')); + }); + + it('shows nothing extra when there is no feedback yet', function () { + const html = render({ capabilities: caps({}) }); + assert.ok(!html.includes('role="status"'), 'no stray feedback region before anything has been picked'); + }); + }); + it('survives an empty draft and a very long one', function () { const empty = render({ draft: '' }); assert.ok(empty.length > 0); diff --git a/test/chat-model-controller.test.js b/test/chat-model-controller.test.js new file mode 100644 index 0000000..f6f7180 --- /dev/null +++ b/test/chat-model-controller.test.js @@ -0,0 +1,70 @@ +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +// A `chat_model_result` of 'pending'/'sent' means the switch is not actually +// running yet — only 'live'/'cleared' confirm the session is on the named +// model. The label the UI shows must track only the confirmed state: adopting +// it on 'pending' would tell the user a model is active before it ever ran, +// and possibly forever if the conversation is never relaunched. + +const ROOT = path.join(__dirname, '..'); + +let mod; +let bundle; + +before(function () { + this.timeout(60000); + const contents = [ + `export { ChatController } from ${JSON.stringify(path.join(ROOT, 'src/client/chat/controller'))};`, + ].join('\n'); + + bundle = path.join(os.tmpdir(), `chat-model-controller-${process.pid}.js`); + require('esbuild').buildSync({ + stdin: { contents, resolveDir: ROOT, loader: 'ts', sourcefile: 'chat-model-controller.ts' }, + bundle: true, + outfile: bundle, + format: 'cjs', + platform: 'node', + target: ['node20'], + logLevel: 'silent', + }); + mod = require(bundle); +}); + +after(function () { + if (bundle) fs.rmSync(bundle, { force: true }); +}); + +function controller() { + return new mod.ChatController('s1', { send: () => {} }); +} + +describe('the model override label', function () { + it('adopts the switched model once the server confirms it is live', function () { + const c = controller(); + c.handle({ type: 'chat_model_result', sessionId: 's1', model: 'grok-3-fast', applied: 'live', message: 'ok' }); + assert.strictEqual(c.modelOverrideValue, 'grok-3-fast'); + }); + + it('does not adopt the label while a switch is only pending the next session', function () { + const c = controller(); + c.handle({ type: 'chat_model_result', sessionId: 's1', model: 'some-model', applied: 'pending', message: 'saved' }); + assert.strictEqual(c.modelOverrideValue, null, 'not running this model yet, so the label must not claim it is'); + assert.strictEqual(c.modelFeedback.applied, 'pending', 'the banner still reports what happened'); + }); + + it('does not adopt the label for a best-effort slash command still awaiting confirmation', function () { + const c = controller(); + c.handle({ type: 'chat_model_result', sessionId: 's1', model: 'claude-opus', applied: 'sent', message: 'sent' }); + assert.strictEqual(c.modelOverrideValue, null); + }); + + it('adopts null immediately when the override is cleared', function () { + const c = controller(); + c.handle({ type: 'chat_model_result', sessionId: 's1', model: 'grok-3-fast', applied: 'live', message: 'ok' }); + c.handle({ type: 'chat_model_result', sessionId: 's1', model: null, applied: 'cleared', message: 'cleared' }); + assert.strictEqual(c.modelOverrideValue, null); + }); +}); diff --git a/test/chat-wiring.test.js b/test/chat-wiring.test.js index c6e65eb..ad92737 100644 --- a/test/chat-wiring.test.js +++ b/test/chat-wiring.test.js @@ -38,10 +38,14 @@ function createSessionRecord(params = {}) { /** A chat manager that records what it was asked to do. */ function createChatManager(overrides = {}) { - const calls = { start: [], send: [], interrupt: [], permission: [], page: [], cancelQueued: [] }; + const calls = { start: [], send: [], interrupt: [], permission: [], page: [], cancelQueued: [], setModel: [] }; return { calls, has: () => false, + async setModel(sessionId, model) { + calls.setModel.push({ sessionId, model }); + return false; + }, async start(record, options) { calls.start.push({ record, options }); return { @@ -231,6 +235,27 @@ describe('chat wiring', function () { }); }); + describe('startRuntime (PTY)', function () { + it('lets a saved model override outrank the profile default on this launch too', async function () { + const { processor, session } = build({ surface: undefined }); + session.chatModelOverride = 'saved-override'; + processor.deps.resolveRuntimeProfile = () => ({ profileName: 'p', model: 'profile-default' }); + + const startCalls = []; + processor.deps.getRuntimeBridge = () => ({ + startSession: async (sessionId, options) => { + startCalls.push(options); + return { pid: 1 }; + }, + }); + + await processor.startRuntime('ws-1', 'claude', {}); + + assert.strictEqual(startCalls.length, 1, 'the bridge was not asked to start'); + assert.strictEqual(startCalls[0].model, 'saved-override'); + }); + }); + // The approval mode is part of how the user set a conversation up, so it has // to outlive the process serving it. It used to live only on the live // ChatSession: reconnecting to a conversation whose agent had gone, restarting @@ -404,6 +429,121 @@ describe('chat wiring', function () { }); }); + describe('chat_set_model', function () { + it('applies live when the adapter can switch without a restart', async function () { + const { processor, chatManager, session, sent } = build( + { surface: 'chat' }, + { setModel: async (sessionId, model) => { chatManager.calls.setModel.push({ sessionId, model }); return true; } }, + ); + + await processor.handleMessage('ws-1', { type: 'chat_set_model', model: 'grok-3-fast' }); + + assert.deepStrictEqual(chatManager.calls.setModel[0], { sessionId: 'session-1', model: 'grok-3-fast' }); + assert.strictEqual(session.chatModelOverride, 'grok-3-fast', 'the override is persisted regardless of how it applied'); + const result = lastOfType(sent, 'chat_model_result'); + assert.strictEqual(result.applied, 'live'); + assert.strictEqual(result.model, 'grok-3-fast'); + }); + + it('falls back to a best-effort slash command when the runtime advertises /model but cannot switch live', async function () { + const { processor, chatManager, session, sent } = build( + { surface: 'chat' }, + { + async snapshot(record) { + return { + sessionId: record.id, + runtime: 'claude', + messages: [], + state: 'idle', + capabilities: { commands: [{ name: 'model' }] }, + pendingPermissions: [], + firstSeq: 0, + cursor: 0, + live: true, + bypassPermissions: false, + }; + }, + }, + ); + + await processor.handleMessage('ws-1', { type: 'chat_set_model', model: 'claude-opus' }); + + assert.strictEqual(chatManager.calls.send.length, 1, 'the slash command must be sent as a turn'); + assert.strictEqual(chatManager.calls.send[0].turn.text, '/model claude-opus'); + assert.strictEqual(session.chatModelOverride, 'claude-opus'); + const result = lastOfType(sent, 'chat_model_result'); + assert.strictEqual(result.applied, 'sent'); + }); + + it('saves for the next session when nothing live can take the change', async function () { + const { processor, session, sent } = build({ surface: 'chat' }); + + await processor.handleMessage('ws-1', { type: 'chat_set_model', model: 'some-custom-model' }); + + assert.strictEqual(session.chatModelOverride, 'some-custom-model', 'still persisted for next launch'); + const result = lastOfType(sent, 'chat_model_result'); + assert.strictEqual(result.applied, 'pending'); + assert.match(result.message, /next time/i); + }); + + it('clears the override rather than treating an empty string as a model', async function () { + const { processor, session, sent } = build({ surface: 'chat' }); + session.chatModelOverride = 'previously-set'; + + await processor.handleMessage('ws-1', { type: 'chat_set_model', model: '' }); + + assert.strictEqual(session.chatModelOverride, undefined); + const result = lastOfType(sent, 'chat_model_result'); + assert.strictEqual(result.applied, 'cleared'); + assert.strictEqual(result.model, null); + }); + + it('lets a saved override outrank the profile default on the next launch', async function () { + const { processor, chatManager, session } = build( + { surface: 'chat' }, + {}, + ); + session.chatModelOverride = 'saved-override'; + processor.deps.resolveRuntimeProfile = () => ({ profileName: 'p', model: 'profile-default' }); + + await processor.startChat('ws-1', 'claude', {}); + + assert.strictEqual(chatManager.calls.start[0].options.model, 'saved-override'); + }); + + it('will not set a model override for a session belonging to another user', async function () { + const { processor, chatManager, session } = build({ surface: 'chat', ownerUserId: 999 }); + + await processor.handleMessage('ws-1', { type: 'chat_set_model', model: 'sneaky-model' }); + + assert.strictEqual(chatManager.calls.setModel.length, 0); + assert.strictEqual(session.chatModelOverride, undefined); + }); + + it('reports pending rather than live when the adapter’s live switch throws', async function () { + const { processor, session, sent } = build( + { surface: 'chat' }, + { setModel: async () => { throw new Error('adapter rejected the switch'); } }, + ); + + await processor.handleMessage('ws-1', { type: 'chat_set_model', model: 'grok-3-fast' }); + + assert.strictEqual(session.chatModelOverride, 'grok-3-fast', 'still saved for next launch despite the failed live attempt'); + const result = lastOfType(sent, 'chat_model_result'); + assert.strictEqual(result.applied, 'pending'); + assert.match(result.message, /adapter rejected the switch|next time/i); + }); + + it('falls back to the profile default on the next launch when no override was ever saved', async function () { + const { processor, chatManager } = build({ surface: 'chat' }, {}); + processor.deps.resolveRuntimeProfile = () => ({ profileName: 'p', model: 'profile-default' }); + + await processor.startChat('ws-1', 'claude', {}); + + assert.strictEqual(chatManager.calls.start[0].options.model, 'profile-default'); + }); + }); + describe('rejoining', function () { it('hands a chat session its conversation, and says which surface it is', async function () { const { processor, sent } = build({ surface: 'chat' }); diff --git a/test/session-store.test.js b/test/session-store.test.js index f6ca90f..8e04608 100644 --- a/test/session-store.test.js +++ b/test/session-store.test.js @@ -140,6 +140,48 @@ describe('SessionStore', function() { assert.strictEqual(loaded.get('manual').chatBypassPermissions, undefined); }); + it('remembers a conversation-scoped model override', async function () { + // The override is per conversation, not a new default: a restart must + // bring back exactly the choice made for this record and nothing for + // any other, and a record that never set one must read as "no override" + // rather than an empty string. + await sessionStore.saveSessions(new Map([ + ['custom', createSessionRecord({ + id: 'custom', + ownerUserId, + surface: 'chat', + chatModelOverride: 'grok-3-fast', + })], + ['default', createSessionRecord({ id: 'default', ownerUserId, surface: 'chat' })], + ])); + sessionStore.database.close(); + sessionStore = new SessionStore({ dataDir: tempDir }); + + const loaded = await sessionStore.loadSessions(); + assert.strictEqual(loaded.get('custom').chatModelOverride, 'grok-3-fast'); + assert.strictEqual(loaded.get('default').chatModelOverride, undefined); + }); + + it('adds the model-override column to a database that predates it', async function () { + await sessionStore.saveSessions(new Map([ + ['old', createSessionRecord({ id: 'old', ownerUserId, surface: 'chat' })], + ])); + sessionStore.database.raw.exec( + 'ALTER TABLE runtime_sessions DROP COLUMN chat_model_override', + ); + sessionStore.database.close(); + + sessionStore = new SessionStore({ dataDir: tempDir }); + const loaded = await sessionStore.loadSessions(); + + assert.strictEqual(loaded.size, 1, 'the upgrade must not cost the user their sessions'); + assert.strictEqual(loaded.get('old').chatModelOverride, undefined); + + sessionStore.database.close(); + sessionStore = new SessionStore({ dataDir: tempDir }); + assert.strictEqual((await sessionStore.loadSessions()).size, 1); + }); + it('adds the approval-mode column to a database that predates it', async function () { // Every install being upgraded has a session table written by an older // build. The column is added on boot rather than by a migration file, so