unify model selection

This commit is contained in:
weishu
2026-03-16 18:29:09 +08:00
parent 1f02bb7de1
commit 02c8e12e80
27 changed files with 454 additions and 41 deletions
+3 -1
View File
@@ -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)
+4 -1
View File
@@ -18,13 +18,15 @@ export class ApiClient {
tag: string
metadata: Metadata
state: AgentState | null
model?: string
}): Promise<Session> {
const response = await axios.post<CreateSessionResponse>(
`${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
}
+1
View File
@@ -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()
})
+17 -1
View File
@@ -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')
})
})
+15 -3
View File
@@ -8,11 +8,23 @@ const CLAUDE_SESSION_MODEL_MODES = new Set<SessionModelMode>([
])
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
}
+3 -2
View File
@@ -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<void> {
flavor: 'claude',
startedBy,
workingDirectory,
agentState: initialState
agentState: initialState,
model: resolveClaudePersistedModel(options.model)
});
logger.debug(`Session created: ${sessionInfo.id}`);
+2 -1
View File
@@ -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';
+2 -1
View File
@@ -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';
+109
View File
@@ -0,0 +1,109 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const harness = vi.hoisted(() => ({
bootstrapArgs: [] as Array<Record<string, unknown>>,
geminiLoopArgs: [] as Array<Record<string, unknown>>,
session: {
onUserMessage: vi.fn(),
rpcHandlerManager: {
registerHandler: vi.fn()
}
}
}));
vi.mock('@/agent/sessionFactory', () => ({
bootstrapSession: vi.fn(async (options: Record<string, unknown>) => {
harness.bootstrapArgs.push(options);
return {
api: {},
session: harness.session
};
})
}));
vi.mock('./loop', () => ({
geminiLoop: vi.fn(async (options: Record<string, unknown>) => {
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');
});
});
+8 -2
View File
@@ -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) => {
+17 -6
View File
@@ -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: {
+15 -1
View File
@@ -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<string> {
const rows = this.db.prepare('PRAGMA table_info(sessions)').all() as Array<{ name: string }>
return new Set(rows.map((row) => row.name))
+7 -2
View File
@@ -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)
}
+41 -2
View File
@@ -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
+1
View File
@@ -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
+12 -2
View File
@@ -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,
+112
View File
@@ -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()
}
})
})
+3 -3
View File
@@ -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,
+9 -2
View File
@@ -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<Cl
}
const namespace = c.get('namespace')
const session = engine.getOrCreateSession(parsed.data.tag, parsed.data.metadata, parsed.data.agentState ?? null, namespace)
const session = engine.getOrCreateSession(
parsed.data.tag,
parsed.data.metadata,
parsed.data.agentState ?? null,
namespace,
parsed.data.model
)
return c.json({ session })
})
+1
View File
@@ -174,6 +174,7 @@ export const SessionSchema = z.object({
thinkingAt: z.number(),
todos: TodosSchema.optional(),
teamState: TeamStateSchema.optional(),
model: z.string().optional(),
permissionMode: PermissionModeSchema.optional(),
modelMode: ModelModeSchema.optional()
})
+2
View File
@@ -19,6 +19,7 @@ export type SessionSummary = {
metadata: SessionSummaryMetadata | null
todoProgress: { completed: number; total: number } | null
pendingRequestsCount: number
model?: string
modelMode?: ModelMode
}
@@ -48,6 +49,7 @@ export function toSessionSummary(session: Session): SessionSummary {
metadata,
todoProgress,
pendingRequestsCount,
model: session.model,
modelMode: session.modelMode
}
}
+7 -5
View File
@@ -1,4 +1,3 @@
import { getModelModeLabel } from '@hapi/protocol'
import { useId, useMemo, useRef, useState } from 'react'
import type { Session } from '@/types/api'
import type { ApiClient } from '@/api/client'
@@ -7,6 +6,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'
function getSessionTitle(session: Session): string {
@@ -71,7 +71,7 @@ export function SessionHeader(props: {
const { session, api, onSessionDeleted } = props
const title = useMemo(() => 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: {
<span aria-hidden="true">❖</span>
{session.metadata?.flavor?.trim() || 'unknown'}
</span>
<span>
{t('session.item.modelMode')}: {modelModeLabel}
</span>
{modelLabel ? (
<span>
{t(modelLabel.key)}: {modelLabel.value}
</span>
) : null}
{worktreeBranch ? (
<span>{t('session.item.worktree')}: {worktreeBranch}</span>
) : null}
+5 -3
View File
@@ -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: {
</span>
{getAgentLabel(s)}
</span>
<span>{t('session.item.modelMode')}: {modelModeLabel}</span>
{modelLabel ? (
<span>{t(modelLabel.key)}: {modelLabel.value}</span>
) : null}
{s.metadata?.worktree?.branch ? (
<span>{t('session.item.worktree')}: {s.metadata.worktree.branch}</span>
) : null}
+7 -2
View File
@@ -30,7 +30,7 @@ const RECONNECT_MAX_DELAY_MS = 30_000
const RECONNECT_JITTER_MS = 500
const INVALIDATION_BATCH_MS = 16
type SessionPatch = Partial<Pick<Session, 'active' | 'thinking' | 'activeAt' | 'updatedAt' | 'permissionMode' | 'modelMode'>>
type SessionPatch = Partial<Pick<Session, 'active' | 'thinking' | 'activeAt' | 'updatedAt' | 'model' | 'permissionMode' | 'modelMode'>>
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
}
+1 -1
View File
@@ -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': '思考中',
+22
View File
@@ -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()
})
})
+28
View File
@@ -0,0 +1,28 @@
import { getModelModeLabel } from '@hapi/protocol'
import type { Session, SessionSummary } from '@/types/api'
type SessionModelSource = Pick<Session, 'model' | 'modelMode'> | Pick<SessionSummary, 'model' | 'modelMode'>
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
}