diff --git a/cli/src/agent/sessionFactory.ts b/cli/src/agent/sessionFactory.ts index b3c91175..43e06a76 100644 --- a/cli/src/agent/sessionFactory.ts +++ b/cli/src/agent/sessionFactory.ts @@ -21,6 +21,7 @@ export type SessionBootstrapOptions = { workingDirectory?: string tag?: string agentState?: AgentState | null + model?: string } export type SessionBootstrapResult = { @@ -124,7 +125,8 @@ export async function bootstrapSession(options: SessionBootstrapOptions): Promis const sessionInfo = await api.getOrCreateSession({ tag: sessionTag, metadata, - state: agentState + state: agentState, + model: options.model }) const session = api.sessionSyncClient(sessionInfo) diff --git a/cli/src/api/api.ts b/cli/src/api/api.ts index 97999de8..907b493e 100644 --- a/cli/src/api/api.ts +++ b/cli/src/api/api.ts @@ -18,13 +18,15 @@ export class ApiClient { tag: string metadata: Metadata state: AgentState | null + model?: string }): Promise { const response = await axios.post( `${configuration.apiUrl}/cli/sessions`, { tag: opts.tag, metadata: opts.metadata, - agentState: opts.state + agentState: opts.state, + model: opts.model }, { headers: { @@ -69,6 +71,7 @@ export class ApiClient { thinking: raw.thinking, thinkingAt: raw.thinkingAt, todos: raw.todos, + model: raw.model, permissionMode: raw.permissionMode, modelMode: raw.modelMode } diff --git a/cli/src/api/types.ts b/cli/src/api/types.ts index f340a453..8daf753c 100644 --- a/cli/src/api/types.ts +++ b/cli/src/api/types.ts @@ -96,6 +96,7 @@ export const CreateSessionResponseSchema = z.object({ thinking: z.boolean(), thinkingAt: z.number(), todos: TodosSchema.optional(), + model: z.string().optional(), permissionMode: PermissionModeSchema.optional(), modelMode: ModelModeSchema.optional() }) diff --git a/cli/src/claude/modelMode.test.ts b/cli/src/claude/modelMode.test.ts index 06d1cdf5..1953bf77 100644 --- a/cli/src/claude/modelMode.test.ts +++ b/cli/src/claude/modelMode.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { resolveClaudeSessionModelMode } from './modelMode' +import { resolveClaudePersistedModel, resolveClaudeSessionModelMode } from './modelMode' describe('resolveClaudeSessionModelMode', () => { it('returns default when model is missing', () => { @@ -21,3 +21,19 @@ describe('resolveClaudeSessionModelMode', () => { 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 index b8f74f2b..d3136390 100644 --- a/cli/src/claude/modelMode.ts +++ b/cli/src/claude/modelMode.ts @@ -8,11 +8,23 @@ const CLAUDE_SESSION_MODEL_MODES = new Set([ ]) export function resolveClaudeSessionModelMode(model?: string): SessionModelMode { - if (!model) { + const trimmedModel = model?.trim() + if (!trimmedModel) { return 'default' } - return CLAUDE_SESSION_MODEL_MODES.has(model as SessionModelMode) - ? model as SessionModelMode + 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 8730fc39..fb5a9941 100644 --- a/cli/src/claude/runClaude.ts +++ b/cli/src/claude/runClaude.ts @@ -17,7 +17,7 @@ import { createModeChangeHandler, createRunnerLifecycle, setControlledByUser } f import { isModelModeAllowedForFlavor, isPermissionModeAllowedForFlavor } from '@hapi/protocol'; import { ModelModeSchema, PermissionModeSchema } from '@hapi/protocol/schemas'; import { formatMessageWithAttachments } from '@/utils/attachmentFormatter'; -import { resolveClaudeSessionModelMode } from './modelMode'; +import { resolveClaudePersistedModel, resolveClaudeSessionModelMode } from './modelMode'; export interface StartOptions { model?: string @@ -50,7 +50,8 @@ export async function runClaude(options: StartOptions = {}): Promise { flavor: 'claude', startedBy, workingDirectory, - agentState: initialState + agentState: initialState, + model: resolveClaudePersistedModel(options.model) }); logger.debug(`Session created: ${sessionInfo.id}`); diff --git a/cli/src/codex/runCodex.ts b/cli/src/codex/runCodex.ts index 91657eca..c05b4bcd 100644 --- a/cli/src/codex/runCodex.ts +++ b/cli/src/codex/runCodex.ts @@ -33,7 +33,8 @@ export async function runCodex(opts: { flavor: 'codex', startedBy, workingDirectory, - agentState: state + agentState: state, + model: opts.model }); const startingMode: 'local' | 'remote' = startedBy === 'runner' ? 'remote' : 'local'; diff --git a/cli/src/cursor/runCursor.ts b/cli/src/cursor/runCursor.ts index 124c7b4f..7e3e5fa2 100644 --- a/cli/src/cursor/runCursor.ts +++ b/cli/src/cursor/runCursor.ts @@ -38,7 +38,8 @@ export async function runCursor(opts: { flavor: 'cursor', startedBy, workingDirectory, - agentState: state + agentState: state, + model: opts.model }); const startingMode: 'local' | 'remote' = startedBy === 'runner' ? 'remote' : 'local'; diff --git a/cli/src/gemini/runGemini.test.ts b/cli/src/gemini/runGemini.test.ts new file mode 100644 index 00000000..be216e33 --- /dev/null +++ b/cli/src/gemini/runGemini.test.ts @@ -0,0 +1,109 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const harness = vi.hoisted(() => ({ + bootstrapArgs: [] as Array>, + geminiLoopArgs: [] as Array>, + session: { + onUserMessage: vi.fn(), + rpcHandlerManager: { + registerHandler: vi.fn() + } + } +})); + +vi.mock('@/agent/sessionFactory', () => ({ + bootstrapSession: vi.fn(async (options: Record) => { + harness.bootstrapArgs.push(options); + return { + api: {}, + session: harness.session + }; + }) +})); + +vi.mock('./loop', () => ({ + geminiLoop: vi.fn(async (options: Record) => { + harness.geminiLoopArgs.push(options); + }) +})); + +vi.mock('@/claude/registerKillSessionHandler', () => ({ + registerKillSessionHandler: vi.fn() +})); + +vi.mock('@/agent/runnerLifecycle', () => ({ + createModeChangeHandler: vi.fn(() => vi.fn()), + createRunnerLifecycle: vi.fn(() => ({ + registerProcessHandlers: vi.fn(), + cleanupAndExit: vi.fn(async () => {}), + markCrash: vi.fn(), + setExitCode: vi.fn(), + setArchiveReason: vi.fn() + })), + setControlledByUser: vi.fn() +})); + +vi.mock('@/claude/utils/startHookServer', () => ({ + startHookServer: vi.fn(async () => ({ + port: 1234, + token: 'token', + stop: vi.fn() + })) +})); + +vi.mock('@/modules/common/hooks/generateHookSettings', () => ({ + cleanupHookSettingsFile: vi.fn(), + generateHookSettingsFile: vi.fn(() => '/tmp/gemini-hooks.json') +})); + +const resolveGeminiRuntimeConfigMock = vi.hoisted(() => vi.fn()); + +vi.mock('./utils/config', () => ({ + resolveGeminiRuntimeConfig: resolveGeminiRuntimeConfigMock +})); + +vi.mock('@/ui/logger', () => ({ + logger: { + debug: vi.fn() + } +})); + +vi.mock('@/utils/attachmentFormatter', () => ({ + formatMessageWithAttachments: vi.fn((text: string) => text) +})); + +import { runGemini } from './runGemini'; + +describe('runGemini', () => { + beforeEach(() => { + harness.bootstrapArgs.length = 0; + harness.geminiLoopArgs.length = 0; + harness.session.onUserMessage.mockReset(); + harness.session.rpcHandlerManager.registerHandler.mockReset(); + resolveGeminiRuntimeConfigMock.mockReset(); + }); + + it('persists a resolved config model before bootstrapping the session', async () => { + resolveGeminiRuntimeConfigMock.mockReturnValue({ + model: 'gemini-3-pro-preview', + modelSource: 'local' + }); + + await runGemini({}); + + expect(harness.bootstrapArgs[0]?.model).toBe('gemini-3-pro-preview'); + expect(harness.geminiLoopArgs[0]?.model).toBe('gemini-3-pro-preview'); + }); + + it('does not persist the hardcoded default fallback model', async () => { + resolveGeminiRuntimeConfigMock.mockReturnValue({ + model: 'gemini-2.5-pro', + modelSource: 'default' + }); + + await runGemini({}); + + expect(harness.bootstrapArgs[0]?.model).toBeUndefined(); + expect(harness.geminiLoopArgs[0]?.model).toBe('gemini-2.5-pro'); + }); +}); diff --git a/cli/src/gemini/runGemini.ts b/cli/src/gemini/runGemini.ts index 5cef176b..3c312c38 100644 --- a/cli/src/gemini/runGemini.ts +++ b/cli/src/gemini/runGemini.ts @@ -35,11 +35,17 @@ export async function runGemini(opts: { controlledByUser: false }; + const runtimeConfig = resolveGeminiRuntimeConfig({ model: opts.model }); + const persistedModel = runtimeConfig.modelSource === 'default' + ? undefined + : runtimeConfig.model; + const { api, session } = await bootstrapSession({ flavor: 'gemini', startedBy, workingDirectory, - agentState: initialState + agentState: initialState, + model: persistedModel }); const startingMode: 'local' | 'remote' = opts.startingMode @@ -54,7 +60,7 @@ export async function runGemini(opts: { const sessionWrapperRef: { current: GeminiSession | null } = { current: null }; let currentPermissionMode: PermissionMode = opts.permissionMode ?? 'default'; - const resolvedModel = resolveGeminiRuntimeConfig({ model: opts.model }).model; + const resolvedModel = runtimeConfig.model; const hookServer = await startHookServer({ onSessionHook: (sessionId, data) => { diff --git a/cli/src/gemini/utils/config.ts b/cli/src/gemini/utils/config.ts index ae936b3c..0c90d6ec 100644 --- a/cli/src/gemini/utils/config.ts +++ b/cli/src/gemini/utils/config.ts @@ -13,6 +13,8 @@ export type GeminiLocalConfig = { model?: string; }; +export type GeminiModelSource = 'explicit' | 'env' | 'local' | 'default'; + const GEMINI_DIR = join(homedir(), '.gemini'); const SETTINGS_PATH = join(GEMINI_DIR, 'settings.json'); const CONFIG_PATH = join(GEMINI_DIR, 'config.json'); @@ -85,20 +87,29 @@ export function readGeminiLocalConfig(): GeminiLocalConfig { export function resolveGeminiRuntimeConfig(opts: { model?: string; token?: string; -} = {}): { model: string; token?: string } { +} = {}): { model: string; token?: string; modelSource: GeminiModelSource } { const local = readGeminiLocalConfig(); - const model = opts.model - ?? process.env[GEMINI_MODEL_ENV] - ?? local.model - ?? DEFAULT_GEMINI_MODEL; + let modelSource: GeminiModelSource = 'default'; + let model = DEFAULT_GEMINI_MODEL; + + if (opts.model) { + model = opts.model; + modelSource = 'explicit'; + } else if (process.env[GEMINI_MODEL_ENV]) { + model = process.env[GEMINI_MODEL_ENV]!; + modelSource = 'env'; + } else if (local.model) { + model = local.model; + modelSource = 'local'; + } const token = opts.token ?? process.env[GEMINI_API_KEY_ENV] ?? process.env[GOOGLE_API_KEY_ENV] ?? local.token; - return { model, token }; + return { model, token, modelSource }; } export function buildGeminiEnv(opts: { diff --git a/hub/src/store/index.ts b/hub/src/store/index.ts index cfb59a8b..d3599a20 100644 --- a/hub/src/store/index.ts +++ b/hub/src/store/index.ts @@ -22,7 +22,7 @@ export { PushStore } from './pushStore' export { SessionStore } from './sessionStore' export { UserStore } from './userStore' -const SCHEMA_VERSION: number = 4 +const SCHEMA_VERSION: number = 5 const REQUIRED_TABLES = [ 'sessions', 'machines', @@ -116,6 +116,12 @@ export class Store { return } + if (currentVersion === 4 && SCHEMA_VERSION === 5) { + this.migrateFromV4ToV5() + this.setUserVersion(SCHEMA_VERSION) + return + } + if (currentVersion !== SCHEMA_VERSION) { throw this.buildSchemaMismatchError(currentVersion) } @@ -136,6 +142,7 @@ export class Store { metadata_version INTEGER DEFAULT 1, agent_state TEXT, agent_state_version INTEGER DEFAULT 1, + model TEXT, todos TEXT, todos_updated_at INTEGER, team_state TEXT, @@ -298,6 +305,13 @@ export class Store { } } + private migrateFromV4ToV5(): void { + const columns = this.getSessionColumnNames() + if (!columns.has('model')) { + this.db.exec('ALTER TABLE sessions ADD COLUMN model TEXT') + } + } + private getSessionColumnNames(): Set { const rows = this.db.prepare('PRAGMA table_info(sessions)').all() as Array<{ name: string }> return new Set(rows.map((row) => row.name)) diff --git a/hub/src/store/sessionStore.ts b/hub/src/store/sessionStore.ts index a4e1090a..5eb51ced 100644 --- a/hub/src/store/sessionStore.ts +++ b/hub/src/store/sessionStore.ts @@ -8,6 +8,7 @@ import { getSessionByNamespace, getSessions, getSessionsByNamespace, + setSessionModel, setSessionTeamState, setSessionTodos, updateSessionAgentState, @@ -21,8 +22,8 @@ export class SessionStore { this.db = db } - getOrCreateSession(tag: string, metadata: unknown, agentState: unknown, namespace: string): StoredSession { - return getOrCreateSession(this.db, tag, metadata, agentState, namespace) + getOrCreateSession(tag: string, metadata: unknown, agentState: unknown, namespace: string, model?: string): StoredSession { + return getOrCreateSession(this.db, tag, metadata, agentState, namespace, model) } updateSessionMetadata( @@ -52,6 +53,10 @@ export class SessionStore { return setSessionTeamState(this.db, id, teamState, updatedAt, namespace) } + setSessionModel(id: string, model: string | null, namespace: string, options?: { touchUpdatedAt?: boolean }): boolean { + return setSessionModel(this.db, id, model, namespace, options) + } + getSession(id: string): StoredSession | null { return getSession(this.db, id) } diff --git a/hub/src/store/sessions.ts b/hub/src/store/sessions.ts index 164b2adf..8f8f5ce3 100644 --- a/hub/src/store/sessions.ts +++ b/hub/src/store/sessions.ts @@ -16,6 +16,7 @@ type DbSessionRow = { metadata_version: number agent_state: string | null agent_state_version: number + model: string | null todos: string | null todos_updated_at: number | null team_state: string | null @@ -37,6 +38,7 @@ function toStoredSession(row: DbSessionRow): StoredSession { metadataVersion: row.metadata_version, agentState: safeJsonParse(row.agent_state), agentStateVersion: row.agent_state_version, + model: row.model, todos: safeJsonParse(row.todos), todosUpdatedAt: row.todos_updated_at, teamState: safeJsonParse(row.team_state), @@ -52,7 +54,8 @@ export function getOrCreateSession( tag: string, metadata: unknown, agentState: unknown, - namespace: string + namespace: string, + model?: string ): StoredSession { const existing = db.prepare( 'SELECT * FROM sessions WHERE tag = ? AND namespace = ? ORDER BY created_at DESC LIMIT 1' @@ -73,12 +76,14 @@ export function getOrCreateSession( id, tag, namespace, machine_id, created_at, updated_at, metadata, metadata_version, agent_state, agent_state_version, + model, todos, todos_updated_at, active, active_at, seq ) VALUES ( @id, @tag, @namespace, NULL, @created_at, @updated_at, @metadata, 1, @agent_state, 1, + @model, NULL, NULL, 0, NULL, 0 ) @@ -89,7 +94,8 @@ export function getOrCreateSession( created_at: now, updated_at: now, metadata: metadataJson, - agent_state: agentStateJson + agent_state: agentStateJson, + model: model ?? null }) const row = getSession(db, id) @@ -225,6 +231,39 @@ export function setSessionTeamState( } } +export function setSessionModel( + db: Database, + id: string, + model: string | null, + namespace: string, + options?: { touchUpdatedAt?: boolean } +): boolean { + const now = Date.now() + const touchUpdatedAt = options?.touchUpdatedAt === true + + try { + const result = db.prepare(` + UPDATE sessions + SET model = @model, + updated_at = CASE WHEN @touch_updated_at = 1 THEN @updated_at ELSE updated_at END, + seq = seq + 1 + WHERE id = @id + AND namespace = @namespace + AND model IS NOT @model + `).run({ + id, + namespace, + model, + updated_at: now, + touch_updated_at: touchUpdatedAt ? 1 : 0 + }) + + return result.changes === 1 + } catch { + return false + } +} + export function getSession(db: Database, id: string): StoredSession | null { const row = db.prepare('SELECT * FROM sessions WHERE id = ?').get(id) as DbSessionRow | undefined return row ? toStoredSession(row) : null diff --git a/hub/src/store/types.ts b/hub/src/store/types.ts index 56156492..3fa56f23 100644 --- a/hub/src/store/types.ts +++ b/hub/src/store/types.ts @@ -9,6 +9,7 @@ export type StoredSession = { metadataVersion: number agentState: unknown | null agentStateVersion: number + model: string | null todos: unknown | null todosUpdatedAt: number | null teamState: unknown | null diff --git a/hub/src/sync/sessionCache.ts b/hub/src/sync/sessionCache.ts index c9a0c549..e077a70d 100644 --- a/hub/src/sync/sessionCache.ts +++ b/hub/src/sync/sessionCache.ts @@ -55,8 +55,8 @@ export class SessionCache { return this.getSessions().filter((session) => session.active) } - getOrCreateSession(tag: string, metadata: unknown, agentState: unknown, namespace: string): Session { - const stored = this.store.sessions.getOrCreateSession(tag, metadata, agentState, namespace) + getOrCreateSession(tag: string, metadata: unknown, agentState: unknown, namespace: string, model?: string): Session { + const stored = this.store.sessions.getOrCreateSession(tag, metadata, agentState, namespace, model) return this.refreshSession(stored.id) ?? (() => { throw new Error('Failed to load session') })() } @@ -126,6 +126,7 @@ export class SessionCache { thinkingAt: existing?.thinkingAt ?? 0, todos, teamState, + model: stored.model ?? undefined, permissionMode: existing?.permissionMode, modelMode: existing?.modelMode } @@ -325,6 +326,15 @@ export class SessionCache { } } + if (newStored.model === null && oldStored.model !== null) { + const updated = this.store.sessions.setSessionModel(newSessionId, oldStored.model, namespace, { + touchUpdatedAt: false + }) + if (!updated) { + throw new Error('Failed to preserve session model during merge') + } + } + if (oldStored.todos !== null && oldStored.todosUpdatedAt !== null) { this.store.sessions.setSessionTodos( newSessionId, diff --git a/hub/src/sync/sessionModel.test.ts b/hub/src/sync/sessionModel.test.ts new file mode 100644 index 00000000..4e76179b --- /dev/null +++ b/hub/src/sync/sessionModel.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'bun:test' +import { toSessionSummary } from '@hapi/protocol' +import type { SyncEvent } from '@hapi/protocol/types' +import { Store } from '../store' +import { RpcRegistry } from '../socket/rpcRegistry' +import type { EventPublisher } from './eventPublisher' +import { SessionCache } from './sessionCache' +import { SyncEngine } from './syncEngine' + +function createPublisher(events: SyncEvent[]): EventPublisher { + return { + emit: (event: SyncEvent) => { + events.push(event) + } + } as unknown as EventPublisher +} + +describe('session model', () => { + it('includes explicit model in session summaries', () => { + const store = new Store(':memory:') + const events: SyncEvent[] = [] + const cache = new SessionCache(store, createPublisher(events)) + + const session = cache.getOrCreateSession( + 'session-model-summary', + { path: '/tmp/project', host: 'localhost', flavor: 'codex' }, + null, + 'default', + 'gpt-5.4' + ) + + expect(session.model).toBe('gpt-5.4') + expect(toSessionSummary(session).model).toBe('gpt-5.4') + }) + + it('preserves model from old session when merging into resumed session', async () => { + const store = new Store(':memory:') + const events: SyncEvent[] = [] + const cache = new SessionCache(store, createPublisher(events)) + + const oldSession = cache.getOrCreateSession( + 'session-model-old', + { path: '/tmp/project', host: 'localhost', flavor: 'codex' }, + null, + 'default', + 'gpt-5.4' + ) + const newSession = cache.getOrCreateSession( + 'session-model-new', + { path: '/tmp/project', host: 'localhost', flavor: 'codex' }, + null, + 'default' + ) + + await cache.mergeSessions(oldSession.id, newSession.id, 'default') + + const merged = cache.getSession(newSession.id) + expect(merged?.model).toBe('gpt-5.4') + }) + + it('passes the stored model when respawning a resumed session', async () => { + const store = new Store(':memory:') + const engine = new SyncEngine( + store, + {} as never, + new RpcRegistry(), + { broadcast() {} } as never + ) + + try { + const session = engine.getOrCreateSession( + 'session-model-resume', + { + path: '/tmp/project', + host: 'localhost', + machineId: 'machine-1', + flavor: 'codex', + codexSessionId: 'codex-thread-1' + }, + null, + 'default', + 'gpt-5.4' + ) + engine.getOrCreateMachine( + 'machine-1', + { host: 'localhost', platform: 'linux', happyCliVersion: '0.1.0' }, + null, + 'default' + ) + engine.handleMachineAlive({ machineId: 'machine-1', time: Date.now() }) + + let capturedModel: string | undefined + ;(engine as any).rpcGateway.spawnSession = async ( + _machineId: string, + _directory: string, + _agent: string, + model?: string + ) => { + capturedModel = model + return { type: 'success', sessionId: session.id } + } + ;(engine as any).waitForSessionActive = async () => true + + const result = await engine.resumeSession(session.id, 'default') + + expect(result).toEqual({ type: 'success', sessionId: session.id }) + expect(capturedModel).toBe('gpt-5.4') + } finally { + engine.stop() + } + }) +}) diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index da497556..2b5ff695 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -210,8 +210,8 @@ export class SyncEngine { this.machineCache.reloadAll() } - getOrCreateSession(tag: string, metadata: unknown, agentState: unknown, namespace: string): Session { - return this.sessionCache.getOrCreateSession(tag, metadata, agentState, namespace) + getOrCreateSession(tag: string, metadata: unknown, agentState: unknown, namespace: string, model?: string): Session { + return this.sessionCache.getOrCreateSession(tag, metadata, agentState, namespace, model) } getOrCreateMachine(id: string, metadata: unknown, runnerState: unknown, namespace: string): Machine { @@ -372,7 +372,7 @@ export class SyncEngine { targetMachine.id, metadata.path, flavor, - undefined, + session.model, undefined, undefined, undefined, diff --git a/hub/src/web/routes/cli.ts b/hub/src/web/routes/cli.ts index c8fe85fd..250f6007 100644 --- a/hub/src/web/routes/cli.ts +++ b/hub/src/web/routes/cli.ts @@ -11,7 +11,8 @@ const bearerSchema = z.string().regex(/^Bearer\s+(.+)$/i) const createOrLoadSessionSchema = z.object({ tag: z.string().min(1), metadata: z.unknown(), - agentState: z.unknown().nullable().optional() + agentState: z.unknown().nullable().optional(), + model: z.string().optional() }) const createOrLoadMachineSchema = z.object({ @@ -100,7 +101,13 @@ export function createCliRoutes(getSyncEngine: () => SyncEngine | null): Hono getSessionTitle(session), [session]) const worktreeBranch = session.metadata?.worktree?.branch - const modelModeLabel = getModelModeLabel(session.modelMode ?? 'default') + const modelLabel = getSessionModelLabel(session) const [menuOpen, setMenuOpen] = useState(false) const [menuAnchorPoint, setMenuAnchorPoint] = useState<{ x: number; y: number }>({ x: 0, y: 0 }) @@ -140,9 +140,11 @@ export function SessionHeader(props: { {session.metadata?.flavor?.trim() || 'unknown'} - - {t('session.item.modelMode')}: {modelModeLabel} - + {modelLabel ? ( + + {t(modelLabel.key)}: {modelLabel.value} + + ) : null} {worktreeBranch ? ( {t('session.item.worktree')}: {worktreeBranch} ) : null} diff --git a/web/src/components/SessionList.tsx b/web/src/components/SessionList.tsx index fc53062b..5131b98a 100644 --- a/web/src/components/SessionList.tsx +++ b/web/src/components/SessionList.tsx @@ -1,4 +1,3 @@ -import { getModelModeLabel } from '@hapi/protocol' import { useEffect, useMemo, useState } from 'react' import type { SessionSummary } from '@/types/api' import type { ApiClient } from '@/api/client' @@ -8,6 +7,7 @@ import { useSessionActions } from '@/hooks/mutations/useSessionActions' import { SessionActionMenu } from '@/components/SessionActionMenu' import { RenameSessionDialog } from '@/components/RenameSessionDialog' import { ConfirmDialog } from '@/components/ui/ConfirmDialog' +import { getSessionModelLabel } from '@/lib/sessionModelLabel' import { useTranslation } from '@/lib/use-translation' type SessionGroup = { @@ -199,7 +199,7 @@ function SessionItem(props: { }) const sessionName = getSessionTitle(s) - const modelModeLabel = getModelModeLabel(s.modelMode ?? 'default') + const modelLabel = getSessionModelLabel(s) const statusDotClass = s.active ? (s.thinking ? 'bg-[#007AFF]' : 'bg-[var(--app-badge-success-text)]') : 'bg-[var(--app-hint)]' @@ -261,7 +261,9 @@ function SessionItem(props: { {getAgentLabel(s)} - {t('session.item.modelMode')}: {modelModeLabel} + {modelLabel ? ( + {t(modelLabel.key)}: {modelLabel.value} + ) : null} {s.metadata?.worktree?.branch ? ( {t('session.item.worktree')}: {s.metadata.worktree.branch} ) : null} diff --git a/web/src/hooks/useSSE.ts b/web/src/hooks/useSSE.ts index 488ff7bc..22c6f571 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,6 +81,10 @@ function getSessionPatch(value: unknown): SessionPatch | null { patch.updatedAt = value.updatedAt hasKnownPatch = true } + if (typeof value.model === 'string') { + patch.model = value.model + hasKnownPatch = true + } if (typeof value.permissionMode === 'string') { patch.permissionMode = value.permissionMode as Session['permissionMode'] hasKnownPatch = true @@ -97,7 +101,7 @@ function hasUnknownSessionPatchKeys(value: unknown): boolean { if (!hasRecordShape(value)) { return false } - const knownKeys = new Set(['active', 'thinking', 'activeAt', 'updatedAt', 'permissionMode', 'modelMode']) + const knownKeys = new Set(['active', 'thinking', 'activeAt', 'updatedAt', 'model', 'permissionMode', 'modelMode']) return Object.keys(value).some((key) => !knownKeys.has(key)) } @@ -382,6 +386,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 } diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index fa218ed9..7337edba 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -46,7 +46,7 @@ export default { 'session.item.path': '路径', 'session.item.agent': '代理', 'session.item.model': '模型', - 'session.item.modelMode': '模型', + '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 new file mode 100644 index 00000000..b351a475 --- /dev/null +++ b/web/src/lib/sessionModelLabel.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { getSessionModelLabel } from './sessionModelLabel' + +describe('getSessionModelLabel', () => { + it('prefers the explicit session model', () => { + expect(getSessionModelLabel({ model: 'gpt-5.4', modelMode: 'default' })).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', + value: 'Opus' + }) + }) + + it('returns null when neither model nor mode is available', () => { + expect(getSessionModelLabel({})).toBeNull() + }) +}) diff --git a/web/src/lib/sessionModelLabel.ts b/web/src/lib/sessionModelLabel.ts new file mode 100644 index 00000000..466b6d6b --- /dev/null +++ b/web/src/lib/sessionModelLabel.ts @@ -0,0 +1,28 @@ +import { getModelModeLabel } from '@hapi/protocol' +import type { Session, SessionSummary } from '@/types/api' + +type SessionModelSource = Pick | Pick + +export type SessionModelLabel = { + key: 'session.item.model' | 'session.item.modelMode' + value: string +} + +export function getSessionModelLabel(session: SessionModelSource): SessionModelLabel | null { + const explicitModel = typeof session.model === 'string' ? session.model.trim() : '' + if (explicitModel) { + return { + key: 'session.item.model', + value: explicitModel + } + } + + if (session.modelMode) { + return { + key: 'session.item.modelMode', + value: getModelModeLabel(session.modelMode) + } + } + + return null +}