Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions src/web-ui/src/flow_chat/components/ModelSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { getEffectiveReasoningMode, isReasoningVisiblyEnabled } from '@/infrastr
import { globalEventBus } from '@/infrastructure/event-bus';
import type { AIModelConfig, AgentModelDefaultsConfig, DefaultModelsConfig } from '@/infrastructure/config/types';
import { Switch, Tooltip } from '@/component-library';
import { notificationService } from '@/shared/notification-system';
import { FlowChatStore } from '../store/FlowChatStore';
import { getModelMaxTokens } from '../services/flow-chat-manager/SessionModule';
import { acpClientIdFromAgentType } from '../utils/acpSession';
Expand Down Expand Up @@ -570,6 +571,15 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
setLoading(true);
setDropdownOpen(false);

// The optimistic session write below must be undone when the backend
// rejects the switch; otherwise the selector keeps showing a model the
// session never adopted, and the next send pushes it to the backend.
const store = FlowChatStore.getInstance();
const previousSessionModelName = sessionId
? store.getState().sessions.get(sessionId)?.config.modelName
: undefined;
let sessionModelWrittenOptimistically = false;

try {
if (externalSelection) {
await externalSelection.onSelect(modelId);
Expand All @@ -586,18 +596,18 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
});
setAcpOptions(options);
syncAcpContextUsageToStore(sessionId, options);
FlowChatStore.getInstance().updateSessionModelName(sessionId, modelId);
store.updateSessionModelName(sessionId, modelId);
log.info('ACP session model updated', { sessionId, acpClientId, modelId });
return;
}

const updateTargetSessionModel = async () => {
if (!sessionId) return;

const store = FlowChatStore.getInstance();
// Update the frontend session model immediately so the UI reflects the
// switch without waiting for the backend IPC round-trip.
store.updateSessionModelName(sessionId, modelId);
sessionModelWrittenOptimistically = true;
const maxContextTokens = await getModelMaxTokens(modelId, currentMode);
store.updateSessionMaxContextTokens(sessionId, maxContextTokens);
const session = store.getState().sessions.get(sessionId);
Expand Down Expand Up @@ -628,6 +638,13 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
globalEventBus.emit('mode:config:updated');
} catch (error) {
log.error('Failed to switch model', error);
// Only a previously pinned selection can be restored: the store has no
// way to express "never pinned", and forcing 'auto' there would claim a
// binding the session does not have either.
if (sessionId && sessionModelWrittenOptimistically && previousSessionModelName) {
store.updateSessionModelName(sessionId, previousSessionModelName);
}
notificationService.error(t('modelSelector.switchFailed'));
} finally {
setLoading(false);
}
Expand All @@ -642,6 +659,7 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
isAcpSession,
loading,
sessionId,
t,
targetIsSubagent,
]);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1181,11 +1181,24 @@ function handleSessionTitleGenerated(event: any): void {
}

function handleSessionModelAutoMigrated(event: SessionModelAutoMigratedEvent): void {
const { sessionId, newModelId } = event;
const { sessionId, previousModelId, newModelId, reason } = event;
if (!sessionId || !newModelId) return;

const store = FlowChatStore.getInstance();
store.updateSessionModelName(sessionId, newModelId);
const applied = store.applySessionModelAutoMigration(
sessionId,
previousModelId ?? '',
newModelId,
);
if (!applied) {
log.debug('Ignoring stale session model migration', {
sessionId,
previousModelId,
newModelId,
reason,
currentModelId: store.getState().sessions.get(sessionId)?.config.modelName,
});
}
}

/**
Expand Down
67 changes: 67 additions & 0 deletions src/web-ui/src/flow_chat/store/FlowChatStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1154,6 +1154,73 @@ describe('FlowChatStore session model selection', () => {

expect(flowChatStore.getState().sessions.get(session.sessionId)?.config.modelName).toBe('auto');
});

it('applies an auto-migration notice that matches the stored model', () => {
const session = createSession({
config: { agentType: 'agentic', modelName: 'removed-model' },
});
flowChatStore.setState(() => ({
sessions: new Map([[session.sessionId, session]]),
activeSessionId: session.sessionId,
}));

const applied = flowChatStore.applySessionModelAutoMigration(
session.sessionId,
'removed-model',
'auto',
);

expect(applied).toBe(true);
expect(flowChatStore.getState().sessions.get(session.sessionId)?.config.modelName).toBe('auto');
});

it('applies an auto-migration notice when the session has no stored model yet', () => {
const session = createSession({ config: { agentType: 'agentic' } });
flowChatStore.setState(() => ({
sessions: new Map([[session.sessionId, session]]),
activeSessionId: session.sessionId,
}));

const applied = flowChatStore.applySessionModelAutoMigration(
session.sessionId,
'removed-model',
'auto',
);

expect(applied).toBe(true);
expect(flowChatStore.getState().sessions.get(session.sessionId)?.config.modelName).toBe('auto');
});

it('ignores a stale auto-migration notice that would revert a newer selection', () => {
// Restore-time migration races the explicit update that triggered the
// restore: the composer already stored the picked model when the notice
// for the old one lands.
const session = createSession({
config: { agentType: 'agentic', modelName: 'removed-model' },
});
flowChatStore.setState(() => ({
sessions: new Map([[session.sessionId, session]]),
activeSessionId: session.sessionId,
}));

flowChatStore.updateSessionModelName(session.sessionId, 'deepseek-v4-flash');
const applied = flowChatStore.applySessionModelAutoMigration(
session.sessionId,
'removed-model',
'auto',
);

expect(applied).toBe(false);
expect(flowChatStore.getState().sessions.get(session.sessionId)?.config.modelName).toBe(
'deepseek-v4-flash',
);
});

it('ignores an auto-migration notice for an unknown session', () => {
expect(
flowChatStore.applySessionModelAutoMigration('missing-session', 'removed-model', 'auto'),
).toBe(false);
});
});

describe('FlowChatStore historical session hydration state', () => {
Expand Down
32 changes: 32 additions & 0 deletions src/web-ui/src/flow_chat/store/FlowChatStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2188,6 +2188,38 @@ export class FlowChatStore {
});
}

/**
* Apply a backend `SessionModelAutoMigrated` notice as a compare-and-swap.
*
* The backend emits this while restoring a session whose persisted model is
* gone. That restore is frequently triggered by the very model update the
* user just made, so the notice can land *after* the composer already stored
* the newly picked model. Applying it blindly reverts the user's choice, and
* the reverted value is what the next send pushes back to the backend.
*
* Only migrate while the session still holds the model the backend migrated
* away from (or holds no selection yet). Mirrors the CLI guard in
* `src/apps/cli/src/modes/chat/selection.rs`.
*
* Returns whether the migration was applied.
*/
public applySessionModelAutoMigration(
sessionId: string,
previousModelId: string,
newModelId: string,
): boolean {
const session = this.state.sessions.get(sessionId);
if (!session) return false;

const currentModelName = session.config.modelName?.trim();
if (currentModelName && currentModelName !== previousModelId.trim()) {
return false;
}

this.updateSessionModelName(sessionId, newModelId);
return true;
}

/** Update the target-owned model choice before an observer job is submitted. */
public updateSessionDispatchModel(sessionId: string, modelName: string): void {
this.setState(prev => {
Expand Down
1 change: 1 addition & 0 deletions src/web-ui/src/locales/en-US/flow-chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -2357,6 +2357,7 @@
"fastModel": "Fast Model",
"fastMode": "Fast mode",
"fastModeDescription": "1.5x speed with higher credit usage",
"switchFailed": "Failed to switch model",
"modelNotConfigured": "Not Configured",
"contextUsage": {
"agentPrompt": "Last request prompt: {{usage}}",
Expand Down
1 change: 1 addition & 0 deletions src/web-ui/src/locales/zh-CN/flow-chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -2357,6 +2357,7 @@
"fastModel": "Fast 模型",
"fastMode": "Fast 模式",
"fastModeDescription": "1.5 倍速度,消耗更多额度",
"switchFailed": "切换模型失败",
"modelNotConfigured": "未配置",
"contextUsage": {
"agentPrompt": "上次请求输入上下文: {{usage}}",
Expand Down
1 change: 1 addition & 0 deletions src/web-ui/src/locales/zh-TW/flow-chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -2357,6 +2357,7 @@
"fastModel": "Fast 模型",
"fastMode": "Fast 模式",
"fastModeDescription": "1.5 倍速度,消耗更多額度",
"switchFailed": "切換模型失敗",
"modelNotConfigured": "未設定",
"contextUsage": {
"agentPrompt": "上次請求輸入上下文: {{usage}}",
Expand Down
Loading