diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a328c26..cf99140 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,6 +42,13 @@ jobs: - name: Test run: npm test + # Whole classes of defect here are invisible to the unit suite because + # only a layout engine can answer them: whether a bar wraps, whether an + # answer actually reaches the screen while it streams. The runner image + # ships Chrome, and the runner refuses to skip when CI is set. + - name: Browser checks + run: npm run test:browser + install: name: Verify the documented install runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index bed5c6e..b475705 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,34 @@ # Changelog +## [5.1.2] - 2026-07-26 + +### Added +- **A conversation can run a different model from the one the profile picks.** + The model shown beside the composer is now something to click: choose one the + runtime has listed, or type a name it has not, and that choice belongs to that + one conversation — never written back as a profile or personal default, and + never inherited by the next conversation. Where the runtime can change model + without restarting, it changes immediately; where it cannot, the choice is + saved and used the next time that conversation starts, and the reply says + which of the two happened rather than claiming success either way. The same + menu offers the way back to the runtime's own default. +- **Turns fold away.** Each turn's header can be collapsed to hide everything + under it, and a turn folds on its own once the next one begins, so a long + session reads as a list of what was asked rather than an endless scroll. The + turn index can open or close them all at once, and jumping to a turn from the + index opens it. A folded turn still says what it was about, and its copy and + branch actions keep working while it is shut. + +### Fixed +- **The runtime's own slash commands are there from the moment a conversation + opens.** They used to appear only after the first message had been sent, + because nothing knew what the agent supported until the agent had spoken. +- **`/clear` and `/new` really do start over.** The transcript went blank, but + the text was handed to the agent like any other message, so the process kept + the whole conversation in mind and the next answer brought it all back. They + now restart the agent on a genuinely empty conversation, which is what they + had appeared to do. + ## [5.1.1] - 2026-07-26 ### Fixed diff --git a/package-lock.json b/package-lock.json index e60b590..8132bf1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "code-agents-webcli", - "version": "5.1.1", + "version": "5.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "code-agents-webcli", - "version": "5.1.1", + "version": "5.1.2", "license": "MIT", "dependencies": { "@lydell/node-pty": "1.2.0-beta.14", diff --git a/package.json b/package.json index ba60531..18f6b98 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "code-agents-webcli", - "version": "5.1.1", + "version": "5.1.2", "description": "Multiuser web CLI for Claude Code, Codex, and terminal sessions", "main": "dist/server/index.js", "bin": { 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/chat/turns.ts b/src/client/chat/turns.ts index cfd0822..96e95ea 100644 --- a/src/client/chat/turns.ts +++ b/src/client/chat/turns.ts @@ -21,6 +21,14 @@ import { compactCount, formatDuration } from './tool-meta.js'; export type TurnStatus = 'done' | 'running' | 'failed' | 'waiting'; +/** One glyph per status, shared by every surface that shows a turn's outcome. */ +export const STATUS_GLYPH: Record = { + done: { icon: 'check', color: 'var(--success)', word: 'done' }, + failed: { icon: 'circle-x', color: 'var(--destructive)', word: 'failed' }, + waiting: { icon: 'shield', color: 'var(--warning)', word: 'waiting for you' }, + running: { icon: 'loader-circle', color: 'var(--info)', spin: true, word: 'running' }, +}; + export interface TurnSummary { /** Id of the message that opened the turn — the user's, where there is one. */ id: string; @@ -159,6 +167,27 @@ export function turnOf(messageId: string, turns: TurnSummary[]): TurnSummary | u return turns.find((turn) => turn.messageIds.includes(messageId)); } +/** + * Whether a turn's contents should be shown, folded history vs. the one in + * progress. + * + * The default is "only the newest turn is open" — an unset entry in + * `overrides` reads as that default rather than as closed, which is what + * makes a brand-new turn open itself and everything before it fold without + * either one needing its own entry written first. An override, once made, + * wins regardless of which turn is newest — that persistence is what stops + * the next turn starting from slamming shut something the user deliberately + * opened. + */ +export function isTurnOpen( + turnId: string, + lastTurnId: string, + overrides: ReadonlyMap, +): boolean { + const override = overrides.get(turnId); + return override === undefined ? turnId === lastTurnId : override; +} + export interface TurnMeta { tools: string; reasoning: string; diff --git a/src/client/shell/chat/ChatView.tsx b/src/client/shell/chat/ChatView.tsx index ee31aed..77f4afd 100644 --- a/src/client/shell/chat/ChatView.tsx +++ b/src/client/shell/chat/ChatView.tsx @@ -4,7 +4,7 @@ import { uploadAttachment } from '../../chat/attachments-api.js'; import { ChatController, type ChatUnavailable } from '../../chat/controller.js'; import { fetchStatus, findFiles } from '../../chat/workspace-api.js'; import { activityEvents } from '../../chat/activity.js'; -import { groupTurns, turnOf, type TurnSummary } from '../../chat/turns.js'; +import { groupTurns, isTurnOpen, turnOf, type TurnSummary } from '../../chat/turns.js'; import type { ChatPanelId, ChatViewSettings } from '../../chat/view-settings.js'; import { DEFAULT_CHAT_VIEW, @@ -153,6 +153,51 @@ export function ChatView({ ? selectedTurnId : lastTurnId; + // A turn's fold state, once the user has set it explicitly. Unset, a turn + // reads as open exactly when it is the newest one — see isTurnOpen — which + // is what makes a fresh turn open itself and everything before it fold + // without this ever growing an entry for turns nobody has touched. + const [openOverrides, setOpenOverrides] = React.useState>(new Map()); + const openTurnIds = React.useMemo(() => { + const ids = new Set(); + for (const turn of turns) { + if (isTurnOpen(turn.id, lastTurnId, openOverrides)) ids.add(turn.id); + } + return ids; + }, [turns, lastTurnId, openOverrides]); + + const toggleTurn = React.useCallback( + (turnId: string) => { + setOpenOverrides((prev) => { + const next = new Map(prev); + next.set(turnId, !isTurnOpen(turnId, lastTurnId, prev)); + return next; + }); + }, + [lastTurnId], + ); + + // Idempotent, so a jump into an already-open turn does not thrash the map. + const openTurn = React.useCallback( + (turnId: string) => { + setOpenOverrides((prev) => { + if (isTurnOpen(turnId, lastTurnId, prev)) return prev; + const next = new Map(prev); + next.set(turnId, true); + return next; + }); + }, + [lastTurnId], + ); + + const expandAllTurns = React.useCallback(() => { + setOpenOverrides(() => new Map(turns.map((turn) => [turn.id, true]))); + }, [turns]); + + const collapseAllTurns = React.useCallback(() => { + setOpenOverrides(() => new Map(turns.map((turn) => [turn.id, false]))); + }, [turns]); + const [searchOpen, setSearchOpen] = React.useState(false); // Paired with a counter, not held as a bare id: clicking the same work pill // twice has to scroll the rail twice, and a string that did not change would @@ -239,6 +284,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], @@ -248,10 +297,17 @@ export function ChatView({ [controller], ); - const selectTurn = React.useCallback((id: string) => { - setSelectedTurnId(id); - list.current?.scrollToTurn(id); - }, []); + const selectTurn = React.useCallback( + (id: string) => { + setSelectedTurnId(id); + // The strip itself is always mounted, open or not, so this can scroll + // immediately — only a jump *inside* a turn's body needs to wait for it + // to open first. See revealMessage. + openTurn(id); + list.current?.scrollToTurn(id); + }, + [openTurn], + ); const jumpLatest = React.useCallback(() => { setSelectedTurnId(null); @@ -284,9 +340,18 @@ export function ChatView({ [openRail, transcript, view.showThinking, view.showToolCalls], ); - const revealMessage = React.useCallback((messageId: string) => { - list.current?.scrollToMessage(messageId); - }, []); + const revealMessage = React.useCallback( + (messageId: string) => { + const turn = turnOf(messageId, turns); + if (turn) openTurn(turn.id); + // A turn that was just opened has not painted yet — its bubbles have no + // layout box to scroll to until the next tick. A turn already open + // scrolls one tick later than it strictly needs to, which nothing on + // screen can tell apart from immediate. + window.setTimeout(() => list.current?.scrollToMessage(messageId), 0); + }, + [turns, openTurn], + ); const copyTurn = React.useCallback( (turnId: string) => { @@ -493,6 +558,8 @@ export function ChatView({ onSelect={selectTurn} onJumpLatest={jumpLatest} collapsed={indexCollapsed} + onExpandAll={expandAllTurns} + onCollapseAll={collapseAllTurns} /> ) : null} @@ -538,6 +605,8 @@ export function ChatView({ transcript={transcript} turns={turns} currentTurnId={currentTurnId} + openTurnIds={openTurnIds} + onToggleTurn={toggleTurn} onLoadMore={loadMore} onShowWork={showWork} onEditTurn={seedDraft} @@ -710,7 +779,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} /> @@ -754,6 +830,8 @@ export function ChatView({ setIndexSheet(false); jumpLatest(); }} + onExpandAll={expandAllTurns} + onCollapseAll={collapseAllTurns} /> + + + {(models ?? []).map((choice) => ( + ))} + + {!models?.length ? ( +
+ This runtime hasn't listed models — type one above. +
+ ) : null} + + {/* The way back. Picking a model is one click; without this, undoing + that choice was impossible — the text field refuses to submit + empty and every entry above carries a name — so a conversation + could be moved off its profile default but never returned to it, + and a typo stayed in force for every later launch. */} + + + ) : null} + + {!open && feedback ? ( +
+ {feedback.message}
) : null} diff --git a/src/client/shell/chat/MessageList.tsx b/src/client/shell/chat/MessageList.tsx index 592b89a..b3367e7 100644 --- a/src/client/shell/chat/MessageList.tsx +++ b/src/client/shell/chat/MessageList.tsx @@ -46,6 +46,13 @@ export interface MessageListProps { onForkTurn?: (turnId: string) => void; /** Which turn the index has selected; its strip is the sticky one. */ currentTurnId?: string; + /** + * Turns whose body is shown. Absent from this set, a turn's strip stays + * mounted (so copy/branch keep working) but its messages are hidden. + * Undefined — no caller managing fold state — reads as "every turn open". + */ + openTurnIds?: ReadonlySet; + onToggleTurn?: (turnId: string) => void; /** * Passed down as primitives, not as a settings object. * @@ -80,6 +87,8 @@ export const MessageList = React.forwardRef onCopyTurn, onForkTurn, currentTurnId, + openTurnIds, + onToggleTurn, showThinking = true, showToolCalls = true, }, @@ -236,15 +245,21 @@ export const MessageList = React.forwardRef setStuck(true); }, [stick]); - // Which message opens which turn, so the strips can be interleaved in one - // pass instead of searching the turn list per message. - const stripFor = React.useMemo(() => { - const map = new Map(); - for (const turn of turns) { - if (turn.messageIds.length) map.set(turn.messageIds[0], turn); - } + // Looked up by id rather than iterated in transcript order: a turn's own + // messages are already listed on it, and rendering turn-by-turn (below) is + // what lets a collapsed turn hide its whole body as one unit. + // + // Keyed on `version`, not on `messages`: the transcript appends to its + // array in place, so its identity never changes and a `[messages]` map + // would be built once at mount and then never see another message. Every + // later id would miss and render nothing — a turn whose body stays empty + // while its strip keeps counting. + const messageById = React.useMemo(() => { + const map = new Map(); + for (const message of messages) map.set(message.id, message); return map; - }, [turns]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [messages, version]); const lastTurnId = turns.length ? turns[turns.length - 1].id : undefined; @@ -330,33 +345,48 @@ export const MessageList = React.forwardRef {messages.length === 0 ? : null} - {messages.map((message) => { - const turn = stripFor.get(message.id); + {turns.map((turn) => { + const open = !openTurnIds || openTurnIds.has(turn.id); + const bodyId = turnBodyId(turn.id); return ( - - {turn ? ( - copyTurn(turn.id)} - onBranch={onForkTurn ? () => onForkTurn(turn.id) : undefined} - /> - ) : null} - + copyTurn(turn.id)} + onBranch={onForkTurn ? () => onForkTurn(turn.id) : undefined} + open={open} + onToggleOpen={() => onToggleTurn?.(turn.id)} + bodyId={bodyId} /> + {/* Hidden rather than unmounted: a collapsed turn's bubbles + keep their scroll offsets and streaming subscriptions, so + re-expanding it is instant and cannot desync from a turn + that kept running underneath. */} + ); })} @@ -401,6 +431,10 @@ const ZERO = (): number => 0; * replayed from disk and carried across versions, and a selector built by * concatenation is one odd id away from throwing inside a scroll handler. */ +function turnBodyId(turnId: string): string { + return `turn-body-${turnId}`; +} + function cssEscape(value: string): string { const escape = (globalThis as { CSS?: { escape?: (v: string) => string } }).CSS?.escape; return escape ? escape(value) : value.replace(/["\\]/g, '\\$&'); diff --git a/src/client/shell/chat/TurnIndex.tsx b/src/client/shell/chat/TurnIndex.tsx index f3e4f71..fdc6064 100644 --- a/src/client/shell/chat/TurnIndex.tsx +++ b/src/client/shell/chat/TurnIndex.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import { Icon } from '../../ui/relay/Icon.js'; -import type { TurnStatus, TurnSummary } from '../../chat/turns.js'; +import { STATUS_GLYPH, type TurnSummary } from '../../chat/turns.js'; /** * Every turn in the conversation, as a list you can jump around. @@ -23,15 +23,11 @@ export interface TurnIndexProps { onJumpLatest(): void; /** Below 1280px the labels go and the numbers stay. */ collapsed?: boolean; + /** Open or close every turn's body at once. Omitted, the row shows neither. */ + onExpandAll?: () => void; + onCollapseAll?: () => void; } -const STATUS_GLYPH: Record = { - done: { icon: 'check', color: 'var(--success)', word: 'done' }, - failed: { icon: 'circle-x', color: 'var(--destructive)', word: 'failed' }, - waiting: { icon: 'shield', color: 'var(--warning)', word: 'waiting for you' }, - running: { icon: 'loader-circle', color: 'var(--info)', spin: true, word: 'running' }, -}; - export const TURN_INDEX_WIDTH = 196; export const TURN_INDEX_COLLAPSED_WIDTH = 44; @@ -41,6 +37,8 @@ export function TurnIndex({ onSelect, onJumpLatest, collapsed = false, + onExpandAll, + onCollapseAll, }: TurnIndexProps): React.JSX.Element { const listRef = React.useRef(null); @@ -104,6 +102,18 @@ export function TurnIndex({ )} {turns.length} + {/* Icon-rail width has no room for these — the row is already tight + around the count at 44px. */} + {!collapsed && (onExpandAll || onCollapseAll) ? ( + + {onExpandAll ? ( + + ) : null} + {onCollapseAll ? ( + + ) : null} + + ) : null}
void; +}): React.JSX.Element { + const [hover, setHover] = React.useState(false); + return ( + + ); +} + function TurnRow({ turn, active, diff --git a/src/client/shell/chat/TurnStrip.tsx b/src/client/shell/chat/TurnStrip.tsx index bbfdff8..1fa782a 100644 --- a/src/client/shell/chat/TurnStrip.tsx +++ b/src/client/shell/chat/TurnStrip.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import { Icon } from '../../ui/relay/Icon.js'; -import { formatTurnMeta, turnTime, type TurnSummary } from '../../chat/turns.js'; +import { formatTurnMeta, turnTime, STATUS_GLYPH, type TurnSummary } from '../../chat/turns.js'; /** * The 28px rule that opens a turn, and everything that turn cost. @@ -25,6 +25,11 @@ export interface TurnStripProps { onBranch?: () => void; /** Set while the copy has just landed, so the glyph can acknowledge it. */ copied?: boolean; + /** Whether the turn's body is shown below this strip. */ + open: boolean; + onToggleOpen(): void; + /** Id of the element this strip discloses, for `aria-controls`. */ + bodyId?: string; } export function TurnStrip({ @@ -34,10 +39,14 @@ export function TurnStrip({ onCopy, onBranch, copied = false, + open, + onToggleOpen, + bodyId, }: TurnStripProps): React.JSX.Element { const past = variant === 'past'; const meta = formatTurnMeta(turn); const time = turnTime(turn); + const glyph = STATUS_GLYPH[turn.status] || STATUS_GLYPH.done; return (
+ + ) : null} + {/* Only shown while the body it names is hidden — this is the entire + reason a collapsed strip is still worth reading rather than just a + number. */} + {!open ? ( + <> + + {turn.label} + + ) : null} + {children} diff --git a/src/server/chat/adapters/claude.ts b/src/server/chat/adapters/claude.ts index 4eb482d..828866a 100644 --- a/src/server/chat/adapters/claude.ts +++ b/src/server/chat/adapters/claude.ts @@ -1,7 +1,7 @@ import { randomUUID } from 'node:crypto'; import { readFileSync } from 'node:fs'; import { BaseChatAdapter } from '../adapter.js'; -import { describeSlashCommand } from '../../../shared/slash-commands.js'; +import { defaultSlashCommands, describeSlashCommand } from '../../../shared/slash-commands.js'; import { ChatAttachment, ChatBlock, @@ -48,6 +48,11 @@ export class ClaudeChatAdapter extends BaseChatAdapter { usage: true, cost: true, plan: false, + // The real list only arrives with the first turn's `init` (see + // handleInit below); until then this is what makes the command menu and + // its composer button available from the moment the session opens, + // rather than staying empty until a message has already been sent. + commands: defaultSlashCommands(), }; /** Session id we generated for a fresh launch, before init echoes it back. */ diff --git a/src/server/chat/manager.ts b/src/server/chat/manager.ts index 8629052..adfa038 100644 --- a/src/server/chat/manager.ts +++ b/src/server/chat/manager.ts @@ -207,6 +207,18 @@ 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); + } + + /** Carry a new model into the options an in-place `/clear` restart replays. */ + rememberModel(sessionId: string, model: string | undefined): void { + this.sessions.get(sessionId)?.rememberModel(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..1037451 100644 --- a/src/server/chat/session.ts +++ b/src/server/chat/session.ts @@ -150,6 +150,8 @@ export class ChatSession { private nativeSessionId: string | null = null; private startedAt = 0; private cwd = ''; + /** The options this session was last launched with, kept for `/clear` and `/new`. */ + private lastStartOptions: ChatSessionStartOptions | null = null; /** * True between resuming a conversation and the first thing the user says in it. @@ -224,6 +226,7 @@ export class ChatSession { throw new Error(`${options.runtime} has no chat adapter`); } + this.lastStartOptions = options; this.runtime = options.runtime; this.cwd = options.workingDir; this.bypass = Boolean(options.bypassPermissions); @@ -544,20 +547,40 @@ export class ChatSession { }); } this.ingest({ t: 'msg_end', msgId: messageId }); - this.setState('thinking'); - - await this.adapter.send(turn); - // After the runtime has accepted it, not before: if the send throws, the - // conversation is still there and clearing the window would have thrown - // away a transcript for a command that never ran. - // - // The marker goes in the durable log like everything else, so a browser - // that rejoins later replays the clear rather than being handed back the - // messages this was supposed to remove. + // `/clear` and `/new` promise a conversation the agent has never seen + // before, not one that only looks that way. Forwarding the text to the + // still-alive process would just add "/clear" to its own context — the + // process would still remember everything said before it. A real reset + // means a new process with no resume id, the same thing a manual "start + // fresh" relaunch already does. if (isClearingCommand(turn.text)) { - this.ingest({ t: 'marker', kind: 'cleared' }); + await this.restart(); + return; } + + this.setState('thinking'); + await this.adapter.send(turn); + } + + /** + * Stop the running adapter and start a brand new one with no resume id, in + * place, without tearing down the `ChatSession` itself. + * + * The marker that tells a rejoining browser to stop paging back past this + * point is emitted by `start()` itself (`startFresh`), so this only has to + * get a fresh process running — the same path a manual "start fresh" + * relaunch takes, just triggered from inside a live conversation instead of + * from the recovery banner. + */ + private async restart(): Promise { + const options = this.lastStartOptions; + if (!options) return; + await this.stop(); + // Stale until the new process's own `init` event reports its id — cleared + // up front so nothing reads the old conversation's id in the meantime. + this.nativeSessionId = null; + await this.start({ ...options, resumeSessionId: undefined, startFresh: true }); } async interrupt(): Promise { @@ -643,6 +666,40 @@ 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; + } + + /** + * Record the model an in-place restart must launch with. + * + * `restart()` replays the options this session was last started with, and + * those were resolved once, at launch. Everything in them is fixed for the + * life of the conversation except the model, which `chat_set_model` can + * change underneath them — so without this a `/clear` would quietly + * reinstate the model the conversation happened to open with, discarding a + * choice the browser has already been told was applied. + * + * Takes the effective model rather than the override, so clearing an + * override lands on the profile default here exactly as it would on a fresh + * launch. + */ + rememberModel(model: string | undefined): void { + if (!this.lastStartOptions) return; + this.lastStartOptions = { ...this.lastStartOptions, model }; + } + 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..94624e9 100644 --- a/src/server/websocket/messages.ts +++ b/src/server/websocket/messages.ts @@ -15,6 +15,29 @@ import { sendToWebSocket, broadcastChat, broadcastToSession } from './handler.js import { chatUnavailableReason, isChatRuntime } from '../../shared/chat-runtimes.js'; import { ChatNotRunningError } from '../chat/session.js'; +/** + * The longest model name worth storing. Real ones are far shorter; this only + * has to stop an unbounded string from being persisted and then handed to a + * spawn on every future launch of the conversation. + */ +const MAX_MODEL_NAME = 200; + +/** + * Tidy a typed model name into something safe to keep. + * + * Names are never validated against a list — a runtime knows its own models + * and new ones appear without us — so this only removes what can't belong in + * one: control characters, which would otherwise ride into the best-effort + * `/model ` turn as extra lines, and unbounded length. + */ +function normaliseModelName(raw: string): string | undefined { + const stripped = raw.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g, ' ').trim(); + // Sliced by code point, not by code unit: cutting mid-character would store + // half a surrogate pair. + const cleaned = [...stripped].slice(0, MAX_MODEL_NAME).join('').trim(); + return cleaned || undefined; +} + export interface MessageProcessorDeps { dev: boolean; claudeSessions: Map; @@ -88,6 +111,10 @@ 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; + /** Carry a new model into the options an in-place `/clear` restart replays. */ + rememberModel(sessionId: string, model: string | undefined): void; cancelQueued(sessionId: string, queuedId: string): boolean; respondPermission(sessionId: string, requestId: string, optionId: string): boolean; stop(sessionId: string): Promise; @@ -118,6 +145,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 +228,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 +499,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 +509,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 +1016,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 +1055,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 +1098,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, { @@ -1097,6 +1146,23 @@ export class MessageProcessor { const text = typeof data.text === 'string' ? data.text : ''; if (!text.trim() && !(data.attachments || []).length) return; + // The runtime's own `/model` reaches the same decision by the other door, + // so it has to leave the same trace. Without this the command is forwarded + // untouched, the conversation really does change model, and then the next + // `/clear` restarts it on the model it opened with — the same silent + // reversion the model picker was fixed for. Recorded, then forwarded + // unchanged: the runtime still runs its own command, and whether it + // accepted the name is still its answer to give, not ours. + const typedModel = /^\/model[ \t]+(\S.*)$/.exec(text.trim()); + if (typedModel) { + const model = normaliseModelName(typedModel[1]); + if (model) { + session.chatModelOverride = model; + await this.deps.saveSessionsToDisk(); + manager.rememberModel(session.id, model); + } + } + try { await manager.send(session.id, { text, @@ -1137,6 +1203,123 @@ 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 ? normaliseModelName(raw) : undefined; + session.chatModelOverride = model; + await this.deps.saveSessionsToDisk(); + + // A live session keeps the options it was launched with so that `/clear` + // can restart the process in place. The model is the one thing in there + // this handler can change, so it has to be carried across too — otherwise + // the next `/clear` reinstates the model the conversation opened with, + // after the browser has already been told the switch was applied. Resolved + // the way a launch resolves it, so clearing lands on the profile default. + const profile = this.deps.resolveRuntimeProfile( + session.agent as AgentKind, + session.workingDir, + ); + this.deps.chatManager?.rememberModel(session.id, model || profile?.model); + + 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/src/shared/slash-commands.ts b/src/shared/slash-commands.ts index 6cb2ac5..550dd6c 100644 --- a/src/shared/slash-commands.ts +++ b/src/shared/slash-commands.ts @@ -48,6 +48,21 @@ export function describeSlashCommand(name: string): string | undefined { return BUILT_IN[String(name || '').trim().toLowerCase().replace(/^\//, '')]; } +/** + * The built-ins, as a menu the picker can show before a runtime has said + * anything about itself. + * + * Claude does not report `slash_commands` until it has processed a first + * turn (see claude.ts), which used to mean a brand-new session showed no + * command menu at all until one message had already been sent. This table is + * this app's own knowledge of what a fresh Claude session accepts, not + * something Claude told it — a project or plugin command still only appears + * once the real `init` arrives and replaces this list outright. + */ +export function defaultSlashCommands(): { name: string; description: string }[] { + return Object.entries(BUILT_IN).map(([name, description]) => ({ name, description })); +} + /** * Commands that empty the conversation. * diff --git a/test/browser/checks.ts b/test/browser/checks.ts index d110f5a..3160a6a 100644 --- a/test/browser/checks.ts +++ b/test/browser/checks.ts @@ -225,6 +225,7 @@ async function run(): Promise { await checkATallDialogStaysOnScreen(); await checkTheComposerShrinksWithTheWorkspaceRail(); await checkTheFixedBarsNeverWrap(); + await checkALiveAnswerAppearsAsItStreams(); const pre = document.createElement('pre'); pre.id = 'results'; @@ -517,7 +518,7 @@ async function checkTheFixedBarsNeverWrap(): Promise { messages: [ { id: 'u1', seq: 1, turnId: 't1', role: 'user', ts: Date.now(), - blocks: [{ kind: 'text', text: 'a question long enough to need the whole column for itself' }], + blocks: [{ kind: 'text', text: 'please refactor the authentication middleware so that it validates the bearer token against the new issuer, then update every call site that still assumes the old claim shape, and make sure the integration tests cover the expired-token path as well as the malformed-header path' }], }, { id: 'a1', seq: 2, turnId: 't1', role: 'assistant', ts: Date.now(), @@ -531,6 +532,14 @@ async function checkTheFixedBarsNeverWrap(): Promise { ], usage: { inputTokens: 123456, outputTokens: 98765, costUsd: 1234.5678 }, }, + { + id: 'u2', seq: 3, turnId: 't2', role: 'user', ts: Date.now(), + blocks: [{ kind: 'text', text: 'and now a second turn, so the first one folds' }], + }, + { + id: 'a2', seq: 4, turnId: 't2', role: 'assistant', ts: Date.now(), + blocks: [{ kind: 'text', text: 'done' }], + }, ], pendingPermissions: [], queued: [], firstSeq: 1, replayFrom: 1, cursor: 2, live: true, bypassPermissions: true, @@ -611,6 +620,112 @@ async function checkTheFixedBarsNeverWrap(): Promise { host.remove(); } +/** + * The chat has to show the answer while it is being written. + * + * Every layer under this was already covered — the reducer applies the events, + * the transcript bumps its version, the strips re-render — and the chat still + * went blank in 5.1.2, because the list looked its messages up in a map that + * was built once and never rebuilt. Nothing short of mounting the real view + * and streaming into it catches that, so this asserts the only thing that + * actually matters: the words reach the screen. + */ +async function checkALiveAnswerAppearsAsItStreams(): Promise { + const host = document.createElement('div'); + host.style.cssText = 'width:1280px;height:760px;position:absolute;top:0;left:0;display:flex'; + document.body.appendChild(host); + + const controller = new ChatController('live-check', { send: () => {} }); + controller.handle({ + type: 'chat_snapshot', + sessionId: 'live-check', + snapshot: { + sessionId: 'live-check', runtime: 'claude', state: 'idle', + capabilities: { + streaming: true, thinking: true, toolCalls: true, diffs: true, permissions: true, + interrupt: true, resume: true, fork: false, attachments: true, usage: true, + cost: true, plan: true, commands: [], + }, + messages: [ + { + id: 'u1', seq: 1, turnId: 't1', role: 'user', ts: Date.now(), + blocks: [{ kind: 'text', text: 'the question already in the transcript' }], + }, + ], + pendingPermissions: [], queued: [], firstSeq: 1, replayFrom: 1, cursor: 1, + live: true, bypassPermissions: false, + }, + } as never); + + const root = createRoot(host); + root.render( + React.createElement(ChatView, { + controller, + runtime: 'claude', + runtimeLabel: 'Claude Code', + workingDir: '/home/dev/project', + branch: 'main', + view: { ...DEFAULT_CHAT_VIEW }, + onViewChange: () => {}, + } as never), + ); + await wait(400); + + // Exactly what arrives from the socket while an answer is being written: + // a new turn opens, then text lands in it a delta at a time. + const event = (payload: unknown): void => + controller.handle({ type: 'chat_event', sessionId: 'live-check', event: payload } as never); + + // Read from the rendered messages only. The turn strip repeats the opening + // question as its label, so asserting against the whole subtree would pass + // on the strip alone while the body it names is empty — which is the exact + // bug being guarded against. + const bodyText = (): string => + Array.from(host.querySelectorAll('[data-message-id]')) + .map((node) => node.textContent || '') + .join(' '); + + event({ t: 'msg_start', id: 'u2', seq: 2, turnId: 't2', role: 'user', ts: Date.now() }); + event({ t: 'block_start', msgId: 'u2', index: 0, block: { kind: 'text', text: '' } }); + event({ t: 'block_delta', msgId: 'u2', index: 0, text: 'a question typed while the app is open' }); + event({ t: 'msg_end', msgId: 'u2' }); + await wait(250); + + check( + 'a message sent while the app is open shows up', + bodyText().includes('a question typed while the app is open'), + 'the user\'s own turn never rendered', + ); + + event({ t: 'msg_start', id: 'a2', seq: 3, turnId: 't2', role: 'assistant', ts: Date.now() }); + event({ t: 'block_start', msgId: 'a2', index: 0, block: { kind: 'text', text: '' } }); + await wait(120); + event({ t: 'block_delta', msgId: 'a2', index: 0, text: 'the answer as it ' }); + await wait(120); + event({ t: 'block_delta', msgId: 'a2', index: 0, text: 'is being written' }); + await wait(250); + + const text = bodyText(); + check( + 'the answer is on screen before it has finished', + text.includes('the answer as it is being written'), + text.includes('the answer as it') + ? 'the first delta rendered but later ones did not' + : 'the streaming turn body stayed empty', + ); + + // And the transcript really did receive it — so a failure above is the view + // not rendering, not the events being wrong. + check( + 'the transcript itself holds the streamed message', + controller.transcript.messages.some((m: { id: string }) => m.id === 'a2'), + `ids=${controller.transcript.messages.map((m: { id: string }) => m.id).join(',')}`, + ); + + root.unmount(); + host.remove(); +} + run().catch((error: unknown) => { const pre = document.createElement('pre'); pre.id = 'results'; diff --git a/test/browser/run.js b/test/browser/run.js index fdc050c..0c8172c 100755 --- a/test/browser/run.js +++ b/test/browser/run.js @@ -11,6 +11,14 @@ const chrome = ['google-chrome', 'google-chrome-stable', 'chromium', 'chromium-b }); if (!chrome) { + // Skipping is a convenience for a machine that happens to have no browser, + // never for CI: these checks are the only thing covering defects that a + // layout engine has to be running to see, and a silent skip there would + // report a green build for a suite that never ran. + if (process.env.CI) { + console.error('No Chrome/Chromium on PATH. CI must run the browser checks, not skip them.'); + process.exit(1); + } console.log('Skipping browser checks: no Chrome/Chromium on PATH.'); process.exit(0); } diff --git a/test/chat-claude.test.js b/test/chat-claude.test.js index 28543fc..7e2e70f 100644 --- a/test/chat-claude.test.js +++ b/test/chat-claude.test.js @@ -295,7 +295,8 @@ describe('claude chat adapter', function () { describe('capabilities', function () { it('advertises streaming/thinking/toolCalls/resume/interrupt/attachments but not diffs or permissions', function () { const { adapter } = makeAdapter(); - assert.deepStrictEqual(adapter.capabilities, { + const { commands, ...rest } = adapter.capabilities; + assert.deepStrictEqual(rest, { streaming: true, thinking: true, toolCalls: true, @@ -310,5 +311,22 @@ describe('claude chat adapter', function () { plan: false, }); }); + + it('advertises a baseline command list before the runtime has said anything', function () { + const { adapter } = makeAdapter(); + assert.ok(adapter.capabilities.commands.length > 0); + assert.ok(adapter.capabilities.commands.some((c) => c.name === 'resume')); + assert.ok(adapter.capabilities.commands.every((c) => c.description)); + }); + }); + + describe('before the first turn', function () { + it('a freshly constructed adapter already has a non-empty command list, so the menu never starts empty', function () { + const { adapter } = makeAdapter(); + // No handleMessage call at all here: this is the state the session sees + // the instant the process is spawned, before Claude has said a word. + assert.ok(Array.isArray(adapter.capabilities.commands)); + assert.ok(adapter.capabilities.commands.length > 0); + }); }); }); diff --git a/test/chat-clear-reset.test.js b/test/chat-clear-reset.test.js new file mode 100644 index 0000000..3180aa6 --- /dev/null +++ b/test/chat-clear-reset.test.js @@ -0,0 +1,208 @@ +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { ChatSession } = require('../dist/server/chat/session.js'); + +// Issue #43: `/clear` and `/new` looked like they reset the conversation — +// the transcript went blank — but the text was sent to the still-running +// agent process like any other message, so the very next turn brought the +// old context right back. A real reset has to stop the process that +// remembers and start a new one; these pin that down at the point the bug +// lived, `ChatSession.send`, without spawning a real CLI. + +function memoryStore() { + const events = []; + return { + events, + append(_ref, batch) { + events.push(...batch); + }, + async stat() { + return { firstSeq: 1, cursor: events.length }; + }, + async read() { + return { events: [], firstSeq: 1, from: 1, cursor: events.length }; + }, + }; +} + +function fakeAdapter(sendCalls) { + return { + alive: true, + async send(turn) { + sendCalls.push(turn); + }, + async interrupt() {}, + respondPermission() {}, + async stop() {}, + }; +} + +function session() { + const store = memoryStore(); + const s = new ChatSession( + { id: 's1', ownerUserId: 7 }, + { + store, + socketDir: fs.mkdtempSync(path.join(os.tmpdir(), 'clear-reset-')), + hookScript: path.join(__dirname, '..', 'does-not-exist.js'), + broadcast: () => {}, + resolveCommand: () => 'claude', + }, + ); + const sendCalls = []; + s.adapter = fakeAdapter(sendCalls); + s.state = 'idle'; + // Stands in for whatever `start()` was last called with — set directly + // because these tests stub the adapter rather than spawning a real one. + s.lastStartOptions = { runtime: 'claude', workingDir: '/tmp' }; + return { s, store, sendCalls }; +} + +describe('/clear and /new actually reset the conversation', function () { + it('never forwards the clearing command to the live adapter', async function () { + const { s, sendCalls } = session(); + s.start = async () => { + s.adapter = fakeAdapter([]); + s.state = 'idle'; + }; + + await s.send({ text: '/clear' }); + + assert.deepStrictEqual( + sendCalls, + [], + 'the process that already holds the old context must never see this turn', + ); + }); + + it('stops the running adapter and starts a fresh one with no resume id', async function () { + const { s } = session(); + const startCalls = []; + const stopCalls = []; + const originalAdapter = s.adapter; + originalAdapter.stop = async () => stopCalls.push('stopped'); + s.start = async (options) => { + startCalls.push(options); + s.adapter = fakeAdapter([]); + s.state = 'idle'; + }; + + await s.send({ text: '/new' }); + + assert.strictEqual(stopCalls.length, 1, 'the old process must be torn down, not reused'); + assert.strictEqual(startCalls.length, 1); + assert.strictEqual(startCalls[0].runtime, 'claude'); + assert.strictEqual(startCalls[0].resumeSessionId, undefined, 'a resume would hand the new process the old context back'); + assert.strictEqual(startCalls[0].startFresh, true); + }); + + it('is case-insensitive and ignores arguments, matching isClearingCommand', async function () { + const { s, sendCalls } = session(); + let started = 0; + s.start = async () => { + started += 1; + s.adapter = fakeAdapter([]); + s.state = 'idle'; + }; + + await s.send({ text: '/CLEAR now please' }); + + assert.strictEqual(started, 1); + assert.deepStrictEqual(sendCalls, []); + }); + + it('still records the user turn in the transcript before resetting', async function () { + const { s, store } = session(); + s.start = async () => { + s.adapter = fakeAdapter([]); + s.state = 'idle'; + }; + + await s.send({ text: '/clear' }); + + const texts = store.events + .filter((e) => e.t === 'block_start') + .map((e) => e.block.text); + assert.deepStrictEqual(texts, ['/clear']); + }); + + it('clears the stale native session id before the new process reports its own', async function () { + const { s } = session(); + s.ingest({ t: 'session', nativeSessionId: 'native-old', capabilities: {} }); + assert.strictEqual(s.nativeId, 'native-old'); + + s.start = async () => { + // The new process has not announced itself yet. + s.adapter = fakeAdapter([]); + s.state = 'idle'; + }; + + await s.send({ text: '/new' }); + + assert.strictEqual(s.nativeId, null, 'must not still point at the pre-clear conversation'); + }); + + it('leaves an ordinary message alone', async function () { + const { s, sendCalls } = session(); + let started = 0; + s.start = async () => { + started += 1; + }; + + await s.send({ text: 'clear the table, please' }); + + assert.strictEqual(started, 0, 'not a slash command, so no restart'); + assert.strictEqual(sendCalls.length, 1); + assert.strictEqual(sendCalls[0].text, 'clear the table, please'); + }); + + // The restart replays the options the session was launched with, and the + // model is the one thing in them that can change while the session is alive. + // Both features shipped in 5.1.2 and each one's own tests passed: the + // override was lost only where they met. + it('restarts with the model the conversation was moved to, not the one it opened with', async function () { + const { s } = session(); + s.lastStartOptions = { runtime: 'claude', workingDir: '/tmp', model: 'opened-with' }; + + // What `chat_set_model` does once the choice is persisted, whether or not + // the live adapter could take it. + s.rememberModel('moved-to'); + + let startedWith = null; + s.start = async (options) => { + startedWith = options; + s.adapter = fakeAdapter([]); + s.state = 'idle'; + }; + + await s.send({ text: '/clear' }); + + assert.strictEqual( + startedWith.model, + 'moved-to', + 'the fresh process must run the model the browser was told was applied', + ); + }); + + it('restarts on the profile default once the override is cleared', async function () { + const { s } = session(); + s.lastStartOptions = { runtime: 'claude', workingDir: '/tmp', model: 'an-override' }; + + // Clearing resolves to the profile default, so that is what arrives here. + s.rememberModel('profile-default'); + + let startedWith = null; + s.start = async (options) => { + startedWith = options; + s.adapter = fakeAdapter([]); + s.state = 'idle'; + }; + + await s.send({ text: '/clear' }); + + assert.strictEqual(startedWith.model, 'profile-default'); + }); +}); 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-turns.test.js b/test/chat-turns.test.js index ed33ccc..35faa66 100644 --- a/test/chat-turns.test.js +++ b/test/chat-turns.test.js @@ -221,6 +221,22 @@ describe('turnOf', function () { }); }); +describe('isTurnOpen', function () { + it('reads an untouched turn as open exactly when it is the newest', function () { + const overrides = new Map(); + assert.strictEqual(mod.isTurnOpen('t2', 't2', overrides), true); + assert.strictEqual(mod.isTurnOpen('t1', 't2', overrides), false); + }); + + it('lets an explicit override win regardless of which turn is newest', function () { + // A turn the user deliberately opened must not be slammed shut by the + // next turn starting, and one they closed must not spring back open. + const overrides = new Map([['t1', true], ['t2', false]]); + assert.strictEqual(mod.isTurnOpen('t1', 't2', overrides), true); + assert.strictEqual(mod.isTurnOpen('t2', 't2', overrides), false); + }); +}); + describe('formatTurnMeta', function () { it('says nothing rather than zero for a turn that did nothing', function () { const messages = [msg('user', [text('hello')]), msg('assistant', [text('hi')])]; diff --git a/test/chat-view.test.js b/test/chat-view.test.js index 2fd1e2e..dc245fd 100644 --- a/test/chat-view.test.js +++ b/test/chat-view.test.js @@ -145,6 +145,37 @@ describe('ChatView', function () { assert.ok(!html.includes('Nothing here yet'), 'the transcript is not empty'); }); + it('folds every turn but the newest, and keeps its ask readable while folded', function () { + const controller = controllerWith({ + messages: [ + message('m1', 1, 'user', 'run the tests'), + message('m2', 2, 'assistant', 'all green'), + message('m3', 3, 'user', 'now deploy'), + message('m4', 4, 'assistant', 'deployed'), + ], + cursor: 4, + }); + + const html = render({ controller }); + + // Turn 1's disclosure reads closed and its body is hidden, without either + // its ask or its outcome leaving the strip — folding history must not + // mean losing the map of it. + const closedToggle = /aria-label="Expand turn 1"[^>]*aria-expanded="false"|aria-expanded="false"[^>]*aria-label="Expand turn 1"/; + assert.ok(closedToggle.test(html), 'turn 1 must read as collapsed'); + assert.ok(/id="turn-body-m1"[^>]*hidden=""/.test(html), 'turn 1 body must be hidden'); + assert.ok(html.includes('run the tests'), 'a collapsed turn must still show what was asked'); + + // The newest turn opens by default and its body is not hidden. + const openToggle = /aria-label="Collapse turn 2"[^>]*aria-expanded="true"|aria-expanded="true"[^>]*aria-label="Collapse turn 2"/; + assert.ok(openToggle.test(html), 'turn 2 must read as open'); + assert.ok(!/id="turn-body-m3"[^>]*hidden=""/.test(html), 'the current turn must not be hidden'); + assert.ok(html.includes('deployed'), 'the open turn must show its content'); + + // The strip stays mounted — and its actions reachable — for a folded turn. + assert.ok(/aria-label="Copy this turn as Markdown"/.test(html), 'copy control must survive folding'); + }); + it('pins a pending approval outside the scroller and announces it', function () { const controller = controllerWith({ state: 'awaiting_permission', diff --git a/test/chat-wiring.test.js b/test/chat-wiring.test.js index c6e65eb..9cd290c 100644 --- a/test/chat-wiring.test.js +++ b/test/chat-wiring.test.js @@ -38,10 +38,20 @@ 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: [], rememberModel: [], + }; return { calls, has: () => false, + async setModel(sessionId, model) { + calls.setModel.push({ sessionId, model }); + return false; + }, + rememberModel(sessionId, model) { + calls.rememberModel.push({ sessionId, model }); + }, async start(record, options) { calls.start.push({ record, options }); return { @@ -231,6 +241,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 +435,190 @@ 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); + }); + + // A live session holds the options `/clear` will relaunch from, and the + // model is the only one this handler can change. Without carrying it over, + // the next `/clear` reinstated the model the conversation opened with. + it('carries the new model into the options a /clear restart replays', async function () { + const { processor, chatManager } = build({ surface: 'chat' }); + + await processor.handleMessage('ws-1', { type: 'chat_set_model', model: 'some-custom-model' }); + + assert.deepStrictEqual(chatManager.calls.rememberModel[0], { + sessionId: 'session-1', + model: 'some-custom-model', + }); + }); + + it('carries the profile default across when the override is cleared', async function () { + const { processor, chatManager } = build({ surface: 'chat' }); + processor.deps.resolveRuntimeProfile = () => ({ profileName: 'p', model: 'profile-default' }); + + await processor.handleMessage('ws-1', { type: 'chat_set_model', model: '' }); + + // The value itself, not just that something was carried: clearing has to + // land where a fresh launch would land, which is the profile's model and + // not the runtime's own default. + assert.deepStrictEqual(chatManager.calls.rememberModel[0], { + sessionId: 'session-1', + model: 'profile-default', + }); + }); + + // The same choice by the other door. Forwarding it untouched left the + // record unaware, so the next /clear restarted on the original model. + it('records a /model typed straight into the composer, and still forwards it', async function () { + const { processor, chatManager, session } = build({ surface: 'chat' }); + + await processor.handleMessage('ws-1', { type: 'chat_send', text: '/model haiku-3' }); + + assert.strictEqual(session.chatModelOverride, 'haiku-3'); + assert.deepStrictEqual(chatManager.calls.rememberModel[0], { + sessionId: 'session-1', + model: 'haiku-3', + }); + assert.strictEqual( + chatManager.calls.send[0].turn.text, + '/model haiku-3', + 'the runtime still has to receive its own command', + ); + }); + + it('leaves an ordinary message that merely mentions /model alone', async function () { + const { processor, chatManager, session } = build({ surface: 'chat' }); + + await processor.handleMessage('ws-1', { type: 'chat_send', text: 'what does /model do?' }); + + assert.strictEqual(session.chatModelOverride, undefined); + assert.strictEqual(chatManager.calls.rememberModel.length, 0); + }); + + it('strips control characters and caps the length of a typed model name', async function () { + const { processor, session } = build({ surface: 'chat' }); + + await processor.handleMessage('ws-1', { + type: 'chat_set_model', + model: `sneaky\nrm -rf /${'x'.repeat(400)}`, + }); + + assert.ok(!session.chatModelOverride.includes('\n'), 'a newline would become a second line of the /model turn'); + assert.ok(session.chatModelOverride.length <= 200, `stored ${session.chatModelOverride.length} characters`); + }); + + 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