mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-06 06:41:56 +00:00
remove , using instead
This commit is contained in:
@@ -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
@@ -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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -24,7 +24,7 @@ export type {
|
|||||||
CursorPermissionMode,
|
CursorPermissionMode,
|
||||||
GeminiPermissionMode,
|
GeminiPermissionMode,
|
||||||
OpencodePermissionMode,
|
OpencodePermissionMode,
|
||||||
ModelMode,
|
ClaudeModelPreset,
|
||||||
PermissionMode,
|
PermissionMode,
|
||||||
PermissionModeOption,
|
PermissionModeOption,
|
||||||
PermissionModeTone
|
PermissionModeTone
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
|||||||
@@ -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()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+22
-11
@@ -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,
|
|
||||||
'opus[1m]': 1_000_000
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getContextBudgetTokens(modelMode: ModelMode | undefined): number | null {
|
const trimmedModel = model?.trim()
|
||||||
const mode: ModelMode = modelMode ?? 'default'
|
const windowTokens = (() => {
|
||||||
const windowTokens = MODEL_CONTEXT_WINDOWS[mode]
|
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
|
||||||
|
})()
|
||||||
|
|
||||||
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
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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': '思考中',
|
||||||
|
|||||||
@@ -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()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import type {
|
|||||||
export type {
|
export type {
|
||||||
AgentState,
|
AgentState,
|
||||||
AttachmentMetadata,
|
AttachmentMetadata,
|
||||||
ModelMode,
|
|
||||||
PermissionMode,
|
PermissionMode,
|
||||||
Session,
|
Session,
|
||||||
SessionSummary,
|
SessionSummary,
|
||||||
|
|||||||
Reference in New Issue
Block a user