remove , using instead

This commit is contained in:
weishu
2026-03-16 18:29:09 +08:00
parent 02c8e12e80
commit 329d28a93c
40 changed files with 384 additions and 285 deletions
+11 -11
View File
@@ -1,6 +1,6 @@
import { ApiClient, ApiSessionClient } from '@/lib'; import { ApiClient, ApiSessionClient } from '@/lib';
import { MessageQueue2 } from '@/utils/MessageQueue2'; 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'; import { logger } from '@/ui/logger';
export type AgentSessionBaseOptions<Mode> = { export type AgentSessionBaseOptions<Mode> = {
@@ -16,7 +16,7 @@ export type AgentSessionBaseOptions<Mode> = {
sessionIdLabel: string; sessionIdLabel: string;
applySessionIdToMetadata: (metadata: Metadata, sessionId: string) => Metadata; applySessionIdToMetadata: (metadata: Metadata, sessionId: string) => Metadata;
permissionMode?: SessionPermissionMode; permissionMode?: SessionPermissionMode;
modelMode?: SessionModelMode; model?: SessionModel;
}; };
export class AgentSessionBase<Mode> { export class AgentSessionBase<Mode> {
@@ -37,7 +37,7 @@ export class AgentSessionBase<Mode> {
private readonly sessionIdLabel: string; private readonly sessionIdLabel: string;
private keepAliveInterval: NodeJS.Timeout | null = null; private keepAliveInterval: NodeJS.Timeout | null = null;
protected permissionMode?: SessionPermissionMode; protected permissionMode?: SessionPermissionMode;
protected modelMode?: SessionModelMode; protected model?: SessionModel;
constructor(opts: AgentSessionBaseOptions<Mode>) { constructor(opts: AgentSessionBaseOptions<Mode>) {
this.path = opts.path; this.path = opts.path;
@@ -52,7 +52,7 @@ export class AgentSessionBase<Mode> {
this.sessionIdLabel = opts.sessionIdLabel; this.sessionIdLabel = opts.sessionIdLabel;
this.mode = opts.mode ?? 'local'; this.mode = opts.mode ?? 'local';
this.permissionMode = opts.permissionMode; this.permissionMode = opts.permissionMode;
this.modelMode = opts.modelMode; this.model = opts.model;
this.client.keepAlive(this.thinking, this.mode, this.getKeepAliveRuntime()); this.client.keepAlive(this.thinking, this.mode, this.getKeepAliveRuntime());
this.keepAliveInterval = setInterval(() => { this.keepAliveInterval = setInterval(() => {
@@ -70,8 +70,8 @@ export class AgentSessionBase<Mode> {
this.mode = mode; this.mode = mode;
this.client.keepAlive(this.thinking, mode, this.getKeepAliveRuntime()); this.client.keepAlive(this.thinking, mode, this.getKeepAliveRuntime());
const permissionLabel = this.permissionMode ?? 'unset'; const permissionLabel = this.permissionMode ?? 'unset';
const modelLabel = this.modelMode ?? 'unset'; const modelLabel = this.model === undefined ? 'unset' : (this.model ?? 'auto');
logger.debug(`[${this.sessionLabel}] Mode switched to ${mode} (permissionMode=${permissionLabel}, modelMode=${modelLabel})`); logger.debug(`[${this.sessionLabel}] Mode switched to ${mode} (permissionMode=${permissionLabel}, model=${modelLabel})`);
this._onModeChange(mode); this._onModeChange(mode);
}; };
@@ -103,13 +103,13 @@ export class AgentSessionBase<Mode> {
} }
}; };
protected getKeepAliveRuntime(): { permissionMode?: SessionPermissionMode; modelMode?: SessionModelMode } | undefined { protected getKeepAliveRuntime(): { permissionMode?: SessionPermissionMode; model?: SessionModel } | undefined {
if (this.permissionMode === undefined && this.modelMode === undefined) { if (this.permissionMode === undefined && this.model === undefined) {
return undefined; return undefined;
} }
return { return {
permissionMode: this.permissionMode, permissionMode: this.permissionMode,
modelMode: this.modelMode model: this.model
}; };
} }
@@ -117,7 +117,7 @@ export class AgentSessionBase<Mode> {
return this.permissionMode; return this.permissionMode;
} }
getModelMode(): SessionModelMode | undefined { getModel(): SessionModel | undefined {
return this.modelMode; return this.model;
} }
} }
+1 -2
View File
@@ -72,8 +72,7 @@ export class ApiClient {
thinkingAt: raw.thinkingAt, thinkingAt: raw.thinkingAt,
todos: raw.todos, todos: raw.todos,
model: raw.model, model: raw.model,
permissionMode: raw.permissionMode, permissionMode: raw.permissionMode
modelMode: raw.modelMode
} }
} }
+2 -2
View File
@@ -22,7 +22,7 @@ import type {
MessageMeta, MessageMeta,
Metadata, Metadata,
Session, Session,
SessionModelMode, SessionModel,
SessionPermissionMode, SessionPermissionMode,
UserMessage UserMessage
} from './types' } from './types'
@@ -438,7 +438,7 @@ export class ApiSessionClient extends EventEmitter {
keepAlive( keepAlive(
thinking: boolean, thinking: boolean,
mode: 'local' | 'remote', mode: 'local' | 'remote',
runtime?: { permissionMode?: SessionPermissionMode; modelMode?: SessionModelMode } runtime?: { permissionMode?: SessionPermissionMode; model?: SessionModel }
): void { ): void {
this.socket.volatile.emit('session-alive', { this.socket.volatile.emit('session-alive', {
sid: this.sessionId, sid: this.sessionId,
+4 -6
View File
@@ -2,11 +2,10 @@ import {
AgentStateSchema, AgentStateSchema,
AttachmentMetadataSchema, AttachmentMetadataSchema,
MetadataSchema, MetadataSchema,
ModelModeSchema,
PermissionModeSchema, PermissionModeSchema,
TodosSchema TodosSchema
} from '@hapi/protocol/schemas' } from '@hapi/protocol/schemas'
import type { ModelMode, PermissionMode } from '@hapi/protocol/types' import type { PermissionMode } from '@hapi/protocol/types'
import { z } from 'zod' import { z } from 'zod'
import { UsageSchema } from '@/claude/types' import { UsageSchema } from '@/claude/types'
@@ -21,7 +20,7 @@ export type {
Session Session
} from '@hapi/protocol/types' } from '@hapi/protocol/types'
export type SessionPermissionMode = PermissionMode export type SessionPermissionMode = PermissionMode
export type SessionModelMode = ModelMode export type SessionModel = string | null
export { AgentStateSchema, AttachmentMetadataSchema, MetadataSchema } export { AgentStateSchema, AttachmentMetadataSchema, MetadataSchema }
@@ -96,9 +95,8 @@ export const CreateSessionResponseSchema = z.object({
thinking: z.boolean(), thinking: z.boolean(),
thinkingAt: z.number(), thinkingAt: z.number(),
todos: TodosSchema.optional(), todos: TodosSchema.optional(),
model: z.string().optional(), model: z.string().nullable(),
permissionMode: PermissionModeSchema.optional(), permissionMode: PermissionModeSchema.optional()
modelMode: ModelModeSchema.optional()
}) })
}) })
+3 -5
View File
@@ -6,9 +6,8 @@ import { Session } from "./session"
import { claudeLocalLauncher } from "./claudeLocalLauncher" import { claudeLocalLauncher } from "./claudeLocalLauncher"
import { claudeRemoteLauncher } from "./claudeRemoteLauncher" import { claudeRemoteLauncher } from "./claudeRemoteLauncher"
import { ApiClient } from "@/lib" import { ApiClient } from "@/lib"
import type { SessionModelMode } from "@/api/types" import type { SessionModel } from "@/api/types"
import type { ClaudePermissionMode } from "@hapi/protocol/types" import type { ClaudePermissionMode } from "@hapi/protocol/types"
import { resolveClaudeSessionModelMode } from "./modelMode"
export type PermissionMode = ClaudePermissionMode; export type PermissionMode = ClaudePermissionMode;
@@ -24,7 +23,7 @@ export interface EnhancedMode {
interface LoopOptions { interface LoopOptions {
path: string path: string
model?: string model?: SessionModel
permissionMode?: PermissionMode permissionMode?: PermissionMode
startingMode?: 'local' | 'remote' startingMode?: 'local' | 'remote'
startedBy?: 'runner' | 'terminal' startedBy?: 'runner' | 'terminal'
@@ -46,7 +45,6 @@ export async function loop(opts: LoopOptions) {
const logPath = logger.logFilePath; const logPath = logger.logFilePath;
const startedBy = opts.startedBy ?? 'terminal'; const startedBy = opts.startedBy ?? 'terminal';
const startingMode = opts.startingMode ?? 'local'; const startingMode = opts.startingMode ?? 'local';
const modelMode: SessionModelMode = resolveClaudeSessionModelMode(opts.model)
const session = new Session({ const session = new Session({
api: opts.api, api: opts.api,
client: opts.session, client: opts.session,
@@ -64,7 +62,7 @@ export async function loop(opts: LoopOptions) {
startingMode, startingMode,
hookSettingsPath: opts.hookSettingsPath, hookSettingsPath: opts.hookSettingsPath,
permissionMode: opts.permissionMode ?? 'default', permissionMode: opts.permissionMode ?? 'default',
modelMode model: opts.model
}); });
await runLocalRemoteSession({ await runLocalRemoteSession({
+21
View File
@@ -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')
})
})
+10
View File
@@ -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
}
-39
View File
@@ -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')
})
})
-30
View File
@@ -1,30 +0,0 @@
import type { SessionModelMode } from '@/api/types'
const CLAUDE_SESSION_MODEL_MODES = new Set<SessionModelMode>([
'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
}
+29 -21
View File
@@ -1,6 +1,6 @@
import { logger } from '@/ui/logger'; import { logger } from '@/ui/logger';
import { loop } from '@/claude/loop'; import { loop } from '@/claude/loop';
import { AgentState, SessionModelMode } from '@/api/types'; import { AgentState, SessionModel } from '@/api/types';
import { EnhancedMode, PermissionMode } from './loop'; import { EnhancedMode, PermissionMode } from './loop';
import { MessageQueue2 } from '@/utils/MessageQueue2'; import { MessageQueue2 } from '@/utils/MessageQueue2';
import { hashObject } from '@/utils/deterministicJson'; import { hashObject } from '@/utils/deterministicJson';
@@ -14,10 +14,10 @@ import { registerKillSessionHandler } from './registerKillSessionHandler';
import type { Session } from './session'; import type { Session } from './session';
import { bootstrapSession } from '@/agent/sessionFactory'; import { bootstrapSession } from '@/agent/sessionFactory';
import { createModeChangeHandler, createRunnerLifecycle, setControlledByUser } from '@/agent/runnerLifecycle'; import { createModeChangeHandler, createRunnerLifecycle, setControlledByUser } from '@/agent/runnerLifecycle';
import { isModelModeAllowedForFlavor, isPermissionModeAllowedForFlavor } from '@hapi/protocol'; import { isPermissionModeAllowedForFlavor } from '@hapi/protocol';
import { ModelModeSchema, PermissionModeSchema } from '@hapi/protocol/schemas'; import { PermissionModeSchema } from '@hapi/protocol/schemas';
import { formatMessageWithAttachments } from '@/utils/attachmentFormatter'; import { formatMessageWithAttachments } from '@/utils/attachmentFormatter';
import { resolveClaudePersistedModel, resolveClaudeSessionModelMode } from './modelMode'; import { normalizeClaudeSessionModel } from './model';
export interface StartOptions { export interface StartOptions {
model?: string model?: string
@@ -46,12 +46,13 @@ export async function runClaude(options: StartOptions = {}): Promise<void> {
} }
const initialState: AgentState = {}; const initialState: AgentState = {};
const initialModel = normalizeClaudeSessionModel(options.model);
const { api, session, sessionInfo } = await bootstrapSession({ const { api, session, sessionInfo } = await bootstrapSession({
flavor: 'claude', flavor: 'claude',
startedBy, startedBy,
workingDirectory, workingDirectory,
agentState: initialState, agentState: initialState,
model: resolveClaudePersistedModel(options.model) model: initialModel ?? undefined
}); });
logger.debug(`Session created: ${sessionInfo.id}`); logger.debug(`Session created: ${sessionInfo.id}`);
@@ -145,7 +146,7 @@ export async function runClaude(options: StartOptions = {}): Promise<void> {
// Forward messages to the queue // Forward messages to the queue
let currentPermissionMode: PermissionMode = options.permissionMode ?? 'default'; 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 currentFallbackModel: string | undefined = undefined; // Track current fallback model
let currentCustomSystemPrompt: string | undefined = undefined; // Track current custom system prompt let currentCustomSystemPrompt: string | undefined = undefined; // Track current custom system prompt
let currentAppendSystemPrompt: string | undefined = undefined; // Track current append system prompt let currentAppendSystemPrompt: string | undefined = undefined; // Track current append system prompt
@@ -158,17 +159,21 @@ export async function runClaude(options: StartOptions = {}): Promise<void> {
return; return;
} }
sessionInstance.setPermissionMode(currentPermissionMode); sessionInstance.setPermissionMode(currentPermissionMode);
sessionInstance.setModelMode(currentModelMode); sessionInstance.setModel(currentModel);
logger.debug(`[loop] Synced session modes for keepalive: permissionMode=${currentPermissionMode}, modelMode=${currentModelMode}`); logger.debug(`[loop] Synced session config for keepalive: permissionMode=${currentPermissionMode}, model=${currentModel ?? 'auto'}`);
}; };
session.onUserMessage((message) => { session.onUserMessage((message) => {
const sessionPermissionMode = currentSessionRef.current?.getPermissionMode(); const sessionPermissionMode = currentSessionRef.current?.getPermissionMode();
if (sessionPermissionMode && isPermissionModeAllowedForFlavor(sessionPermissionMode, 'claude')) { if (sessionPermissionMode && isPermissionModeAllowedForFlavor(sessionPermissionMode, 'claude')) {
currentPermissionMode = sessionPermissionMode as PermissionMode; currentPermissionMode = sessionPermissionMode as PermissionMode;
} }
const sessionModel = currentSessionRef.current?.getModel();
if (sessionModel !== undefined) {
currentModel = sessionModel;
}
const messagePermissionMode = currentPermissionMode; const messagePermissionMode = currentPermissionMode;
const messageModel = currentModelMode === 'default' ? undefined : currentModelMode; const messageModel = currentModel ?? undefined;
logger.debug(`[loop] User message received with permission mode: ${currentPermissionMode}, model: ${currentModelMode}`); 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 // Resolve custom system prompt - use message.meta.customSystemPrompt if provided, otherwise use current
let messageCustomSystemPrompt = currentCustomSystemPrompt; let messageCustomSystemPrompt = currentCustomSystemPrompt;
@@ -284,31 +289,34 @@ export async function runClaude(options: StartOptions = {}): Promise<void> {
return parsed.data as PermissionMode; return parsed.data as PermissionMode;
}; };
const resolveModelMode = (value: unknown): SessionModelMode => { const resolveModel = (value: unknown): SessionModel => {
const parsed = ModelModeSchema.safeParse(value); if (value === null) {
if (!parsed.success || !isModelModeAllowedForFlavor(parsed.data, 'claude')) { return null;
throw new Error('Invalid model mode');
} }
return parsed.data;
if (typeof value !== 'string') {
throw new Error('Invalid model');
}
return normalizeClaudeSessionModel(value);
}; };
session.rpcHandlerManager.registerHandler('set-session-config', async (payload: unknown) => { session.rpcHandlerManager.registerHandler('set-session-config', async (payload: unknown) => {
if (!payload || typeof payload !== 'object') { if (!payload || typeof payload !== 'object') {
throw new Error('Invalid session config payload'); 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) { if (config.permissionMode !== undefined) {
currentPermissionMode = resolvePermissionMode(config.permissionMode); currentPermissionMode = resolvePermissionMode(config.permissionMode);
} }
if (config.modelMode !== undefined) { if (config.model !== undefined) {
const resolvedModelMode = resolveModelMode(config.modelMode); currentModel = resolveModel(config.model);
currentModelMode = resolvedModelMode;
} }
syncSessionModes(); syncSessionModes();
return { applied: { permissionMode: currentPermissionMode, modelMode: currentModelMode } }; return { applied: { permissionMode: currentPermissionMode, model: currentModel } };
}); });
let loopError: unknown = null; let loopError: unknown = null;
@@ -316,7 +324,7 @@ export async function runClaude(options: StartOptions = {}): Promise<void> {
try { try {
await loop({ await loop({
path: workingDirectory, path: workingDirectory,
model: options.model, model: currentModel,
permissionMode: options.permissionMode, permissionMode: options.permissionMode,
startingMode, startingMode,
messageQueue, messageQueue,
+6 -6
View File
@@ -2,7 +2,7 @@ import { ApiClient, ApiSessionClient } from '@/lib';
import { MessageQueue2 } from '@/utils/MessageQueue2'; import { MessageQueue2 } from '@/utils/MessageQueue2';
import { logger } from '@/ui/logger'; import { logger } from '@/ui/logger';
import { AgentSessionBase } from '@/agent/sessionBase'; import { AgentSessionBase } from '@/agent/sessionBase';
import type { SessionModelMode } from '@/api/types'; import type { SessionModel } from '@/api/types';
import type { EnhancedMode } from './loop'; import type { EnhancedMode } from './loop';
import type { PermissionMode } from './loop'; import type { PermissionMode } from './loop';
import type { LocalLaunchExitReason } from '@/agent/localLaunchPolicy'; import type { LocalLaunchExitReason } from '@/agent/localLaunchPolicy';
@@ -39,7 +39,7 @@ export class Session extends AgentSessionBase<EnhancedMode> {
startingMode: 'local' | 'remote'; startingMode: 'local' | 'remote';
hookSettingsPath: string; hookSettingsPath: string;
permissionMode?: PermissionMode; permissionMode?: PermissionMode;
modelMode?: SessionModelMode; model?: SessionModel;
}) { }) {
super({ super({
api: opts.api, api: opts.api,
@@ -57,7 +57,7 @@ export class Session extends AgentSessionBase<EnhancedMode> {
claudeSessionId: sessionId claudeSessionId: sessionId
}), }),
permissionMode: opts.permissionMode, permissionMode: opts.permissionMode,
modelMode: opts.modelMode model: opts.model
}); });
this.claudeEnvVars = opts.claudeEnvVars; this.claudeEnvVars = opts.claudeEnvVars;
@@ -68,15 +68,15 @@ export class Session extends AgentSessionBase<EnhancedMode> {
this.startedBy = opts.startedBy; this.startedBy = opts.startedBy;
this.startingMode = opts.startingMode; this.startingMode = opts.startingMode;
this.permissionMode = opts.permissionMode; this.permissionMode = opts.permissionMode;
this.modelMode = opts.modelMode; this.model = opts.model;
} }
setPermissionMode = (mode: PermissionMode): void => { setPermissionMode = (mode: PermissionMode): void => {
this.permissionMode = mode; this.permissionMode = mode;
}; };
setModelMode = (mode: SessionModelMode): void => { setModel = (model: SessionModel): void => {
this.modelMode = mode; this.model = model;
}; };
recordLocalLaunchFailure = (message: string, exitReason: LocalLaunchExitReason): void => { recordLocalLaunchFailure = (message: string, exitReason: LocalLaunchExitReason): void => {
@@ -57,6 +57,7 @@ function createSession(overrides: Partial<Session> = {}): Session {
agentStateVersion: 0, agentStateVersion: 0,
thinking: false, thinking: false,
thinkingAt: 0, thinkingAt: 0,
model: null,
...overrides ...overrides
} }
} }
+2 -2
View File
@@ -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 { Store, StoredMachine, StoredSession } from '../../../store'
import type { RpcRegistry } from '../../rpcRegistry' import type { RpcRegistry } from '../../rpcRegistry'
import type { SyncEvent } from '../../../sync/syncEngine' import type { SyncEvent } from '../../../sync/syncEngine'
@@ -16,7 +16,7 @@ type SessionAlivePayload = {
thinking?: boolean thinking?: boolean
mode?: 'local' | 'remote' mode?: 'local' | 'remote'
permissionMode?: PermissionMode permissionMode?: PermissionMode
modelMode?: ModelMode model?: string | null
} }
type SessionEndPayload = { type SessionEndPayload = {
@@ -1,7 +1,7 @@
import type { ClientToServerEvents } from '@hapi/protocol' import type { ClientToServerEvents } from '@hapi/protocol'
import { z } from 'zod' import { z } from 'zod'
import { randomUUID } from 'node:crypto' 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 { Store, StoredSession } from '../../../store'
import type { SyncEvent } from '../../../sync/syncEngine' import type { SyncEvent } from '../../../sync/syncEngine'
import { extractTodoWriteTodosFromMessageContent } from '../../../sync/todos' import { extractTodoWriteTodosFromMessageContent } from '../../../sync/todos'
@@ -15,7 +15,7 @@ type SessionAlivePayload = {
thinking?: boolean thinking?: boolean
mode?: 'local' | 'remote' mode?: 'local' | 'remote'
permissionMode?: PermissionMode permissionMode?: PermissionMode
modelMode?: ModelMode model?: string | null
} }
type SessionEndPayload = { type SessionEndPayload = {
+2 -2
View File
@@ -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 { Server } from 'socket.io'
import type { RpcRegistry } from '../socket/rpcRegistry' import type { RpcRegistry } from '../socket/rpcRegistry'
@@ -93,7 +93,7 @@ export class RpcGateway {
sessionId: string, sessionId: string,
config: { config: {
permissionMode?: PermissionMode permissionMode?: PermissionMode
modelMode?: ModelMode model?: string | null
} }
): Promise<unknown> { ): Promise<unknown> {
return await this.sessionRpc(sessionId, 'set-session-config', config) return await this.sessionRpc(sessionId, 'set-session-config', config)
+25 -13
View File
@@ -1,5 +1,5 @@
import { AgentStateSchema, MetadataSchema, TeamStateSchema } from '@hapi/protocol/schemas' 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 type { Store } from '../store'
import { clampAliveTime } from './aliveTime' import { clampAliveTime } from './aliveTime'
import { EventPublisher } from './eventPublisher' import { EventPublisher } from './eventPublisher'
@@ -126,9 +126,8 @@ export class SessionCache {
thinkingAt: existing?.thinkingAt ?? 0, thinkingAt: existing?.thinkingAt ?? 0,
todos, todos,
teamState, teamState,
model: stored.model ?? undefined, model: stored.model,
permissionMode: existing?.permissionMode, permissionMode: existing?.permissionMode
modelMode: existing?.modelMode
} }
this.sessions.set(sessionId, session) this.sessions.set(sessionId, session)
@@ -149,7 +148,7 @@ export class SessionCache {
thinking?: boolean thinking?: boolean
mode?: 'local' | 'remote' mode?: 'local' | 'remote'
permissionMode?: PermissionMode permissionMode?: PermissionMode
modelMode?: ModelMode model?: string | null
}): void { }): void {
const t = clampAliveTime(payload.time) const t = clampAliveTime(payload.time)
if (!t) return if (!t) return
@@ -160,7 +159,7 @@ export class SessionCache {
const wasActive = session.active const wasActive = session.active
const wasThinking = session.thinking const wasThinking = session.thinking
const previousPermissionMode = session.permissionMode const previousPermissionMode = session.permissionMode
const previousModelMode = session.modelMode const previousModel = session.model
session.active = true session.active = true
session.activeAt = Math.max(session.activeAt, t) session.activeAt = Math.max(session.activeAt, t)
@@ -169,13 +168,18 @@ export class SessionCache {
if (payload.permissionMode !== undefined) { if (payload.permissionMode !== undefined) {
session.permissionMode = payload.permissionMode session.permissionMode = payload.permissionMode
} }
if (payload.modelMode !== undefined) { if (payload.model !== undefined) {
session.modelMode = payload.modelMode 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 now = Date.now()
const lastBroadcastAt = this.lastBroadcastAtBySessionId.get(session.id) ?? 0 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) const shouldBroadcast = (!wasActive && session.active)
|| (wasThinking !== session.thinking) || (wasThinking !== session.thinking)
|| modeChanged || modeChanged
@@ -191,7 +195,7 @@ export class SessionCache {
activeAt: session.activeAt, activeAt: session.activeAt,
thinking: session.thinking, thinking: session.thinking,
permissionMode: session.permissionMode, 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) const session = this.sessions.get(sessionId) ?? this.refreshSession(sessionId)
if (!session) { if (!session) {
return return
@@ -235,8 +239,16 @@ export class SessionCache {
if (config.permissionMode !== undefined) { if (config.permissionMode !== undefined) {
session.permissionMode = config.permissionMode session.permissionMode = config.permissionMode
} }
if (config.modelMode !== undefined) { if (config.model !== undefined) {
session.modelMode = config.modelMode 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 }) this.publisher.emit({ type: 'session-updated', sessionId, data: session })
+46
View File
@@ -58,6 +58,52 @@ describe('session model', () => {
expect(merged?.model).toBe('gpt-5.4') 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 () => { it('passes the stored model when respawning a resumed session', async () => {
const store = new Store(':memory:') const store = new Store(':memory:')
const engine = new SyncEngine( const engine = new SyncEngine(
+5 -5
View File
@@ -7,7 +7,7 @@
* - No E2E encryption; data is stored as JSON in SQLite * - 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 { Server } from 'socket.io'
import type { Store } from '../store' import type { Store } from '../store'
import type { RpcRegistry } from '../socket/rpcRegistry' import type { RpcRegistry } from '../socket/rpcRegistry'
@@ -187,7 +187,7 @@ export class SyncEngine {
thinking?: boolean thinking?: boolean
mode?: 'local' | 'remote' mode?: 'local' | 'remote'
permissionMode?: PermissionMode permissionMode?: PermissionMode
modelMode?: ModelMode model?: string | null
}): void { }): void {
this.sessionCache.handleSessionAlive(payload) this.sessionCache.handleSessionAlive(payload)
} }
@@ -281,14 +281,14 @@ export class SyncEngine {
sessionId: string, sessionId: string,
config: { config: {
permissionMode?: PermissionMode permissionMode?: PermissionMode
modelMode?: ModelMode model?: string | null
} }
): Promise<void> { ): Promise<void> {
const result = await this.rpcGateway.requestSessionConfig(sessionId, config) const result = await this.rpcGateway.requestSessionConfig(sessionId, config)
if (!result || typeof result !== 'object') { if (!result || typeof result !== 'object') {
throw new Error('Invalid response from session config RPC') 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 const applied = obj.applied
if (!applied || typeof applied !== 'object') { if (!applied || typeof applied !== 'object') {
throw new Error('Missing applied session config') throw new Error('Missing applied session config')
@@ -372,7 +372,7 @@ export class SyncEngine {
targetMachine.id, targetMachine.id,
metadata.path, metadata.path,
flavor, flavor,
session.model, session.model ?? undefined,
undefined, undefined,
undefined, undefined,
undefined, undefined,
+9 -9
View File
@@ -1,5 +1,5 @@
import { getPermissionModesForFlavor, isModelModeAllowedForFlavor, isPermissionModeAllowedForFlavor, toSessionSummary } from '@hapi/protocol' import { getPermissionModesForFlavor, isPermissionModeAllowedForFlavor, toSessionSummary } from '@hapi/protocol'
import { ModelModeSchema, PermissionModeSchema } from '@hapi/protocol/schemas' import { PermissionModeSchema } from '@hapi/protocol/schemas'
import { Hono } from 'hono' import { Hono } from 'hono'
import { z } from 'zod' import { z } from 'zod'
import type { SyncEngine, Session } from '../../sync/syncEngine' import type { SyncEngine, Session } from '../../sync/syncEngine'
@@ -10,8 +10,8 @@ const permissionModeSchema = z.object({
mode: PermissionModeSchema mode: PermissionModeSchema
}) })
const modelModeSchema = z.object({ const modelSchema = z.object({
model: ModelModeSchema model: z.string().trim().min(1).nullable()
}) })
const renameSessionSchema = z.object({ const renameSessionSchema = z.object({
@@ -268,21 +268,21 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho
} }
const body = await c.req.json().catch(() => null) const body = await c.req.json().catch(() => null)
const parsed = modelModeSchema.safeParse(body) const parsed = modelSchema.safeParse(body)
if (!parsed.success) { if (!parsed.success) {
return c.json({ error: 'Invalid body' }, 400) return c.json({ error: 'Invalid body' }, 400)
} }
const flavor = sessionResult.session.metadata?.flavor ?? 'claude' const flavor = sessionResult.session.metadata?.flavor ?? 'claude'
if (!isModelModeAllowedForFlavor(parsed.data.model, flavor)) { if (flavor !== 'claude') {
return c.json({ error: 'Model mode is only supported for Claude sessions' }, 400) return c.json({ error: 'Model selection is only supported for Claude sessions' }, 400)
} }
try { try {
await engine.applySessionConfig(sessionResult.sessionId, { modelMode: parsed.data.model }) await engine.applySessionConfig(sessionResult.sessionId, { model: parsed.data.model })
return c.json({ ok: true }) return c.json({ ok: true })
} catch (error) { } 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) return c.json({ error: message }, 409)
} }
}) })
+14 -17
View File
@@ -25,8 +25,8 @@ export const PERMISSION_MODES = [
] as const ] as const
export type PermissionMode = typeof PERMISSION_MODES[number] export type PermissionMode = typeof PERMISSION_MODES[number]
export const MODEL_MODES = ['default', 'sonnet', 'sonnet[1m]', 'opus', 'opus[1m]'] as const export const CLAUDE_MODEL_PRESETS = ['sonnet', 'sonnet[1m]', 'opus', 'opus[1m]'] as const
export type ModelMode = typeof MODEL_MODES[number] export type ClaudeModelPreset = typeof CLAUDE_MODEL_PRESETS[number]
export type AgentFlavor = 'claude' | 'codex' | 'gemini' | 'opencode' | 'cursor' export type AgentFlavor = 'claude' | 'codex' | 'gemini' | 'opencode' | 'cursor'
@@ -60,16 +60,24 @@ export type PermissionModeOption = {
tone: PermissionModeTone tone: PermissionModeTone
} }
export const MODEL_MODE_LABELS: Record<ModelMode, string> = { export const CLAUDE_MODEL_LABELS: Record<ClaudeModelPreset, string> = {
default: 'Default',
sonnet: 'Sonnet', sonnet: 'Sonnet',
'sonnet[1m]': 'Sonnet 1M', 'sonnet[1m]': 'Sonnet 1M',
opus: 'Opus', opus: 'Opus',
'opus[1m]': 'Opus 1M' 'opus[1m]': 'Opus 1M'
} }
export function getModelModeLabel(mode: ModelMode): string { export function isClaudeModelPreset(model: string | null | undefined): model is ClaudeModelPreset {
return MODEL_MODE_LABELS[mode] 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 { 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 { export function isPermissionModeAllowedForFlavor(mode: PermissionMode, flavor?: string | null): boolean {
return getPermissionModesForFlavor(flavor).includes(mode) 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)
}
+3 -5
View File
@@ -1,8 +1,7 @@
import { z } from 'zod' 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 PermissionModeSchema = z.enum(PERMISSION_MODES)
export const ModelModeSchema = z.enum(MODEL_MODES)
const MetadataSummarySchema = z.object({ const MetadataSummarySchema = z.object({
text: z.string(), text: z.string(),
@@ -174,9 +173,8 @@ export const SessionSchema = z.object({
thinkingAt: z.number(), thinkingAt: z.number(),
todos: TodosSchema.optional(), todos: TodosSchema.optional(),
teamState: TeamStateSchema.optional(), teamState: TeamStateSchema.optional(),
model: z.string().optional(), model: z.string().nullable(),
permissionMode: PermissionModeSchema.optional(), permissionMode: PermissionModeSchema.optional()
modelMode: ModelModeSchema.optional()
}) })
export type Session = z.infer<typeof SessionSchema> export type Session = z.infer<typeof SessionSchema>
+2 -5
View File
@@ -1,4 +1,3 @@
import type { ModelMode } from './modes'
import type { Session, WorktreeMetadata } from './schemas' import type { Session, WorktreeMetadata } from './schemas'
export type SessionSummaryMetadata = { export type SessionSummaryMetadata = {
@@ -19,8 +18,7 @@ export type SessionSummary = {
metadata: SessionSummaryMetadata | null metadata: SessionSummaryMetadata | null
todoProgress: { completed: number; total: number } | null todoProgress: { completed: number; total: number } | null
pendingRequestsCount: number pendingRequestsCount: number
model?: string model: string | null
modelMode?: ModelMode
} }
export function toSessionSummary(session: Session): SessionSummary { export function toSessionSummary(session: Session): SessionSummary {
@@ -49,7 +47,6 @@ export function toSessionSummary(session: Session): SessionSummary {
metadata, metadata,
todoProgress, todoProgress,
pendingRequestsCount, pendingRequestsCount,
model: session.model, model: session.model
modelMode: session.modelMode
} }
} }
+2 -2
View File
@@ -1,5 +1,5 @@
import { z } from 'zod' import { z } from 'zod'
import type { ModelMode, PermissionMode } from './modes' import type { PermissionMode } from './modes'
export type SocketErrorReason = 'namespace-missing' | 'access-denied' | 'not-found' export type SocketErrorReason = 'namespace-missing' | 'access-denied' | 'not-found'
@@ -139,7 +139,7 @@ export interface ClientToServerEvents {
thinking: boolean thinking: boolean
mode?: 'local' | 'remote' mode?: 'local' | 'remote'
permissionMode?: PermissionMode permissionMode?: PermissionMode
modelMode?: ModelMode model?: string | null
}) => void }) => void
'session-end': (data: { sid: string; time: number }) => void 'session-end': (data: { sid: string; time: number }) => void
'update-metadata': (data: { sid: string; expectedVersion: number; metadata: unknown }, cb: (answer: { 'update-metadata': (data: { sid: string; expectedVersion: number; metadata: unknown }, cb: (answer: {
+1 -1
View File
@@ -24,7 +24,7 @@ export type {
CursorPermissionMode, CursorPermissionMode,
GeminiPermissionMode, GeminiPermissionMode,
OpencodePermissionMode, OpencodePermissionMode,
ModelMode, ClaudeModelPreset,
PermissionMode, PermissionMode,
PermissionModeOption, PermissionModeOption,
PermissionModeTone PermissionModeTone
+1 -2
View File
@@ -9,7 +9,6 @@ import type {
MachinePathsExistsResponse, MachinePathsExistsResponse,
MachinesResponse, MachinesResponse,
MessagesResponse, MessagesResponse,
ModelMode,
PermissionMode, PermissionMode,
PushSubscriptionPayload, PushSubscriptionPayload,
PushUnsubscribePayload, PushUnsubscribePayload,
@@ -313,7 +312,7 @@ export class ApiClient {
}) })
} }
async setModelMode(sessionId: string, model: ModelMode): Promise<void> { async setModel(sessionId: string, model: string | null): Promise<void> {
await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/model`, { await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/model`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ model }) body: JSON.stringify({ model })
+8 -8
View File
@@ -2,15 +2,15 @@ import { describe, expect, it } from 'vitest'
import { getContextBudgetTokens } from './modelConfig' import { getContextBudgetTokens } from './modelConfig'
describe('getContextBudgetTokens', () => { describe('getContextBudgetTokens', () => {
it('uses the existing 200k budget for default Claude modes', () => { it('uses the large budget only for explicit 1m Claude presets', () => {
expect(getContextBudgetTokens(undefined)).toBe(190_000) expect(getContextBudgetTokens('sonnet[1m]', 'claude')).toBe(990_000)
expect(getContextBudgetTokens('default')).toBe(190_000)
expect(getContextBudgetTokens('sonnet')).toBe(190_000)
expect(getContextBudgetTokens('opus')).toBe(190_000)
}) })
it('uses the 1m budget for Claude 1m modes', () => { it('uses the default Claude budget for full Claude model names', () => {
expect(getContextBudgetTokens('sonnet[1m]')).toBe(990_000) expect(getContextBudgetTokens('claude-sonnet-4-6', 'claude')).toBe(190_000)
expect(getContextBudgetTokens('opus[1m]')).toBe(990_000) })
it('returns null for non-Claude sessions', () => {
expect(getContextBudgetTokens('gpt-5.4', 'codex')).toBeNull()
}) })
}) })
+23 -12
View File
@@ -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. * 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. * and use this only as a fallback.
*/ */
const CONTEXT_HEADROOM_TOKENS = 10_000 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<ModelMode, number> = { export function getContextBudgetTokens(model: string | null | undefined, flavor?: string | null): number | null {
// Claude Code modes used in this app. 1M variants get the larger budget. if (flavor !== 'claude') {
default: 200_000, return null
sonnet: 200_000, }
'sonnet[1m]': 1_000_000,
opus: 200_000, const trimmedModel = model?.trim()
'opus[1m]': 1_000_000 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 if (!windowTokens) return null
return Math.max(1, windowTokens - CONTEXT_HEADROOM_TOKENS) return Math.max(1, windowTokens - CONTEXT_HEADROOM_TOKENS)
} }
@@ -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 { ComposerPrimitive, useAssistantApi, useAssistantState } from '@assistant-ui/react'
import { import {
type ChangeEvent as ReactChangeEvent, type ChangeEvent as ReactChangeEvent,
@@ -12,7 +12,7 @@ import {
useRef, useRef,
useState useState
} from 'react' } 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 { Suggestion } from '@/hooks/useActiveSuggestions'
import type { ConversationStatus } from '@/realtime/types' import type { ConversationStatus } from '@/realtime/types'
import { useActiveWord } from '@/hooks/useActiveWord' import { useActiveWord } from '@/hooks/useActiveWord'
@@ -28,6 +28,7 @@ import { StatusBar } from '@/components/AssistantChat/StatusBar'
import { ComposerButtons } from '@/components/AssistantChat/ComposerButtons' import { ComposerButtons } from '@/components/AssistantChat/ComposerButtons'
import { AttachmentItem } from '@/components/AssistantChat/AttachmentItem' import { AttachmentItem } from '@/components/AssistantChat/AttachmentItem'
import { useTranslation } from '@/lib/use-translation' import { useTranslation } from '@/lib/use-translation'
import { getClaudeComposerModelOptions, getNextClaudeComposerModel } from './claudeModelOptions'
export interface TextInputState { export interface TextInputState {
text: string text: string
@@ -39,7 +40,7 @@ const defaultSuggestionHandler = async (): Promise<Suggestion[]> => []
export function HappyComposer(props: { export function HappyComposer(props: {
disabled?: boolean disabled?: boolean
permissionMode?: PermissionMode permissionMode?: PermissionMode
modelMode?: ModelMode model?: string | null
active?: boolean active?: boolean
allowSendWhenInactive?: boolean allowSendWhenInactive?: boolean
thinking?: boolean thinking?: boolean
@@ -48,7 +49,7 @@ export function HappyComposer(props: {
controlledByUser?: boolean controlledByUser?: boolean
agentFlavor?: string | null agentFlavor?: string | null
onPermissionModeChange?: (mode: PermissionMode) => void onPermissionModeChange?: (mode: PermissionMode) => void
onModelModeChange?: (mode: ModelMode) => void onModelChange?: (model: string | null) => void
onSwitchToRemote?: () => void onSwitchToRemote?: () => void
onTerminal?: () => void onTerminal?: () => void
autocompletePrefixes?: string[] autocompletePrefixes?: string[]
@@ -63,7 +64,7 @@ export function HappyComposer(props: {
const { const {
disabled = false, disabled = false,
permissionMode: rawPermissionMode, permissionMode: rawPermissionMode,
modelMode: rawModelMode, model: rawModel,
active = true, active = true,
allowSendWhenInactive = false, allowSendWhenInactive = false,
thinking = false, thinking = false,
@@ -72,7 +73,7 @@ export function HappyComposer(props: {
controlledByUser = false, controlledByUser = false,
agentFlavor, agentFlavor,
onPermissionModeChange, onPermissionModeChange,
onModelModeChange, onModelChange,
onSwitchToRemote, onSwitchToRemote,
onTerminal, onTerminal,
autocompletePrefixes = ['@', '/', '$'], autocompletePrefixes = ['@', '/', '$'],
@@ -85,7 +86,7 @@ export function HappyComposer(props: {
// Use ?? so missing values fall back to default (destructuring defaults only handle undefined) // Use ?? so missing values fall back to default (destructuring defaults only handle undefined)
const permissionMode = rawPermissionMode ?? 'default' const permissionMode = rawPermissionMode ?? 'default'
const modelMode = rawModelMode ?? 'default' const model = rawModel ?? null
const api = useAssistantApi() const api = useAssistantApi()
const composerText = useAssistantState(({ composer }) => composer.text) const composerText = useAssistantState(({ composer }) => composer.text)
@@ -245,6 +246,10 @@ export function HappyComposer(props: {
() => getPermissionModeOptionsForFlavor(agentFlavor), () => getPermissionModeOptionsForFlavor(agentFlavor),
[agentFlavor] [agentFlavor]
) )
const claudeModelOptions = useMemo(
() => getClaudeComposerModelOptions(model),
[model]
)
const permissionModes = useMemo( const permissionModes = useMemo(
() => permissionModeOptions.map((option) => option.mode), () => permissionModeOptions.map((option) => option.mode),
[permissionModeOptions] [permissionModeOptions]
@@ -324,18 +329,16 @@ export function HappyComposer(props: {
useEffect(() => { useEffect(() => {
const handleGlobalKeyDown = (e: globalThis.KeyboardEvent) => { 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() e.preventDefault()
const currentIndex = MODEL_MODES.indexOf(modelMode as typeof MODEL_MODES[number]) onModelChange(getNextClaudeComposerModel(model))
const nextIndex = (currentIndex + 1) % MODEL_MODES.length
onModelModeChange(MODEL_MODES[nextIndex])
haptic('light') haptic('light')
} }
} }
window.addEventListener('keydown', handleGlobalKeyDown) window.addEventListener('keydown', handleGlobalKeyDown)
return () => window.removeEventListener('keydown', handleGlobalKeyDown) return () => window.removeEventListener('keydown', handleGlobalKeyDown)
}, [modelMode, onModelModeChange, haptic, agentFlavor]) }, [model, onModelChange, haptic, agentFlavor])
const handleChange = useCallback((e: ReactChangeEvent<HTMLTextAreaElement>) => { const handleChange = useCallback((e: ReactChangeEvent<HTMLTextAreaElement>) => {
const selection = { const selection = {
@@ -390,15 +393,15 @@ export function HappyComposer(props: {
haptic('light') haptic('light')
}, [onPermissionModeChange, controlsDisabled, haptic]) }, [onPermissionModeChange, controlsDisabled, haptic])
const handleModelChange = useCallback((mode: ModelMode) => { const handleModelChange = useCallback((nextModel: string | null) => {
if (!onModelModeChange || controlsDisabled) return if (!onModelChange || controlsDisabled) return
onModelModeChange(mode) onModelChange(nextModel)
setShowSettings(false) setShowSettings(false)
haptic('light') haptic('light')
}, [onModelModeChange, controlsDisabled, haptic]) }, [onModelChange, controlsDisabled, haptic])
const showPermissionSettings = Boolean(onPermissionModeChange && permissionModeOptions.length > 0) 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 showSettingsButton = Boolean(showPermissionSettings || showModelSettings)
const showAbortButton = true const showAbortButton = true
const voiceEnabled = Boolean(onVoiceToggle) const voiceEnabled = Boolean(onVoiceToggle)
@@ -458,9 +461,9 @@ export function HappyComposer(props: {
<div className="px-3 pb-1 text-xs font-semibold text-[var(--app-hint)]"> <div className="px-3 pb-1 text-xs font-semibold text-[var(--app-hint)]">
{t('misc.model')} {t('misc.model')}
</div> </div>
{MODEL_MODES.map((mode) => ( {claudeModelOptions.map((option) => (
<button <button
key={mode} key={option.value ?? 'auto'}
type="button" type="button"
disabled={controlsDisabled} disabled={controlsDisabled}
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors ${ className={`flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors ${
@@ -468,22 +471,22 @@ export function HappyComposer(props: {
? 'cursor-not-allowed opacity-50' ? 'cursor-not-allowed opacity-50'
: 'cursor-pointer hover:bg-[var(--app-secondary-bg)]' : 'cursor-pointer hover:bg-[var(--app-secondary-bg)]'
}`} }`}
onClick={() => handleModelChange(mode)} onClick={() => handleModelChange(option.value)}
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
> >
<div <div
className={`flex h-4 w-4 items-center justify-center rounded-full border-2 ${ className={`flex h-4 w-4 items-center justify-center rounded-full border-2 ${
modelMode === mode model === option.value
? 'border-[var(--app-link)]' ? 'border-[var(--app-link)]'
: 'border-[var(--app-hint)]' : 'border-[var(--app-hint)]'
}`} }`}
> >
{modelMode === mode && ( {model === option.value && (
<div className="h-2 w-2 rounded-full bg-[var(--app-link)]" /> <div className="h-2 w-2 rounded-full bg-[var(--app-link)]" />
)} )}
</div> </div>
<span className={modelMode === mode ? 'text-[var(--app-link)]' : ''}> <span className={model === option.value ? 'text-[var(--app-link)]' : ''}>
{MODEL_MODE_LABELS[mode]} {option.label}
</span> </span>
</button> </button>
))} ))}
@@ -513,15 +516,17 @@ export function HappyComposer(props: {
showSettings, showSettings,
showPermissionSettings, showPermissionSettings,
showModelSettings, showModelSettings,
claudeModelOptions,
suggestions, suggestions,
selectedIndex, selectedIndex,
controlsDisabled, controlsDisabled,
permissionMode, permissionMode,
modelMode, model,
permissionModeOptions, permissionModeOptions,
handlePermissionChange, handlePermissionChange,
handleModelChange, handleModelChange,
handleSuggestionSelect handleSuggestionSelect,
t
]) ])
return ( return (
@@ -535,7 +540,7 @@ export function HappyComposer(props: {
thinking={thinking} thinking={thinking}
agentState={agentState} agentState={agentState}
contextSize={contextSize} contextSize={contextSize}
modelMode={modelMode} model={model}
permissionMode={permissionMode} permissionMode={permissionMode}
agentFlavor={agentFlavor} agentFlavor={agentFlavor}
voiceStatus={voiceStatus} voiceStatus={voiceStatus}
@@ -1,7 +1,7 @@
import { getPermissionModeLabel, getPermissionModeTone, isPermissionModeAllowedForFlavor } from '@hapi/protocol' import { getPermissionModeLabel, getPermissionModeTone, isPermissionModeAllowedForFlavor } from '@hapi/protocol'
import type { PermissionModeTone } from '@hapi/protocol' import type { PermissionModeTone } from '@hapi/protocol'
import { useMemo } from 'react' 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 type { ConversationStatus } from '@/realtime/types'
import { getContextBudgetTokens } from '@/chat/modelConfig' import { getContextBudgetTokens } from '@/chat/modelConfig'
import { useTranslation } from '@/lib/use-translation' import { useTranslation } from '@/lib/use-translation'
@@ -106,7 +106,7 @@ export function StatusBar(props: {
thinking: boolean thinking: boolean
agentState: AgentState | null | undefined agentState: AgentState | null | undefined
contextSize?: number contextSize?: number
modelMode?: ModelMode model?: string | null
permissionMode?: PermissionMode permissionMode?: PermissionMode
agentFlavor?: string | null agentFlavor?: string | null
voiceStatus?: ConversationStatus voiceStatus?: ConversationStatus
@@ -120,11 +120,11 @@ export function StatusBar(props: {
const contextWarning = useMemo( const contextWarning = useMemo(
() => { () => {
if (props.contextSize === undefined) return null if (props.contextSize === undefined) return null
const maxContextSize = getContextBudgetTokens(props.modelMode) const maxContextSize = getContextBudgetTokens(props.model, props.agentFlavor)
if (!maxContextSize) return null if (!maxContextSize) return null
return getContextWarning(props.contextSize, maxContextSize, t) return getContextWarning(props.contextSize, maxContextSize, t)
}, },
[props.contextSize, props.modelMode, t] [props.contextSize, props.model, props.agentFlavor, t]
) )
const permissionMode = props.permissionMode const permissionMode = props.permissionMode
@@ -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')
})
})
@@ -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
}
+5 -5
View File
@@ -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 { describe, expect, it } from 'vitest'
import { MODEL_OPTIONS } from './types' import { MODEL_OPTIONS } from './types'
@@ -13,9 +13,9 @@ describe('Claude model options', () => {
]) ])
}) })
it('exposes friendly labels for session model modes', () => { it('exposes friendly labels for Claude model presets', () => {
expect(MODEL_MODES).toEqual(['default', 'sonnet', 'sonnet[1m]', 'opus', 'opus[1m]']) expect(CLAUDE_MODEL_PRESETS).toEqual(['sonnet', 'sonnet[1m]', 'opus', 'opus[1m]'])
expect(getModelModeLabel('sonnet[1m]')).toBe('Sonnet 1M') expect(getClaudeModelLabel('sonnet[1m]')).toBe('Sonnet 1M')
expect(getModelModeLabel('opus[1m]')).toBe('Opus 1M') expect(getClaudeModelLabel('opus[1m]')).toBe('Opus 1M')
}) })
}) })
+8 -8
View File
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate } from '@tanstack/react-router' import { useNavigate } from '@tanstack/react-router'
import { AssistantRuntimeProvider } from '@assistant-ui/react' import { AssistantRuntimeProvider } from '@assistant-ui/react'
import type { ApiClient } from '@/api/client' 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 { ChatBlock, NormalizedMessage } from '@/chat/types'
import type { Suggestion } from '@/hooks/useActiveSuggestions' import type { Suggestion } from '@/hooks/useActiveSuggestions'
import { normalizeDecryptedMessage } from '@/chat/normalize' import { normalizeDecryptedMessage } from '@/chat/normalize'
@@ -46,7 +46,7 @@ export function SessionChat(props: {
const blocksByIdRef = useRef<Map<string, ChatBlock>>(new Map()) const blocksByIdRef = useRef<Map<string, ChatBlock>>(new Map())
const [forceScrollToken, setForceScrollToken] = useState(0) const [forceScrollToken, setForceScrollToken] = useState(0)
const agentFlavor = props.session.metadata?.flavor ?? null const agentFlavor = props.session.metadata?.flavor ?? null
const { abortSession, switchSession, setPermissionMode, setModelMode } = useSessionActions( const { abortSession, switchSession, setPermissionMode, setModel } = useSessionActions(
props.api, props.api,
props.session.id, props.session.id,
agentFlavor agentFlavor
@@ -206,16 +206,16 @@ export function SessionChat(props: {
}, [setPermissionMode, props.onRefresh, haptic]) }, [setPermissionMode, props.onRefresh, haptic])
// Model mode change handler // Model mode change handler
const handleModelModeChange = useCallback(async (mode: ModelMode) => { const handleModelChange = useCallback(async (model: string | null) => {
try { try {
await setModelMode(mode) await setModel(model)
haptic.notification('success') haptic.notification('success')
props.onRefresh() props.onRefresh()
} catch (e) { } catch (e) {
haptic.notification('error') 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 // Abort handler
const handleAbort = useCallback(async () => { const handleAbort = useCallback(async () => {
@@ -314,7 +314,7 @@ export function SessionChat(props: {
<HappyComposer <HappyComposer
disabled={props.isSending} disabled={props.isSending}
permissionMode={props.session.permissionMode} permissionMode={props.session.permissionMode}
modelMode={props.session.modelMode} model={props.session.model}
agentFlavor={agentFlavor} agentFlavor={agentFlavor}
active={props.session.active} active={props.session.active}
allowSendWhenInactive allowSendWhenInactive
@@ -323,7 +323,7 @@ export function SessionChat(props: {
contextSize={reduced.latestUsage?.contextSize} contextSize={reduced.latestUsage?.contextSize}
controlledByUser={props.session.agentState?.controlledByUser === true} controlledByUser={props.session.agentState?.controlledByUser === true}
onPermissionModeChange={handlePermissionModeChange} onPermissionModeChange={handlePermissionModeChange}
onModelModeChange={handleModelModeChange} onModelChange={handleModelChange}
onSwitchToRemote={handleSwitchToRemote} onSwitchToRemote={handleSwitchToRemote}
onTerminal={props.session.active ? handleViewTerminal : undefined} onTerminal={props.session.active ? handleViewTerminal : undefined}
autocompleteSuggestions={props.autocompleteSuggestions} autocompleteSuggestions={props.autocompleteSuggestions}
+5 -5
View File
@@ -1,7 +1,7 @@
import { useMutation, useQueryClient } from '@tanstack/react-query' import { useMutation, useQueryClient } from '@tanstack/react-query'
import { isPermissionModeAllowedForFlavor } from '@hapi/protocol' import { isPermissionModeAllowedForFlavor } from '@hapi/protocol'
import type { ApiClient } from '@/api/client' import type { ApiClient } from '@/api/client'
import type { ModelMode, PermissionMode } from '@/types/api' import type { PermissionMode } from '@/types/api'
import { queryKeys } from '@/lib/query-keys' import { queryKeys } from '@/lib/query-keys'
import { clearMessageWindow } from '@/lib/message-window-store' import { clearMessageWindow } from '@/lib/message-window-store'
import { isKnownFlavor } from '@/lib/agentFlavorUtils' import { isKnownFlavor } from '@/lib/agentFlavorUtils'
@@ -15,7 +15,7 @@ export function useSessionActions(
archiveSession: () => Promise<void> archiveSession: () => Promise<void>
switchSession: () => Promise<void> switchSession: () => Promise<void>
setPermissionMode: (mode: PermissionMode) => Promise<void> setPermissionMode: (mode: PermissionMode) => Promise<void>
setModelMode: (mode: ModelMode) => Promise<void> setModel: (model: string | null) => Promise<void>
renameSession: (name: string) => Promise<void> renameSession: (name: string) => Promise<void>
deleteSession: () => Promise<void> deleteSession: () => Promise<void>
isPending: boolean isPending: boolean
@@ -72,11 +72,11 @@ export function useSessionActions(
}) })
const modelMutation = useMutation({ const modelMutation = useMutation({
mutationFn: async (mode: ModelMode) => { mutationFn: async (model: string | null) => {
if (!api || !sessionId) { if (!api || !sessionId) {
throw new Error('Session unavailable') throw new Error('Session unavailable')
} }
await api.setModelMode(sessionId, mode) await api.setModel(sessionId, model)
}, },
onSuccess: () => void invalidateSession(), onSuccess: () => void invalidateSession(),
}) })
@@ -111,7 +111,7 @@ export function useSessionActions(
archiveSession: archiveMutation.mutateAsync, archiveSession: archiveMutation.mutateAsync,
switchSession: switchMutation.mutateAsync, switchSession: switchMutation.mutateAsync,
setPermissionMode: permissionMutation.mutateAsync, setPermissionMode: permissionMutation.mutateAsync,
setModelMode: modelMutation.mutateAsync, setModel: modelMutation.mutateAsync,
renameSession: renameMutation.mutateAsync, renameSession: renameMutation.mutateAsync,
deleteSession: deleteMutation.mutateAsync, deleteSession: deleteMutation.mutateAsync,
isPending: abortMutation.isPending isPending: abortMutation.isPending
+4 -9
View File
@@ -30,7 +30,7 @@ const RECONNECT_MAX_DELAY_MS = 30_000
const RECONNECT_JITTER_MS = 500 const RECONNECT_JITTER_MS = 500
const INVALIDATION_BATCH_MS = 16 const INVALIDATION_BATCH_MS = 16
type SessionPatch = Partial<Pick<Session, 'active' | 'thinking' | 'activeAt' | 'updatedAt' | 'model' | 'permissionMode' | 'modelMode'>> type SessionPatch = Partial<Pick<Session, 'active' | 'thinking' | 'activeAt' | 'updatedAt' | 'model' | 'permissionMode'>>
function sortSessionSummaries(left: SessionSummary, right: SessionSummary): number { function sortSessionSummaries(left: SessionSummary, right: SessionSummary): number {
if (left.active !== right.active) { if (left.active !== right.active) {
@@ -81,7 +81,7 @@ function getSessionPatch(value: unknown): SessionPatch | null {
patch.updatedAt = value.updatedAt patch.updatedAt = value.updatedAt
hasKnownPatch = true hasKnownPatch = true
} }
if (typeof value.model === 'string') { if (value.model === null || typeof value.model === 'string') {
patch.model = value.model patch.model = value.model
hasKnownPatch = true hasKnownPatch = true
} }
@@ -89,10 +89,6 @@ function getSessionPatch(value: unknown): SessionPatch | null {
patch.permissionMode = value.permissionMode as Session['permissionMode'] patch.permissionMode = value.permissionMode as Session['permissionMode']
hasKnownPatch = true hasKnownPatch = true
} }
if (typeof value.modelMode === 'string') {
patch.modelMode = value.modelMode as Session['modelMode']
hasKnownPatch = true
}
return hasKnownPatch ? patch : null return hasKnownPatch ? patch : null
} }
@@ -101,7 +97,7 @@ function hasUnknownSessionPatchKeys(value: unknown): boolean {
if (!hasRecordShape(value)) { if (!hasRecordShape(value)) {
return false 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)) return Object.keys(value).some((key) => !knownKeys.has(key))
} }
@@ -386,8 +382,7 @@ export function useSSE(options: {
thinking: patch.thinking ?? current.thinking, thinking: patch.thinking ?? current.thinking,
activeAt: patch.activeAt ?? current.activeAt, activeAt: patch.activeAt ?? current.activeAt,
updatedAt: patch.updatedAt ?? current.updatedAt, updatedAt: patch.updatedAt ?? current.updatedAt,
model: patch.model ?? current.model, model: Object.prototype.hasOwnProperty.call(patch, 'model') ? patch.model ?? null : current.model
modelMode: patch.modelMode ?? current.modelMode
} }
patched = true patched = true
-1
View File
@@ -46,7 +46,6 @@ export default {
'session.item.path': 'path', 'session.item.path': 'path',
'session.item.agent': 'agent', 'session.item.agent': 'agent',
'session.item.model': 'model', 'session.item.model': 'model',
'session.item.modelMode': 'mode',
'session.item.worktree': 'worktree', 'session.item.worktree': 'worktree',
'session.item.pending': 'pending', 'session.item.pending': 'pending',
'session.item.thinking': 'thinking', 'session.item.thinking': 'thinking',
-1
View File
@@ -46,7 +46,6 @@ export default {
'session.item.path': '路径', 'session.item.path': '路径',
'session.item.agent': '代理', 'session.item.agent': '代理',
'session.item.model': '模型', 'session.item.model': '模型',
'session.item.modelMode': '模式',
'session.item.worktree': '工作树', 'session.item.worktree': '工作树',
'session.item.pending': '待处理', 'session.item.pending': '待处理',
'session.item.thinking': '思考中', 'session.item.thinking': '思考中',
+5 -5
View File
@@ -3,20 +3,20 @@ import { getSessionModelLabel } from './sessionModelLabel'
describe('getSessionModelLabel', () => { describe('getSessionModelLabel', () => {
it('prefers the explicit session model', () => { 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', key: 'session.item.model',
value: 'gpt-5.4' value: 'gpt-5.4'
}) })
}) })
it('falls back to Claude model mode when no explicit model exists', () => { it('renders friendly labels for known Claude aliases', () => {
expect(getSessionModelLabel({ modelMode: 'opus' })).toEqual({ expect(getSessionModelLabel({ model: 'opus' })).toEqual({
key: 'session.item.modelMode', key: 'session.item.model',
value: 'Opus' value: 'Opus'
}) })
}) })
it('returns null when neither model nor mode is available', () => { it('returns null when no model is available', () => {
expect(getSessionModelLabel({})).toBeNull() expect(getSessionModelLabel({})).toBeNull()
}) })
}) })
+6 -12
View File
@@ -1,10 +1,11 @@
import { getModelModeLabel } from '@hapi/protocol' import { getClaudeModelLabel } from '@hapi/protocol'
import type { Session, SessionSummary } from '@/types/api'
type SessionModelSource = Pick<Session, 'model' | 'modelMode'> | Pick<SessionSummary, 'model' | 'modelMode'> type SessionModelSource = {
model?: string | null
}
export type SessionModelLabel = { export type SessionModelLabel = {
key: 'session.item.model' | 'session.item.modelMode' key: 'session.item.model'
value: string value: string
} }
@@ -13,14 +14,7 @@ export function getSessionModelLabel(session: SessionModelSource): SessionModelL
if (explicitModel) { if (explicitModel) {
return { return {
key: 'session.item.model', key: 'session.item.model',
value: explicitModel value: getClaudeModelLabel(explicitModel) ?? explicitModel
}
}
if (session.modelMode) {
return {
key: 'session.item.modelMode',
value: getModelModeLabel(session.modelMode)
} }
} }
-1
View File
@@ -9,7 +9,6 @@ import type {
export type { export type {
AgentState, AgentState,
AttachmentMetadata, AttachmentMetadata,
ModelMode,
PermissionMode, PermissionMode,
Session, Session,
SessionSummary, SessionSummary,