From 329d28a93ca0b1fe5b462e9e0f3e31b31eddfa93 Mon Sep 17 00:00:00 2001 From: weishu Date: Mon, 16 Mar 2026 18:29:06 +0800 Subject: [PATCH] remove , using instead --- cli/src/agent/sessionBase.ts | 22 +++---- cli/src/api/api.ts | 3 +- cli/src/api/apiSession.ts | 4 +- cli/src/api/types.ts | 10 ++-- cli/src/claude/loop.ts | 8 +-- cli/src/claude/model.test.ts | 21 +++++++ cli/src/claude/model.ts | 10 ++++ cli/src/claude/modelMode.test.ts | 39 ------------ cli/src/claude/modelMode.ts | 30 ---------- cli/src/claude/runClaude.ts | 50 +++++++++------- cli/src/claude/session.ts | 12 ++-- hub/src/notifications/notificationHub.test.ts | 1 + hub/src/socket/handlers/cli/index.ts | 4 +- .../socket/handlers/cli/sessionHandlers.ts | 4 +- hub/src/sync/rpcGateway.ts | 4 +- hub/src/sync/sessionCache.ts | 38 ++++++++---- hub/src/sync/sessionModel.test.ts | 46 +++++++++++++++ hub/src/sync/syncEngine.ts | 10 ++-- hub/src/web/routes/sessions.ts | 18 +++--- shared/src/modes.ts | 31 +++++----- shared/src/schemas.ts | 8 +-- shared/src/sessionSummary.ts | 7 +-- shared/src/socket.ts | 4 +- shared/src/types.ts | 2 +- web/src/api/client.ts | 3 +- web/src/chat/modelConfig.test.ts | 16 ++--- web/src/chat/modelConfig.ts | 35 +++++++---- .../AssistantChat/HappyComposer.tsx | 59 ++++++++++--------- .../components/AssistantChat/StatusBar.tsx | 8 +-- .../AssistantChat/claudeModelOptions.test.ts | 31 ++++++++++ .../AssistantChat/claudeModelOptions.ts | 51 ++++++++++++++++ web/src/components/NewSession/types.test.ts | 10 ++-- web/src/components/SessionChat.tsx | 16 ++--- web/src/hooks/mutations/useSessionActions.ts | 10 ++-- web/src/hooks/useSSE.ts | 13 ++-- web/src/lib/locales/en.ts | 1 - web/src/lib/locales/zh-CN.ts | 1 - web/src/lib/sessionModelLabel.test.ts | 10 ++-- web/src/lib/sessionModelLabel.ts | 18 ++---- web/src/types/api.ts | 1 - 40 files changed, 384 insertions(+), 285 deletions(-) create mode 100644 cli/src/claude/model.test.ts create mode 100644 cli/src/claude/model.ts delete mode 100644 cli/src/claude/modelMode.test.ts delete mode 100644 cli/src/claude/modelMode.ts create mode 100644 web/src/components/AssistantChat/claudeModelOptions.test.ts create mode 100644 web/src/components/AssistantChat/claudeModelOptions.ts diff --git a/cli/src/agent/sessionBase.ts b/cli/src/agent/sessionBase.ts index 365e2ae9..49c7b193 100644 --- a/cli/src/agent/sessionBase.ts +++ b/cli/src/agent/sessionBase.ts @@ -1,6 +1,6 @@ import { ApiClient, ApiSessionClient } from '@/lib'; import { MessageQueue2 } from '@/utils/MessageQueue2'; -import type { Metadata, SessionModelMode, SessionPermissionMode } from '@/api/types'; +import type { Metadata, SessionModel, SessionPermissionMode } from '@/api/types'; import { logger } from '@/ui/logger'; export type AgentSessionBaseOptions = { @@ -16,7 +16,7 @@ export type AgentSessionBaseOptions = { sessionIdLabel: string; applySessionIdToMetadata: (metadata: Metadata, sessionId: string) => Metadata; permissionMode?: SessionPermissionMode; - modelMode?: SessionModelMode; + model?: SessionModel; }; export class AgentSessionBase { @@ -37,7 +37,7 @@ export class AgentSessionBase { private readonly sessionIdLabel: string; private keepAliveInterval: NodeJS.Timeout | null = null; protected permissionMode?: SessionPermissionMode; - protected modelMode?: SessionModelMode; + protected model?: SessionModel; constructor(opts: AgentSessionBaseOptions) { this.path = opts.path; @@ -52,7 +52,7 @@ export class AgentSessionBase { this.sessionIdLabel = opts.sessionIdLabel; this.mode = opts.mode ?? 'local'; this.permissionMode = opts.permissionMode; - this.modelMode = opts.modelMode; + this.model = opts.model; this.client.keepAlive(this.thinking, this.mode, this.getKeepAliveRuntime()); this.keepAliveInterval = setInterval(() => { @@ -70,8 +70,8 @@ export class AgentSessionBase { this.mode = mode; this.client.keepAlive(this.thinking, mode, this.getKeepAliveRuntime()); const permissionLabel = this.permissionMode ?? 'unset'; - const modelLabel = this.modelMode ?? 'unset'; - logger.debug(`[${this.sessionLabel}] Mode switched to ${mode} (permissionMode=${permissionLabel}, modelMode=${modelLabel})`); + const modelLabel = this.model === undefined ? 'unset' : (this.model ?? 'auto'); + logger.debug(`[${this.sessionLabel}] Mode switched to ${mode} (permissionMode=${permissionLabel}, model=${modelLabel})`); this._onModeChange(mode); }; @@ -103,13 +103,13 @@ export class AgentSessionBase { } }; - protected getKeepAliveRuntime(): { permissionMode?: SessionPermissionMode; modelMode?: SessionModelMode } | undefined { - if (this.permissionMode === undefined && this.modelMode === undefined) { + protected getKeepAliveRuntime(): { permissionMode?: SessionPermissionMode; model?: SessionModel } | undefined { + if (this.permissionMode === undefined && this.model === undefined) { return undefined; } return { permissionMode: this.permissionMode, - modelMode: this.modelMode + model: this.model }; } @@ -117,7 +117,7 @@ export class AgentSessionBase { return this.permissionMode; } - getModelMode(): SessionModelMode | undefined { - return this.modelMode; + getModel(): SessionModel | undefined { + return this.model; } } diff --git a/cli/src/api/api.ts b/cli/src/api/api.ts index 907b493e..7cc244f3 100644 --- a/cli/src/api/api.ts +++ b/cli/src/api/api.ts @@ -72,8 +72,7 @@ export class ApiClient { thinkingAt: raw.thinkingAt, todos: raw.todos, model: raw.model, - permissionMode: raw.permissionMode, - modelMode: raw.modelMode + permissionMode: raw.permissionMode } } diff --git a/cli/src/api/apiSession.ts b/cli/src/api/apiSession.ts index 9a5ba2b4..6d3b29cc 100644 --- a/cli/src/api/apiSession.ts +++ b/cli/src/api/apiSession.ts @@ -22,7 +22,7 @@ import type { MessageMeta, Metadata, Session, - SessionModelMode, + SessionModel, SessionPermissionMode, UserMessage } from './types' @@ -438,7 +438,7 @@ export class ApiSessionClient extends EventEmitter { keepAlive( thinking: boolean, mode: 'local' | 'remote', - runtime?: { permissionMode?: SessionPermissionMode; modelMode?: SessionModelMode } + runtime?: { permissionMode?: SessionPermissionMode; model?: SessionModel } ): void { this.socket.volatile.emit('session-alive', { sid: this.sessionId, diff --git a/cli/src/api/types.ts b/cli/src/api/types.ts index 8daf753c..553b635a 100644 --- a/cli/src/api/types.ts +++ b/cli/src/api/types.ts @@ -2,11 +2,10 @@ import { AgentStateSchema, AttachmentMetadataSchema, MetadataSchema, - ModelModeSchema, PermissionModeSchema, TodosSchema } from '@hapi/protocol/schemas' -import type { ModelMode, PermissionMode } from '@hapi/protocol/types' +import type { PermissionMode } from '@hapi/protocol/types' import { z } from 'zod' import { UsageSchema } from '@/claude/types' @@ -21,7 +20,7 @@ export type { Session } from '@hapi/protocol/types' export type SessionPermissionMode = PermissionMode -export type SessionModelMode = ModelMode +export type SessionModel = string | null export { AgentStateSchema, AttachmentMetadataSchema, MetadataSchema } @@ -96,9 +95,8 @@ export const CreateSessionResponseSchema = z.object({ thinking: z.boolean(), thinkingAt: z.number(), todos: TodosSchema.optional(), - model: z.string().optional(), - permissionMode: PermissionModeSchema.optional(), - modelMode: ModelModeSchema.optional() + model: z.string().nullable(), + permissionMode: PermissionModeSchema.optional() }) }) diff --git a/cli/src/claude/loop.ts b/cli/src/claude/loop.ts index a493f110..387d973d 100644 --- a/cli/src/claude/loop.ts +++ b/cli/src/claude/loop.ts @@ -6,9 +6,8 @@ import { Session } from "./session" import { claudeLocalLauncher } from "./claudeLocalLauncher" import { claudeRemoteLauncher } from "./claudeRemoteLauncher" import { ApiClient } from "@/lib" -import type { SessionModelMode } from "@/api/types" +import type { SessionModel } from "@/api/types" import type { ClaudePermissionMode } from "@hapi/protocol/types" -import { resolveClaudeSessionModelMode } from "./modelMode" export type PermissionMode = ClaudePermissionMode; @@ -24,7 +23,7 @@ export interface EnhancedMode { interface LoopOptions { path: string - model?: string + model?: SessionModel permissionMode?: PermissionMode startingMode?: 'local' | 'remote' startedBy?: 'runner' | 'terminal' @@ -46,7 +45,6 @@ export async function loop(opts: LoopOptions) { const logPath = logger.logFilePath; const startedBy = opts.startedBy ?? 'terminal'; const startingMode = opts.startingMode ?? 'local'; - const modelMode: SessionModelMode = resolveClaudeSessionModelMode(opts.model) const session = new Session({ api: opts.api, client: opts.session, @@ -64,7 +62,7 @@ export async function loop(opts: LoopOptions) { startingMode, hookSettingsPath: opts.hookSettingsPath, permissionMode: opts.permissionMode ?? 'default', - modelMode + model: opts.model }); await runLocalRemoteSession({ diff --git a/cli/src/claude/model.test.ts b/cli/src/claude/model.test.ts new file mode 100644 index 00000000..8363ef17 --- /dev/null +++ b/cli/src/claude/model.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import { normalizeClaudeSessionModel } from './model' + +describe('normalizeClaudeSessionModel', () => { + it('returns null when model is missing', () => { + expect(normalizeClaudeSessionModel()).toBeNull() + }) + + it('returns null for auto-like values', () => { + expect(normalizeClaudeSessionModel('')).toBeNull() + expect(normalizeClaudeSessionModel('auto')).toBeNull() + expect(normalizeClaudeSessionModel('default')).toBeNull() + }) + + it('preserves Claude aliases and full model strings', () => { + expect(normalizeClaudeSessionModel('sonnet')).toBe('sonnet') + expect(normalizeClaudeSessionModel('opus[1m]')).toBe('opus[1m]') + expect(normalizeClaudeSessionModel('claude-3-7-sonnet-latest')).toBe('claude-3-7-sonnet-latest') + expect(normalizeClaudeSessionModel(' claude-opus-4-1-20250805 ')).toBe('claude-opus-4-1-20250805') + }) +}) diff --git a/cli/src/claude/model.ts b/cli/src/claude/model.ts new file mode 100644 index 00000000..6b4a2292 --- /dev/null +++ b/cli/src/claude/model.ts @@ -0,0 +1,10 @@ +import type { SessionModel } from '@/api/types' + +export function normalizeClaudeSessionModel(model?: string | null): SessionModel { + const trimmedModel = model?.trim() + if (!trimmedModel || trimmedModel === 'auto' || trimmedModel === 'default') { + return null + } + + return trimmedModel +} diff --git a/cli/src/claude/modelMode.test.ts b/cli/src/claude/modelMode.test.ts deleted file mode 100644 index 1953bf77..00000000 --- a/cli/src/claude/modelMode.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { resolveClaudePersistedModel, resolveClaudeSessionModelMode } from './modelMode' - -describe('resolveClaudeSessionModelMode', () => { - it('returns default when model is missing', () => { - expect(resolveClaudeSessionModelMode()).toBe('default') - }) - - it('returns default for auto and unsupported models', () => { - expect(resolveClaudeSessionModelMode('auto')).toBe('default') - expect(resolveClaudeSessionModelMode('claude-sonnet-4-5')).toBe('default') - }) - - it('returns standard Claude session model modes', () => { - expect(resolveClaudeSessionModelMode('sonnet')).toBe('sonnet') - expect(resolveClaudeSessionModelMode('opus')).toBe('opus') - }) - - it('returns 1m Claude session model modes', () => { - expect(resolveClaudeSessionModelMode('sonnet[1m]')).toBe('sonnet[1m]') - expect(resolveClaudeSessionModelMode('opus[1m]')).toBe('opus[1m]') - }) -}) - -describe('resolveClaudePersistedModel', () => { - it('skips missing, auto, default, and representable mode names', () => { - expect(resolveClaudePersistedModel()).toBeUndefined() - expect(resolveClaudePersistedModel('')).toBeUndefined() - expect(resolveClaudePersistedModel('auto')).toBeUndefined() - expect(resolveClaudePersistedModel('default')).toBeUndefined() - expect(resolveClaudePersistedModel('sonnet')).toBeUndefined() - expect(resolveClaudePersistedModel('opus[1m]')).toBeUndefined() - }) - - it('persists unsupported custom Claude model strings', () => { - expect(resolveClaudePersistedModel('claude-3-7-sonnet-latest')).toBe('claude-3-7-sonnet-latest') - expect(resolveClaudePersistedModel(' claude-opus-4-1-20250805 ')).toBe('claude-opus-4-1-20250805') - }) -}) diff --git a/cli/src/claude/modelMode.ts b/cli/src/claude/modelMode.ts deleted file mode 100644 index d3136390..00000000 --- a/cli/src/claude/modelMode.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { SessionModelMode } from '@/api/types' - -const CLAUDE_SESSION_MODEL_MODES = new Set([ - 'sonnet', - 'sonnet[1m]', - 'opus', - 'opus[1m]' -]) - -export function resolveClaudeSessionModelMode(model?: string): SessionModelMode { - const trimmedModel = model?.trim() - if (!trimmedModel) { - return 'default' - } - - return CLAUDE_SESSION_MODEL_MODES.has(trimmedModel as SessionModelMode) - ? trimmedModel as SessionModelMode - : 'default' -} - -export function resolveClaudePersistedModel(model?: string): string | undefined { - const trimmedModel = model?.trim() - if (!trimmedModel || trimmedModel === 'auto' || trimmedModel === 'default') { - return undefined - } - - return resolveClaudeSessionModelMode(trimmedModel) === 'default' - ? trimmedModel - : undefined -} diff --git a/cli/src/claude/runClaude.ts b/cli/src/claude/runClaude.ts index fb5a9941..920d0398 100644 --- a/cli/src/claude/runClaude.ts +++ b/cli/src/claude/runClaude.ts @@ -1,6 +1,6 @@ import { logger } from '@/ui/logger'; import { loop } from '@/claude/loop'; -import { AgentState, SessionModelMode } from '@/api/types'; +import { AgentState, SessionModel } from '@/api/types'; import { EnhancedMode, PermissionMode } from './loop'; import { MessageQueue2 } from '@/utils/MessageQueue2'; import { hashObject } from '@/utils/deterministicJson'; @@ -14,10 +14,10 @@ import { registerKillSessionHandler } from './registerKillSessionHandler'; import type { Session } from './session'; import { bootstrapSession } from '@/agent/sessionFactory'; import { createModeChangeHandler, createRunnerLifecycle, setControlledByUser } from '@/agent/runnerLifecycle'; -import { isModelModeAllowedForFlavor, isPermissionModeAllowedForFlavor } from '@hapi/protocol'; -import { ModelModeSchema, PermissionModeSchema } from '@hapi/protocol/schemas'; +import { isPermissionModeAllowedForFlavor } from '@hapi/protocol'; +import { PermissionModeSchema } from '@hapi/protocol/schemas'; import { formatMessageWithAttachments } from '@/utils/attachmentFormatter'; -import { resolveClaudePersistedModel, resolveClaudeSessionModelMode } from './modelMode'; +import { normalizeClaudeSessionModel } from './model'; export interface StartOptions { model?: string @@ -46,12 +46,13 @@ export async function runClaude(options: StartOptions = {}): Promise { } const initialState: AgentState = {}; + const initialModel = normalizeClaudeSessionModel(options.model); const { api, session, sessionInfo } = await bootstrapSession({ flavor: 'claude', startedBy, workingDirectory, agentState: initialState, - model: resolveClaudePersistedModel(options.model) + model: initialModel ?? undefined }); logger.debug(`Session created: ${sessionInfo.id}`); @@ -145,7 +146,7 @@ export async function runClaude(options: StartOptions = {}): Promise { // Forward messages to the queue let currentPermissionMode: PermissionMode = options.permissionMode ?? 'default'; - let currentModelMode: SessionModelMode = resolveClaudeSessionModelMode(options.model); + let currentModel: SessionModel = initialModel; let currentFallbackModel: string | undefined = undefined; // Track current fallback model let currentCustomSystemPrompt: string | undefined = undefined; // Track current custom system prompt let currentAppendSystemPrompt: string | undefined = undefined; // Track current append system prompt @@ -158,17 +159,21 @@ export async function runClaude(options: StartOptions = {}): Promise { return; } sessionInstance.setPermissionMode(currentPermissionMode); - sessionInstance.setModelMode(currentModelMode); - logger.debug(`[loop] Synced session modes for keepalive: permissionMode=${currentPermissionMode}, modelMode=${currentModelMode}`); + sessionInstance.setModel(currentModel); + logger.debug(`[loop] Synced session config for keepalive: permissionMode=${currentPermissionMode}, model=${currentModel ?? 'auto'}`); }; session.onUserMessage((message) => { const sessionPermissionMode = currentSessionRef.current?.getPermissionMode(); if (sessionPermissionMode && isPermissionModeAllowedForFlavor(sessionPermissionMode, 'claude')) { currentPermissionMode = sessionPermissionMode as PermissionMode; } + const sessionModel = currentSessionRef.current?.getModel(); + if (sessionModel !== undefined) { + currentModel = sessionModel; + } const messagePermissionMode = currentPermissionMode; - const messageModel = currentModelMode === 'default' ? undefined : currentModelMode; - logger.debug(`[loop] User message received with permission mode: ${currentPermissionMode}, model: ${currentModelMode}`); + const messageModel = currentModel ?? undefined; + logger.debug(`[loop] User message received with permission mode: ${currentPermissionMode}, model: ${currentModel ?? 'auto'}`); // Resolve custom system prompt - use message.meta.customSystemPrompt if provided, otherwise use current let messageCustomSystemPrompt = currentCustomSystemPrompt; @@ -284,31 +289,34 @@ export async function runClaude(options: StartOptions = {}): Promise { return parsed.data as PermissionMode; }; - const resolveModelMode = (value: unknown): SessionModelMode => { - const parsed = ModelModeSchema.safeParse(value); - if (!parsed.success || !isModelModeAllowedForFlavor(parsed.data, 'claude')) { - throw new Error('Invalid model mode'); + const resolveModel = (value: unknown): SessionModel => { + if (value === null) { + return null; } - return parsed.data; + + if (typeof value !== 'string') { + throw new Error('Invalid model'); + } + + return normalizeClaudeSessionModel(value); }; session.rpcHandlerManager.registerHandler('set-session-config', async (payload: unknown) => { if (!payload || typeof payload !== 'object') { throw new Error('Invalid session config payload'); } - const config = payload as { permissionMode?: unknown; modelMode?: unknown }; + const config = payload as { permissionMode?: unknown; model?: unknown }; if (config.permissionMode !== undefined) { currentPermissionMode = resolvePermissionMode(config.permissionMode); } - if (config.modelMode !== undefined) { - const resolvedModelMode = resolveModelMode(config.modelMode); - currentModelMode = resolvedModelMode; + if (config.model !== undefined) { + currentModel = resolveModel(config.model); } syncSessionModes(); - return { applied: { permissionMode: currentPermissionMode, modelMode: currentModelMode } }; + return { applied: { permissionMode: currentPermissionMode, model: currentModel } }; }); let loopError: unknown = null; @@ -316,7 +324,7 @@ export async function runClaude(options: StartOptions = {}): Promise { try { await loop({ path: workingDirectory, - model: options.model, + model: currentModel, permissionMode: options.permissionMode, startingMode, messageQueue, diff --git a/cli/src/claude/session.ts b/cli/src/claude/session.ts index faa11b77..4c155e1b 100644 --- a/cli/src/claude/session.ts +++ b/cli/src/claude/session.ts @@ -2,7 +2,7 @@ import { ApiClient, ApiSessionClient } from '@/lib'; import { MessageQueue2 } from '@/utils/MessageQueue2'; import { logger } from '@/ui/logger'; import { AgentSessionBase } from '@/agent/sessionBase'; -import type { SessionModelMode } from '@/api/types'; +import type { SessionModel } from '@/api/types'; import type { EnhancedMode } from './loop'; import type { PermissionMode } from './loop'; import type { LocalLaunchExitReason } from '@/agent/localLaunchPolicy'; @@ -39,7 +39,7 @@ export class Session extends AgentSessionBase { startingMode: 'local' | 'remote'; hookSettingsPath: string; permissionMode?: PermissionMode; - modelMode?: SessionModelMode; + model?: SessionModel; }) { super({ api: opts.api, @@ -57,7 +57,7 @@ export class Session extends AgentSessionBase { claudeSessionId: sessionId }), permissionMode: opts.permissionMode, - modelMode: opts.modelMode + model: opts.model }); this.claudeEnvVars = opts.claudeEnvVars; @@ -68,15 +68,15 @@ export class Session extends AgentSessionBase { this.startedBy = opts.startedBy; this.startingMode = opts.startingMode; this.permissionMode = opts.permissionMode; - this.modelMode = opts.modelMode; + this.model = opts.model; } setPermissionMode = (mode: PermissionMode): void => { this.permissionMode = mode; }; - setModelMode = (mode: SessionModelMode): void => { - this.modelMode = mode; + setModel = (model: SessionModel): void => { + this.model = model; }; recordLocalLaunchFailure = (message: string, exitReason: LocalLaunchExitReason): void => { diff --git a/hub/src/notifications/notificationHub.test.ts b/hub/src/notifications/notificationHub.test.ts index 07194c82..8aeef30f 100644 --- a/hub/src/notifications/notificationHub.test.ts +++ b/hub/src/notifications/notificationHub.test.ts @@ -57,6 +57,7 @@ function createSession(overrides: Partial = {}): Session { agentStateVersion: 0, thinking: false, thinkingAt: 0, + model: null, ...overrides } } diff --git a/hub/src/socket/handlers/cli/index.ts b/hub/src/socket/handlers/cli/index.ts index ac758c5e..e44471e9 100644 --- a/hub/src/socket/handlers/cli/index.ts +++ b/hub/src/socket/handlers/cli/index.ts @@ -1,4 +1,4 @@ -import type { ModelMode, PermissionMode } from '@hapi/protocol/types' +import type { PermissionMode } from '@hapi/protocol/types' import type { Store, StoredMachine, StoredSession } from '../../../store' import type { RpcRegistry } from '../../rpcRegistry' import type { SyncEvent } from '../../../sync/syncEngine' @@ -16,7 +16,7 @@ type SessionAlivePayload = { thinking?: boolean mode?: 'local' | 'remote' permissionMode?: PermissionMode - modelMode?: ModelMode + model?: string | null } type SessionEndPayload = { diff --git a/hub/src/socket/handlers/cli/sessionHandlers.ts b/hub/src/socket/handlers/cli/sessionHandlers.ts index 179a5fb4..f14fa27d 100644 --- a/hub/src/socket/handlers/cli/sessionHandlers.ts +++ b/hub/src/socket/handlers/cli/sessionHandlers.ts @@ -1,7 +1,7 @@ import type { ClientToServerEvents } from '@hapi/protocol' import { z } from 'zod' import { randomUUID } from 'node:crypto' -import type { ModelMode, PermissionMode } from '@hapi/protocol/types' +import type { PermissionMode } from '@hapi/protocol/types' import type { Store, StoredSession } from '../../../store' import type { SyncEvent } from '../../../sync/syncEngine' import { extractTodoWriteTodosFromMessageContent } from '../../../sync/todos' @@ -15,7 +15,7 @@ type SessionAlivePayload = { thinking?: boolean mode?: 'local' | 'remote' permissionMode?: PermissionMode - modelMode?: ModelMode + model?: string | null } type SessionEndPayload = { diff --git a/hub/src/sync/rpcGateway.ts b/hub/src/sync/rpcGateway.ts index b36e8391..70885965 100644 --- a/hub/src/sync/rpcGateway.ts +++ b/hub/src/sync/rpcGateway.ts @@ -1,4 +1,4 @@ -import type { ModelMode, PermissionMode } from '@hapi/protocol/types' +import type { PermissionMode } from '@hapi/protocol/types' import type { Server } from 'socket.io' import type { RpcRegistry } from '../socket/rpcRegistry' @@ -93,7 +93,7 @@ export class RpcGateway { sessionId: string, config: { permissionMode?: PermissionMode - modelMode?: ModelMode + model?: string | null } ): Promise { return await this.sessionRpc(sessionId, 'set-session-config', config) diff --git a/hub/src/sync/sessionCache.ts b/hub/src/sync/sessionCache.ts index e077a70d..d78b0167 100644 --- a/hub/src/sync/sessionCache.ts +++ b/hub/src/sync/sessionCache.ts @@ -1,5 +1,5 @@ import { AgentStateSchema, MetadataSchema, TeamStateSchema } from '@hapi/protocol/schemas' -import type { ModelMode, PermissionMode, Session } from '@hapi/protocol/types' +import type { PermissionMode, Session } from '@hapi/protocol/types' import type { Store } from '../store' import { clampAliveTime } from './aliveTime' import { EventPublisher } from './eventPublisher' @@ -126,9 +126,8 @@ export class SessionCache { thinkingAt: existing?.thinkingAt ?? 0, todos, teamState, - model: stored.model ?? undefined, - permissionMode: existing?.permissionMode, - modelMode: existing?.modelMode + model: stored.model, + permissionMode: existing?.permissionMode } this.sessions.set(sessionId, session) @@ -149,7 +148,7 @@ export class SessionCache { thinking?: boolean mode?: 'local' | 'remote' permissionMode?: PermissionMode - modelMode?: ModelMode + model?: string | null }): void { const t = clampAliveTime(payload.time) if (!t) return @@ -160,7 +159,7 @@ export class SessionCache { const wasActive = session.active const wasThinking = session.thinking const previousPermissionMode = session.permissionMode - const previousModelMode = session.modelMode + const previousModel = session.model session.active = true session.activeAt = Math.max(session.activeAt, t) @@ -169,13 +168,18 @@ export class SessionCache { if (payload.permissionMode !== undefined) { session.permissionMode = payload.permissionMode } - if (payload.modelMode !== undefined) { - session.modelMode = payload.modelMode + if (payload.model !== undefined) { + if (payload.model !== session.model) { + this.store.sessions.setSessionModel(payload.sid, payload.model, session.namespace, { + touchUpdatedAt: false + }) + } + session.model = payload.model } const now = Date.now() const lastBroadcastAt = this.lastBroadcastAtBySessionId.get(session.id) ?? 0 - const modeChanged = previousPermissionMode !== session.permissionMode || previousModelMode !== session.modelMode + const modeChanged = previousPermissionMode !== session.permissionMode || previousModel !== session.model const shouldBroadcast = (!wasActive && session.active) || (wasThinking !== session.thinking) || modeChanged @@ -191,7 +195,7 @@ export class SessionCache { activeAt: session.activeAt, thinking: session.thinking, permissionMode: session.permissionMode, - modelMode: session.modelMode + model: session.model } }) } @@ -226,7 +230,7 @@ export class SessionCache { } } - applySessionConfig(sessionId: string, config: { permissionMode?: PermissionMode; modelMode?: ModelMode }): void { + applySessionConfig(sessionId: string, config: { permissionMode?: PermissionMode; model?: string | null }): void { const session = this.sessions.get(sessionId) ?? this.refreshSession(sessionId) if (!session) { return @@ -235,8 +239,16 @@ export class SessionCache { if (config.permissionMode !== undefined) { session.permissionMode = config.permissionMode } - if (config.modelMode !== undefined) { - session.modelMode = config.modelMode + if (config.model !== undefined) { + if (config.model !== session.model) { + const updated = this.store.sessions.setSessionModel(sessionId, config.model, session.namespace, { + touchUpdatedAt: false + }) + if (!updated) { + throw new Error('Failed to update session model') + } + } + session.model = config.model } this.publisher.emit({ type: 'session-updated', sessionId, data: session }) diff --git a/hub/src/sync/sessionModel.test.ts b/hub/src/sync/sessionModel.test.ts index 4e76179b..a386d8ab 100644 --- a/hub/src/sync/sessionModel.test.ts +++ b/hub/src/sync/sessionModel.test.ts @@ -58,6 +58,52 @@ describe('session model', () => { expect(merged?.model).toBe('gpt-5.4') }) + it('persists applied session model updates, including clear-to-auto', () => { + const store = new Store(':memory:') + const events: SyncEvent[] = [] + const cache = new SessionCache(store, createPublisher(events)) + + const session = cache.getOrCreateSession( + 'session-model-config', + { path: '/tmp/project', host: 'localhost', flavor: 'claude' }, + null, + 'default', + 'sonnet' + ) + + cache.applySessionConfig(session.id, { model: 'opus[1m]' }) + expect(cache.getSession(session.id)?.model).toBe('opus[1m]') + expect(store.sessions.getSession(session.id)?.model).toBe('opus[1m]') + + cache.applySessionConfig(session.id, { model: null }) + expect(cache.getSession(session.id)?.model).toBeNull() + expect(store.sessions.getSession(session.id)?.model).toBeNull() + }) + + it('persists keepalive model changes, including clearing the model', () => { + const store = new Store(':memory:') + const events: SyncEvent[] = [] + const cache = new SessionCache(store, createPublisher(events)) + + const session = cache.getOrCreateSession( + 'session-model-heartbeat', + { path: '/tmp/project', host: 'localhost', flavor: 'claude' }, + null, + 'default', + 'sonnet' + ) + + cache.handleSessionAlive({ + sid: session.id, + time: Date.now(), + thinking: false, + model: null + }) + + expect(cache.getSession(session.id)?.model).toBeNull() + expect(store.sessions.getSession(session.id)?.model).toBeNull() + }) + it('passes the stored model when respawning a resumed session', async () => { const store = new Store(':memory:') const engine = new SyncEngine( diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index 2b5ff695..7e049bac 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -7,7 +7,7 @@ * - No E2E encryption; data is stored as JSON in SQLite */ -import type { DecryptedMessage, ModelMode, PermissionMode, Session, SyncEvent } from '@hapi/protocol/types' +import type { DecryptedMessage, PermissionMode, Session, SyncEvent } from '@hapi/protocol/types' import type { Server } from 'socket.io' import type { Store } from '../store' import type { RpcRegistry } from '../socket/rpcRegistry' @@ -187,7 +187,7 @@ export class SyncEngine { thinking?: boolean mode?: 'local' | 'remote' permissionMode?: PermissionMode - modelMode?: ModelMode + model?: string | null }): void { this.sessionCache.handleSessionAlive(payload) } @@ -281,14 +281,14 @@ export class SyncEngine { sessionId: string, config: { permissionMode?: PermissionMode - modelMode?: ModelMode + model?: string | null } ): Promise { const result = await this.rpcGateway.requestSessionConfig(sessionId, config) if (!result || typeof result !== 'object') { throw new Error('Invalid response from session config RPC') } - const obj = result as { applied?: { permissionMode?: Session['permissionMode']; modelMode?: Session['modelMode'] } } + const obj = result as { applied?: { permissionMode?: Session['permissionMode']; model?: Session['model'] } } const applied = obj.applied if (!applied || typeof applied !== 'object') { throw new Error('Missing applied session config') @@ -372,7 +372,7 @@ export class SyncEngine { targetMachine.id, metadata.path, flavor, - session.model, + session.model ?? undefined, undefined, undefined, undefined, diff --git a/hub/src/web/routes/sessions.ts b/hub/src/web/routes/sessions.ts index 5f3004b5..a4720a00 100644 --- a/hub/src/web/routes/sessions.ts +++ b/hub/src/web/routes/sessions.ts @@ -1,5 +1,5 @@ -import { getPermissionModesForFlavor, isModelModeAllowedForFlavor, isPermissionModeAllowedForFlavor, toSessionSummary } from '@hapi/protocol' -import { ModelModeSchema, PermissionModeSchema } from '@hapi/protocol/schemas' +import { getPermissionModesForFlavor, isPermissionModeAllowedForFlavor, toSessionSummary } from '@hapi/protocol' +import { PermissionModeSchema } from '@hapi/protocol/schemas' import { Hono } from 'hono' import { z } from 'zod' import type { SyncEngine, Session } from '../../sync/syncEngine' @@ -10,8 +10,8 @@ const permissionModeSchema = z.object({ mode: PermissionModeSchema }) -const modelModeSchema = z.object({ - model: ModelModeSchema +const modelSchema = z.object({ + model: z.string().trim().min(1).nullable() }) const renameSessionSchema = z.object({ @@ -268,21 +268,21 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho } const body = await c.req.json().catch(() => null) - const parsed = modelModeSchema.safeParse(body) + const parsed = modelSchema.safeParse(body) if (!parsed.success) { return c.json({ error: 'Invalid body' }, 400) } const flavor = sessionResult.session.metadata?.flavor ?? 'claude' - if (!isModelModeAllowedForFlavor(parsed.data.model, flavor)) { - return c.json({ error: 'Model mode is only supported for Claude sessions' }, 400) + if (flavor !== 'claude') { + return c.json({ error: 'Model selection is only supported for Claude sessions' }, 400) } try { - await engine.applySessionConfig(sessionResult.sessionId, { modelMode: parsed.data.model }) + await engine.applySessionConfig(sessionResult.sessionId, { model: parsed.data.model }) return c.json({ ok: true }) } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to apply model mode' + const message = error instanceof Error ? error.message : 'Failed to apply model' return c.json({ error: message }, 409) } }) diff --git a/shared/src/modes.ts b/shared/src/modes.ts index 24817293..dcc3e3bb 100644 --- a/shared/src/modes.ts +++ b/shared/src/modes.ts @@ -25,8 +25,8 @@ export const PERMISSION_MODES = [ ] as const export type PermissionMode = typeof PERMISSION_MODES[number] -export const MODEL_MODES = ['default', 'sonnet', 'sonnet[1m]', 'opus', 'opus[1m]'] as const -export type ModelMode = typeof MODEL_MODES[number] +export const CLAUDE_MODEL_PRESETS = ['sonnet', 'sonnet[1m]', 'opus', 'opus[1m]'] as const +export type ClaudeModelPreset = typeof CLAUDE_MODEL_PRESETS[number] export type AgentFlavor = 'claude' | 'codex' | 'gemini' | 'opencode' | 'cursor' @@ -60,16 +60,24 @@ export type PermissionModeOption = { tone: PermissionModeTone } -export const MODEL_MODE_LABELS: Record = { - default: 'Default', +export const CLAUDE_MODEL_LABELS: Record = { sonnet: 'Sonnet', 'sonnet[1m]': 'Sonnet 1M', opus: 'Opus', 'opus[1m]': 'Opus 1M' } -export function getModelModeLabel(mode: ModelMode): string { - return MODEL_MODE_LABELS[mode] +export function isClaudeModelPreset(model: string | null | undefined): model is ClaudeModelPreset { + return typeof model === 'string' && CLAUDE_MODEL_PRESETS.includes(model as ClaudeModelPreset) +} + +export function getClaudeModelLabel(model: string): string | null { + const trimmedModel = model.trim() + if (!trimmedModel) { + return null + } + + return CLAUDE_MODEL_LABELS[trimmedModel as ClaudeModelPreset] ?? null } export function getPermissionModeLabel(mode: PermissionMode): string { @@ -107,14 +115,3 @@ export function getPermissionModeOptionsForFlavor(flavor?: string | null): Permi export function isPermissionModeAllowedForFlavor(mode: PermissionMode, flavor?: string | null): boolean { return getPermissionModesForFlavor(flavor).includes(mode) } - -export function getModelModesForFlavor(flavor?: string | null): readonly ModelMode[] { - if (flavor === 'codex' || flavor === 'gemini' || flavor === 'opencode' || flavor === 'cursor') { - return [] - } - return MODEL_MODES -} - -export function isModelModeAllowedForFlavor(mode: ModelMode, flavor?: string | null): boolean { - return getModelModesForFlavor(flavor).includes(mode) -} diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index 937475b0..df2b4774 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -1,8 +1,7 @@ import { z } from 'zod' -import { MODEL_MODES, PERMISSION_MODES } from './modes' +import { PERMISSION_MODES } from './modes' export const PermissionModeSchema = z.enum(PERMISSION_MODES) -export const ModelModeSchema = z.enum(MODEL_MODES) const MetadataSummarySchema = z.object({ text: z.string(), @@ -174,9 +173,8 @@ export const SessionSchema = z.object({ thinkingAt: z.number(), todos: TodosSchema.optional(), teamState: TeamStateSchema.optional(), - model: z.string().optional(), - permissionMode: PermissionModeSchema.optional(), - modelMode: ModelModeSchema.optional() + model: z.string().nullable(), + permissionMode: PermissionModeSchema.optional() }) export type Session = z.infer diff --git a/shared/src/sessionSummary.ts b/shared/src/sessionSummary.ts index 6e25faf6..5d44319a 100644 --- a/shared/src/sessionSummary.ts +++ b/shared/src/sessionSummary.ts @@ -1,4 +1,3 @@ -import type { ModelMode } from './modes' import type { Session, WorktreeMetadata } from './schemas' export type SessionSummaryMetadata = { @@ -19,8 +18,7 @@ export type SessionSummary = { metadata: SessionSummaryMetadata | null todoProgress: { completed: number; total: number } | null pendingRequestsCount: number - model?: string - modelMode?: ModelMode + model: string | null } export function toSessionSummary(session: Session): SessionSummary { @@ -49,7 +47,6 @@ export function toSessionSummary(session: Session): SessionSummary { metadata, todoProgress, pendingRequestsCount, - model: session.model, - modelMode: session.modelMode + model: session.model } } diff --git a/shared/src/socket.ts b/shared/src/socket.ts index ae9e0efb..64181fdd 100644 --- a/shared/src/socket.ts +++ b/shared/src/socket.ts @@ -1,5 +1,5 @@ import { z } from 'zod' -import type { ModelMode, PermissionMode } from './modes' +import type { PermissionMode } from './modes' export type SocketErrorReason = 'namespace-missing' | 'access-denied' | 'not-found' @@ -139,7 +139,7 @@ export interface ClientToServerEvents { thinking: boolean mode?: 'local' | 'remote' permissionMode?: PermissionMode - modelMode?: ModelMode + model?: string | null }) => void 'session-end': (data: { sid: string; time: number }) => void 'update-metadata': (data: { sid: string; expectedVersion: number; metadata: unknown }, cb: (answer: { diff --git a/shared/src/types.ts b/shared/src/types.ts index 60f95018..4522f30b 100644 --- a/shared/src/types.ts +++ b/shared/src/types.ts @@ -24,7 +24,7 @@ export type { CursorPermissionMode, GeminiPermissionMode, OpencodePermissionMode, - ModelMode, + ClaudeModelPreset, PermissionMode, PermissionModeOption, PermissionModeTone diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 2d1dbfd2..d7574a27 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -9,7 +9,6 @@ import type { MachinePathsExistsResponse, MachinesResponse, MessagesResponse, - ModelMode, PermissionMode, PushSubscriptionPayload, PushUnsubscribePayload, @@ -313,7 +312,7 @@ export class ApiClient { }) } - async setModelMode(sessionId: string, model: ModelMode): Promise { + async setModel(sessionId: string, model: string | null): Promise { await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/model`, { method: 'POST', body: JSON.stringify({ model }) diff --git a/web/src/chat/modelConfig.test.ts b/web/src/chat/modelConfig.test.ts index 7392f5db..f3d14de7 100644 --- a/web/src/chat/modelConfig.test.ts +++ b/web/src/chat/modelConfig.test.ts @@ -2,15 +2,15 @@ import { describe, expect, it } from 'vitest' import { getContextBudgetTokens } from './modelConfig' describe('getContextBudgetTokens', () => { - it('uses the existing 200k budget for default Claude modes', () => { - expect(getContextBudgetTokens(undefined)).toBe(190_000) - expect(getContextBudgetTokens('default')).toBe(190_000) - expect(getContextBudgetTokens('sonnet')).toBe(190_000) - expect(getContextBudgetTokens('opus')).toBe(190_000) + it('uses the large budget only for explicit 1m Claude presets', () => { + expect(getContextBudgetTokens('sonnet[1m]', 'claude')).toBe(990_000) }) - it('uses the 1m budget for Claude 1m modes', () => { - expect(getContextBudgetTokens('sonnet[1m]')).toBe(990_000) - expect(getContextBudgetTokens('opus[1m]')).toBe(990_000) + it('uses the default Claude budget for full Claude model names', () => { + expect(getContextBudgetTokens('claude-sonnet-4-6', 'claude')).toBe(190_000) + }) + + it('returns null for non-Claude sessions', () => { + expect(getContextBudgetTokens('gpt-5.4', 'codex')).toBeNull() }) }) diff --git a/web/src/chat/modelConfig.ts b/web/src/chat/modelConfig.ts index 4a54a0f9..ed262d20 100644 --- a/web/src/chat/modelConfig.ts +++ b/web/src/chat/modelConfig.ts @@ -1,4 +1,4 @@ -import type { ModelMode } from '@/types/api' +import { isClaudeModelPreset } from '@hapi/protocol' /** * Context windows vary by model/provider and may change over time. @@ -11,19 +11,30 @@ import type { ModelMode } from '@/types/api' * and use this only as a fallback. */ const CONTEXT_HEADROOM_TOKENS = 10_000 +const DEFAULT_CLAUDE_CONTEXT_WINDOW_TOKENS = 200_000 +const LARGE_CLAUDE_CONTEXT_WINDOW_TOKENS = 1_000_000 -const MODEL_CONTEXT_WINDOWS: Record = { - // Claude Code modes used in this app. 1M variants get the larger budget. - default: 200_000, - sonnet: 200_000, - 'sonnet[1m]': 1_000_000, - opus: 200_000, - 'opus[1m]': 1_000_000 -} +export function getContextBudgetTokens(model: string | null | undefined, flavor?: string | null): number | null { + if (flavor !== 'claude') { + return null + } + + const trimmedModel = model?.trim() + const windowTokens = (() => { + if (!trimmedModel) { + return DEFAULT_CLAUDE_CONTEXT_WINDOW_TOKENS + } + if (isClaudeModelPreset(trimmedModel)) { + return trimmedModel.endsWith('[1m]') + ? LARGE_CLAUDE_CONTEXT_WINDOW_TOKENS + : DEFAULT_CLAUDE_CONTEXT_WINDOW_TOKENS + } + if (trimmedModel.startsWith('claude-')) { + return DEFAULT_CLAUDE_CONTEXT_WINDOW_TOKENS + } + return null + })() -export function getContextBudgetTokens(modelMode: ModelMode | undefined): number | null { - const mode: ModelMode = modelMode ?? 'default' - const windowTokens = MODEL_CONTEXT_WINDOWS[mode] if (!windowTokens) return null return Math.max(1, windowTokens - CONTEXT_HEADROOM_TOKENS) } diff --git a/web/src/components/AssistantChat/HappyComposer.tsx b/web/src/components/AssistantChat/HappyComposer.tsx index 1ab88f29..71887cb6 100644 --- a/web/src/components/AssistantChat/HappyComposer.tsx +++ b/web/src/components/AssistantChat/HappyComposer.tsx @@ -1,4 +1,4 @@ -import { getPermissionModeOptionsForFlavor, MODEL_MODE_LABELS, MODEL_MODES } from '@hapi/protocol' +import { getPermissionModeOptionsForFlavor } from '@hapi/protocol' import { ComposerPrimitive, useAssistantApi, useAssistantState } from '@assistant-ui/react' import { type ChangeEvent as ReactChangeEvent, @@ -12,7 +12,7 @@ import { useRef, useState } from 'react' -import type { AgentState, ModelMode, PermissionMode } from '@/types/api' +import type { AgentState, PermissionMode } from '@/types/api' import type { Suggestion } from '@/hooks/useActiveSuggestions' import type { ConversationStatus } from '@/realtime/types' import { useActiveWord } from '@/hooks/useActiveWord' @@ -28,6 +28,7 @@ import { StatusBar } from '@/components/AssistantChat/StatusBar' import { ComposerButtons } from '@/components/AssistantChat/ComposerButtons' import { AttachmentItem } from '@/components/AssistantChat/AttachmentItem' import { useTranslation } from '@/lib/use-translation' +import { getClaudeComposerModelOptions, getNextClaudeComposerModel } from './claudeModelOptions' export interface TextInputState { text: string @@ -39,7 +40,7 @@ const defaultSuggestionHandler = async (): Promise => [] export function HappyComposer(props: { disabled?: boolean permissionMode?: PermissionMode - modelMode?: ModelMode + model?: string | null active?: boolean allowSendWhenInactive?: boolean thinking?: boolean @@ -48,7 +49,7 @@ export function HappyComposer(props: { controlledByUser?: boolean agentFlavor?: string | null onPermissionModeChange?: (mode: PermissionMode) => void - onModelModeChange?: (mode: ModelMode) => void + onModelChange?: (model: string | null) => void onSwitchToRemote?: () => void onTerminal?: () => void autocompletePrefixes?: string[] @@ -63,7 +64,7 @@ export function HappyComposer(props: { const { disabled = false, permissionMode: rawPermissionMode, - modelMode: rawModelMode, + model: rawModel, active = true, allowSendWhenInactive = false, thinking = false, @@ -72,7 +73,7 @@ export function HappyComposer(props: { controlledByUser = false, agentFlavor, onPermissionModeChange, - onModelModeChange, + onModelChange, onSwitchToRemote, onTerminal, autocompletePrefixes = ['@', '/', '$'], @@ -85,7 +86,7 @@ export function HappyComposer(props: { // Use ?? so missing values fall back to default (destructuring defaults only handle undefined) const permissionMode = rawPermissionMode ?? 'default' - const modelMode = rawModelMode ?? 'default' + const model = rawModel ?? null const api = useAssistantApi() const composerText = useAssistantState(({ composer }) => composer.text) @@ -245,6 +246,10 @@ export function HappyComposer(props: { () => getPermissionModeOptionsForFlavor(agentFlavor), [agentFlavor] ) + const claudeModelOptions = useMemo( + () => getClaudeComposerModelOptions(model), + [model] + ) const permissionModes = useMemo( () => permissionModeOptions.map((option) => option.mode), [permissionModeOptions] @@ -324,18 +329,16 @@ export function HappyComposer(props: { useEffect(() => { const handleGlobalKeyDown = (e: globalThis.KeyboardEvent) => { - if (e.key === 'm' && (e.metaKey || e.ctrlKey) && onModelModeChange && isClaudeFlavor(agentFlavor)) { + if (e.key === 'm' && (e.metaKey || e.ctrlKey) && onModelChange && isClaudeFlavor(agentFlavor)) { e.preventDefault() - const currentIndex = MODEL_MODES.indexOf(modelMode as typeof MODEL_MODES[number]) - const nextIndex = (currentIndex + 1) % MODEL_MODES.length - onModelModeChange(MODEL_MODES[nextIndex]) + onModelChange(getNextClaudeComposerModel(model)) haptic('light') } } window.addEventListener('keydown', handleGlobalKeyDown) return () => window.removeEventListener('keydown', handleGlobalKeyDown) - }, [modelMode, onModelModeChange, haptic, agentFlavor]) + }, [model, onModelChange, haptic, agentFlavor]) const handleChange = useCallback((e: ReactChangeEvent) => { const selection = { @@ -390,15 +393,15 @@ export function HappyComposer(props: { haptic('light') }, [onPermissionModeChange, controlsDisabled, haptic]) - const handleModelChange = useCallback((mode: ModelMode) => { - if (!onModelModeChange || controlsDisabled) return - onModelModeChange(mode) + const handleModelChange = useCallback((nextModel: string | null) => { + if (!onModelChange || controlsDisabled) return + onModelChange(nextModel) setShowSettings(false) haptic('light') - }, [onModelModeChange, controlsDisabled, haptic]) + }, [onModelChange, controlsDisabled, haptic]) const showPermissionSettings = Boolean(onPermissionModeChange && permissionModeOptions.length > 0) - const showModelSettings = Boolean(onModelModeChange && isClaudeFlavor(agentFlavor)) + const showModelSettings = Boolean(onModelChange && isClaudeFlavor(agentFlavor)) const showSettingsButton = Boolean(showPermissionSettings || showModelSettings) const showAbortButton = true const voiceEnabled = Boolean(onVoiceToggle) @@ -458,9 +461,9 @@ export function HappyComposer(props: {
{t('misc.model')}
- {MODEL_MODES.map((mode) => ( + {claudeModelOptions.map((option) => ( ))} @@ -513,15 +516,17 @@ export function HappyComposer(props: { showSettings, showPermissionSettings, showModelSettings, + claudeModelOptions, suggestions, selectedIndex, controlsDisabled, permissionMode, - modelMode, + model, permissionModeOptions, handlePermissionChange, handleModelChange, - handleSuggestionSelect + handleSuggestionSelect, + t ]) return ( @@ -535,7 +540,7 @@ export function HappyComposer(props: { thinking={thinking} agentState={agentState} contextSize={contextSize} - modelMode={modelMode} + model={model} permissionMode={permissionMode} agentFlavor={agentFlavor} voiceStatus={voiceStatus} diff --git a/web/src/components/AssistantChat/StatusBar.tsx b/web/src/components/AssistantChat/StatusBar.tsx index 948c89c2..7128e963 100644 --- a/web/src/components/AssistantChat/StatusBar.tsx +++ b/web/src/components/AssistantChat/StatusBar.tsx @@ -1,7 +1,7 @@ import { getPermissionModeLabel, getPermissionModeTone, isPermissionModeAllowedForFlavor } from '@hapi/protocol' import type { PermissionModeTone } from '@hapi/protocol' import { useMemo } from 'react' -import type { AgentState, ModelMode, PermissionMode } from '@/types/api' +import type { AgentState, PermissionMode } from '@/types/api' import type { ConversationStatus } from '@/realtime/types' import { getContextBudgetTokens } from '@/chat/modelConfig' import { useTranslation } from '@/lib/use-translation' @@ -106,7 +106,7 @@ export function StatusBar(props: { thinking: boolean agentState: AgentState | null | undefined contextSize?: number - modelMode?: ModelMode + model?: string | null permissionMode?: PermissionMode agentFlavor?: string | null voiceStatus?: ConversationStatus @@ -120,11 +120,11 @@ export function StatusBar(props: { const contextWarning = useMemo( () => { if (props.contextSize === undefined) return null - const maxContextSize = getContextBudgetTokens(props.modelMode) + const maxContextSize = getContextBudgetTokens(props.model, props.agentFlavor) if (!maxContextSize) return null return getContextWarning(props.contextSize, maxContextSize, t) }, - [props.contextSize, props.modelMode, t] + [props.contextSize, props.model, props.agentFlavor, t] ) const permissionMode = props.permissionMode diff --git a/web/src/components/AssistantChat/claudeModelOptions.test.ts b/web/src/components/AssistantChat/claudeModelOptions.test.ts new file mode 100644 index 00000000..fe9781b5 --- /dev/null +++ b/web/src/components/AssistantChat/claudeModelOptions.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' +import { getClaudeComposerModelOptions, getNextClaudeComposerModel } from './claudeModelOptions' + +describe('getClaudeComposerModelOptions', () => { + it('includes the active non-preset Claude model in the options list', () => { + expect(getClaudeComposerModelOptions('claude-opus-4-1-20250805')).toEqual([ + { value: null, label: 'Auto' }, + { value: 'claude-opus-4-1-20250805', label: 'claude-opus-4-1-20250805' }, + { value: 'sonnet', label: 'Sonnet' }, + { value: 'sonnet[1m]', label: 'Sonnet 1M' }, + { value: 'opus', label: 'Opus' }, + { value: 'opus[1m]', label: 'Opus 1M' }, + ]) + }) + + it('does not duplicate preset Claude models', () => { + expect(getClaudeComposerModelOptions('opus')).toEqual([ + { value: null, label: 'Auto' }, + { value: 'sonnet', label: 'Sonnet' }, + { value: 'sonnet[1m]', label: 'Sonnet 1M' }, + { value: 'opus', label: 'Opus' }, + { value: 'opus[1m]', label: 'Opus 1M' }, + ]) + }) +}) + +describe('getNextClaudeComposerModel', () => { + it('cycles from a non-preset Claude model to the next selectable model instead of auto', () => { + expect(getNextClaudeComposerModel('claude-opus-4-1-20250805')).toBe('sonnet') + }) +}) diff --git a/web/src/components/AssistantChat/claudeModelOptions.ts b/web/src/components/AssistantChat/claudeModelOptions.ts new file mode 100644 index 00000000..4b4ed74e --- /dev/null +++ b/web/src/components/AssistantChat/claudeModelOptions.ts @@ -0,0 +1,51 @@ +import { CLAUDE_MODEL_PRESETS, getClaudeModelLabel } from '@hapi/protocol' + +export type ClaudeComposerModelOption = { + value: string | null + label: string +} + +function normalizeClaudeComposerModel(model?: string | null): string | null { + const trimmedModel = model?.trim() + if (!trimmedModel || trimmedModel === 'auto' || trimmedModel === 'default') { + return null + } + + return trimmedModel +} + +export function getClaudeComposerModelOptions(currentModel?: string | null): ClaudeComposerModelOption[] { + const normalizedCurrentModel = normalizeClaudeComposerModel(currentModel) + const options: ClaudeComposerModelOption[] = [ + { value: null, label: 'Auto' } + ] + + if ( + normalizedCurrentModel + && !CLAUDE_MODEL_PRESETS.includes(normalizedCurrentModel as typeof CLAUDE_MODEL_PRESETS[number]) + ) { + options.push({ + value: normalizedCurrentModel, + label: getClaudeModelLabel(normalizedCurrentModel) ?? normalizedCurrentModel + }) + } + + options.push(...CLAUDE_MODEL_PRESETS.map((model) => ({ + value: model, + label: getClaudeModelLabel(model) ?? model + }))) + + return options +} + +export function getNextClaudeComposerModel(currentModel?: string | null): string | null { + const normalizedCurrentModel = normalizeClaudeComposerModel(currentModel) + const options = getClaudeComposerModelOptions(normalizedCurrentModel) + const currentIndex = options.findIndex((option) => option.value === normalizedCurrentModel) + + if (currentIndex === -1) { + return options[0]?.value ?? null + } + + return options[(currentIndex + 1) % options.length]?.value ?? null +} diff --git a/web/src/components/NewSession/types.test.ts b/web/src/components/NewSession/types.test.ts index 61f7ae03..5d00c30b 100644 --- a/web/src/components/NewSession/types.test.ts +++ b/web/src/components/NewSession/types.test.ts @@ -1,4 +1,4 @@ -import { getModelModeLabel, MODEL_MODES } from '@hapi/protocol' +import { CLAUDE_MODEL_PRESETS, getClaudeModelLabel } from '@hapi/protocol' import { describe, expect, it } from 'vitest' import { MODEL_OPTIONS } from './types' @@ -13,9 +13,9 @@ describe('Claude model options', () => { ]) }) - it('exposes friendly labels for session model modes', () => { - expect(MODEL_MODES).toEqual(['default', 'sonnet', 'sonnet[1m]', 'opus', 'opus[1m]']) - expect(getModelModeLabel('sonnet[1m]')).toBe('Sonnet 1M') - expect(getModelModeLabel('opus[1m]')).toBe('Opus 1M') + it('exposes friendly labels for Claude model presets', () => { + expect(CLAUDE_MODEL_PRESETS).toEqual(['sonnet', 'sonnet[1m]', 'opus', 'opus[1m]']) + expect(getClaudeModelLabel('sonnet[1m]')).toBe('Sonnet 1M') + expect(getClaudeModelLabel('opus[1m]')).toBe('Opus 1M') }) }) diff --git a/web/src/components/SessionChat.tsx b/web/src/components/SessionChat.tsx index 569f40b7..c28d9c43 100644 --- a/web/src/components/SessionChat.tsx +++ b/web/src/components/SessionChat.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useNavigate } from '@tanstack/react-router' import { AssistantRuntimeProvider } from '@assistant-ui/react' import type { ApiClient } from '@/api/client' -import type { AttachmentMetadata, DecryptedMessage, ModelMode, PermissionMode, Session } from '@/types/api' +import type { AttachmentMetadata, DecryptedMessage, PermissionMode, Session } from '@/types/api' import type { ChatBlock, NormalizedMessage } from '@/chat/types' import type { Suggestion } from '@/hooks/useActiveSuggestions' import { normalizeDecryptedMessage } from '@/chat/normalize' @@ -46,7 +46,7 @@ export function SessionChat(props: { const blocksByIdRef = useRef>(new Map()) const [forceScrollToken, setForceScrollToken] = useState(0) const agentFlavor = props.session.metadata?.flavor ?? null - const { abortSession, switchSession, setPermissionMode, setModelMode } = useSessionActions( + const { abortSession, switchSession, setPermissionMode, setModel } = useSessionActions( props.api, props.session.id, agentFlavor @@ -206,16 +206,16 @@ export function SessionChat(props: { }, [setPermissionMode, props.onRefresh, haptic]) // Model mode change handler - const handleModelModeChange = useCallback(async (mode: ModelMode) => { + const handleModelChange = useCallback(async (model: string | null) => { try { - await setModelMode(mode) + await setModel(model) haptic.notification('success') props.onRefresh() } catch (e) { haptic.notification('error') - console.error('Failed to set model mode:', e) + console.error('Failed to set model:', e) } - }, [setModelMode, props.onRefresh, haptic]) + }, [setModel, props.onRefresh, haptic]) // Abort handler const handleAbort = useCallback(async () => { @@ -314,7 +314,7 @@ export function SessionChat(props: { Promise switchSession: () => Promise setPermissionMode: (mode: PermissionMode) => Promise - setModelMode: (mode: ModelMode) => Promise + setModel: (model: string | null) => Promise renameSession: (name: string) => Promise deleteSession: () => Promise isPending: boolean @@ -72,11 +72,11 @@ export function useSessionActions( }) const modelMutation = useMutation({ - mutationFn: async (mode: ModelMode) => { + mutationFn: async (model: string | null) => { if (!api || !sessionId) { throw new Error('Session unavailable') } - await api.setModelMode(sessionId, mode) + await api.setModel(sessionId, model) }, onSuccess: () => void invalidateSession(), }) @@ -111,7 +111,7 @@ export function useSessionActions( archiveSession: archiveMutation.mutateAsync, switchSession: switchMutation.mutateAsync, setPermissionMode: permissionMutation.mutateAsync, - setModelMode: modelMutation.mutateAsync, + setModel: modelMutation.mutateAsync, renameSession: renameMutation.mutateAsync, deleteSession: deleteMutation.mutateAsync, isPending: abortMutation.isPending diff --git a/web/src/hooks/useSSE.ts b/web/src/hooks/useSSE.ts index 22c6f571..90da7baa 100644 --- a/web/src/hooks/useSSE.ts +++ b/web/src/hooks/useSSE.ts @@ -30,7 +30,7 @@ const RECONNECT_MAX_DELAY_MS = 30_000 const RECONNECT_JITTER_MS = 500 const INVALIDATION_BATCH_MS = 16 -type SessionPatch = Partial> +type SessionPatch = Partial> function sortSessionSummaries(left: SessionSummary, right: SessionSummary): number { if (left.active !== right.active) { @@ -81,7 +81,7 @@ function getSessionPatch(value: unknown): SessionPatch | null { patch.updatedAt = value.updatedAt hasKnownPatch = true } - if (typeof value.model === 'string') { + if (value.model === null || typeof value.model === 'string') { patch.model = value.model hasKnownPatch = true } @@ -89,10 +89,6 @@ function getSessionPatch(value: unknown): SessionPatch | null { patch.permissionMode = value.permissionMode as Session['permissionMode'] hasKnownPatch = true } - if (typeof value.modelMode === 'string') { - patch.modelMode = value.modelMode as Session['modelMode'] - hasKnownPatch = true - } return hasKnownPatch ? patch : null } @@ -101,7 +97,7 @@ function hasUnknownSessionPatchKeys(value: unknown): boolean { if (!hasRecordShape(value)) { return false } - const knownKeys = new Set(['active', 'thinking', 'activeAt', 'updatedAt', 'model', 'permissionMode', 'modelMode']) + const knownKeys = new Set(['active', 'thinking', 'activeAt', 'updatedAt', 'model', 'permissionMode']) return Object.keys(value).some((key) => !knownKeys.has(key)) } @@ -386,8 +382,7 @@ export function useSSE(options: { thinking: patch.thinking ?? current.thinking, activeAt: patch.activeAt ?? current.activeAt, updatedAt: patch.updatedAt ?? current.updatedAt, - model: patch.model ?? current.model, - modelMode: patch.modelMode ?? current.modelMode + model: Object.prototype.hasOwnProperty.call(patch, 'model') ? patch.model ?? null : current.model } patched = true diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 9c67e8d2..71cd5edf 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -46,7 +46,6 @@ export default { 'session.item.path': 'path', 'session.item.agent': 'agent', 'session.item.model': 'model', - 'session.item.modelMode': 'mode', 'session.item.worktree': 'worktree', 'session.item.pending': 'pending', 'session.item.thinking': 'thinking', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 7337edba..2419a4d4 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -46,7 +46,6 @@ export default { 'session.item.path': '路径', 'session.item.agent': '代理', 'session.item.model': '模型', - 'session.item.modelMode': '模式', 'session.item.worktree': '工作树', 'session.item.pending': '待处理', 'session.item.thinking': '思考中', diff --git a/web/src/lib/sessionModelLabel.test.ts b/web/src/lib/sessionModelLabel.test.ts index b351a475..898a46ff 100644 --- a/web/src/lib/sessionModelLabel.test.ts +++ b/web/src/lib/sessionModelLabel.test.ts @@ -3,20 +3,20 @@ import { getSessionModelLabel } from './sessionModelLabel' describe('getSessionModelLabel', () => { it('prefers the explicit session model', () => { - expect(getSessionModelLabel({ model: 'gpt-5.4', modelMode: 'default' })).toEqual({ + expect(getSessionModelLabel({ model: 'gpt-5.4' })).toEqual({ key: 'session.item.model', value: 'gpt-5.4' }) }) - it('falls back to Claude model mode when no explicit model exists', () => { - expect(getSessionModelLabel({ modelMode: 'opus' })).toEqual({ - key: 'session.item.modelMode', + it('renders friendly labels for known Claude aliases', () => { + expect(getSessionModelLabel({ model: 'opus' })).toEqual({ + key: 'session.item.model', value: 'Opus' }) }) - it('returns null when neither model nor mode is available', () => { + it('returns null when no model is available', () => { expect(getSessionModelLabel({})).toBeNull() }) }) diff --git a/web/src/lib/sessionModelLabel.ts b/web/src/lib/sessionModelLabel.ts index 466b6d6b..8662b8a1 100644 --- a/web/src/lib/sessionModelLabel.ts +++ b/web/src/lib/sessionModelLabel.ts @@ -1,10 +1,11 @@ -import { getModelModeLabel } from '@hapi/protocol' -import type { Session, SessionSummary } from '@/types/api' +import { getClaudeModelLabel } from '@hapi/protocol' -type SessionModelSource = Pick | Pick +type SessionModelSource = { + model?: string | null +} export type SessionModelLabel = { - key: 'session.item.model' | 'session.item.modelMode' + key: 'session.item.model' value: string } @@ -13,14 +14,7 @@ export function getSessionModelLabel(session: SessionModelSource): SessionModelL if (explicitModel) { return { key: 'session.item.model', - value: explicitModel - } - } - - if (session.modelMode) { - return { - key: 'session.item.modelMode', - value: getModelModeLabel(session.modelMode) + value: getClaudeModelLabel(explicitModel) ?? explicitModel } } diff --git a/web/src/types/api.ts b/web/src/types/api.ts index 14a7b6c6..332a2b6c 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -9,7 +9,6 @@ import type { export type { AgentState, AttachmentMetadata, - ModelMode, PermissionMode, Session, SessionSummary,