mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-13 20:09:55 +00:00
unify model selection
This commit is contained in:
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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': '思考中',
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user