推理档:外部改配置后重读并重新种「最近落盘值」,重选同一档仍然落盘
Project CI / AI game creator shell Rust crates (pull_request) Successful in 4m35s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 6m59s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 6m14s
Project CI / Frontend tests (pull_request) Successful in 2m45s
Project CI / Backend tests (pull_request) Successful in 7m45s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m30s
Project CI / Repository checks (pull_request) Successful in 7m16s
Project CI / Native shell tests (pull_request) Successful in 9m2s

- 问题(评审 D5 边界):`persistedEffortRef` 只在挂载回读与自身落盘时更新,而配置还有别的写入者——`RuntimeConfigDialog` 保存后会 `notifyLlmConfigChanged()`。外部把 `llm.reasoningEffort` 改成「最高」后,常驻挂载的滑块手上仍是旧口径:用户改选「高」时 `next !== persisted` 本来成立,但若外部改成了用户要选的**同一档**(或先外部改档再重选原档),去重会把这次点击吞掉,界面显示 A、磁盘是 B
- 改法:把「读一次配置」抽成 `readEffortRef`,挂载时读一次,另外在 `LLM_CONFIG_CHANGED_EVENT` 与窗口 `focus` 时**重读并重新种 `persistedEffortRef`**(只读不写盘;`silent` 时不重复报「推理档读取失败」);写盘在途或预览未提交时不回显,避免回读旧值把界面顶回去
- 用例:`chat-composer.suite.ts`「an external config change is re-read so re-selecting that grade still commits」——挂载读到「默认」→ 外部改档 + 派发 `LLM_CONFIG_CHANGED_EVENT` → 断言回显成「最高」→ 用户改选「高」→ **必须写一次**(改前该用例红:writes 里没有 'high',界面对着旧口径把这次点击去重掉)
- 滑块 pointercancel 的用例也在这一条里(handler 随上一条提交落地):预览后 `pointercancel` → 恰好写一次

Refs #600, PR #607
This commit is contained in:
2026-10-05 18:11:20 +08:00
parent 9c350eeda5
commit fe3c1389ae
2 changed files with 127 additions and 6 deletions
@@ -32,6 +32,7 @@ import {
DEFAULT_COMPOSER_REASONING_EFFORT,
normalizeComposerReasoningEffort,
} from '../../../../../features/project-workspace/composerReasoningEffort';
import { LLM_CONFIG_CHANGED_EVENT } from '../../../../../services/llmModelCatalog';
import type { DirectPendingTurn } from '../../conversation/directPendingTurns';
import {
resolveSpeechRecognitionCtor,
@@ -383,28 +384,52 @@ export function ComposerReasoningEffortSelect({
};
}, []);
useEffect(() => {
let cancelled = false;
/*
读一次配置:种「最近落盘值」并在空闲时回显。
—— 配置有**别的写入者**(运行配置对话框改 `llm.reasoningEffort` 后 `notifyLlmConfigChanged()`,
也可能在窗口失焦期间被改)。只在挂载时读一次会让「最近落盘值」过期:用户重选**同一档**会被
去重跳过,界面显示 A、磁盘却是 B。所以挂载、`LLM_CONFIG_CHANGED_EVENT`、窗口重新获得焦点
时都重读一次(**只读不写盘**),重新种 `persistedEffortRef`。
—— 写盘在途或拖动预览未提交时不回显(回读拿到的还是旧值,回显会让界面闪一下)。
*/
const writeInFlightRef = useRef(0);
const readEffortRef = useRef<(silent?: boolean) => void>(() => undefined);
readEffortRef.current = (silent = false) => {
const invoke = resolveTauriInvoke();
if (!invoke) {
return undefined;
return;
}
void invoke<GameCreatorAppConfigView>('read_game_creator_app_config')
.then((view) => {
if (cancelled || !mountedRef.current) return;
if (!mountedRef.current) return;
const read = normalizeComposerReasoningEffort(
view?.config?.llm?.reasoningEffort,
);
// 回读值就是"磁盘上的档位":把它登记成最近落盘值,之后提交同一档位不再写盘。
persistedEffortRef.current = read;
if (writeInFlightRef.current > 0 || previewEffortRef.current !== null) {
return;
}
setEffort(read);
})
.catch(() => {
if (cancelled || !mountedRef.current) return;
if (!mountedRef.current || silent) return;
setNotice('推理档读取失败');
});
};
useEffect(() => {
readEffortRef.current();
}, []);
useEffect(() => {
// 外部写入者改档 / 窗口重新获得焦点:重读一次让「最近落盘值」跟上,避免去重吞掉真实改档。
const reread = () => readEffortRef.current(true);
window.addEventListener(LLM_CONFIG_CHANGED_EVENT, reread);
window.addEventListener('focus', reread);
return () => {
cancelled = true;
window.removeEventListener(LLM_CONFIG_CHANGED_EVENT, reread);
window.removeEventListener('focus', reread);
};
}, []);
@@ -1,6 +1,7 @@
import { ConversationModelSelect } from '../../src/features/project-workspace/ConversationModelSelect';
import { userItemFromContent } from '../../src/features/project-workspace/resourceReferences';
import {
LLM_CONFIG_CHANGED_EVENT,
LlmModelCatalogConfigError,
resetLlmModelCatalogCacheForTest,
} from '../../src/services/llmModelCatalog';
@@ -1279,6 +1280,101 @@ export function registerChatComposerControlTests() {
});
});
it('strength slider: an external config change is re-read so re-selecting that grade still commits', async () => {
// 配置还有别的写入者(`RuntimeConfigDialog` 保存后 `notifyLlmConfigChanged()`)。组件必须重读
// 配置并重新种「最近落盘值」,否则外部把档位改成「最高」后用户再选「高」会被去重跳过:
// 界面显示高、磁盘仍是最高(旧实现在这条用例里 writes 不含 'high')。
let stored = 'default';
const writes: string[] = [];
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_game_creator_app_config') {
return gameCreatorConfigView(stored);
}
if (command === 'select_game_creator_reasoning_effort') {
writes.push(String(args?.effort ?? ''));
stored = String(args?.effort ?? '');
return gameCreatorConfigView(stored);
}
return null;
},
);
installTauriRuntime({
core: { invoke },
event: { listen: async () => () => undefined },
});
render(
React.createElement(ComposerReasoningEffortSelect, {
disabled: false,
presentation: 'section',
}),
);
const slider = (await screen.findByRole('slider', {
name: '思维强度',
})) as HTMLInputElement;
await waitFor(() =>
expect(slider.getAttribute('aria-valuetext')).toBe('默认'),
);
// 外部改档(另一处写完配置会派发这个事件):组件重读并回显成「最高」。
stored = 'max';
await act(async () => {
window.dispatchEvent(new Event(LLM_CONFIG_CHANGED_EVENT));
await Promise.resolve();
});
await waitFor(() =>
expect(slider.getAttribute('aria-valuetext')).toBe('最高'),
);
// 用户改选「高」:仍然要真落盘一次(档位枚举 0=默认 1=低 2=中 3=高 4=最高)。
fireEvent.input(slider, { target: { value: '3' } });
fireEvent.keyUp(slider);
await waitFor(() => expect(writes).toEqual(['high']));
expect(stored).toBe('high');
});
it('strength slider: pointercancel commits the pending preview once', async () => {
// 触屏取消(pointercancel)与「松手即提交」同一口径:预览值不能滞留到下一次提交或卸载 flush。
// jsdom 没有 PointerEvent,这里派发同名原生事件(React 的合成 handler 按事件名分发)。
const writes: string[] = [];
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'select_game_creator_reasoning_effort') {
writes.push(String(args?.effort ?? ''));
return gameCreatorConfigView(String(args?.effort ?? ''));
}
if (command === 'read_game_creator_app_config') {
return gameCreatorConfigView('default');
}
return null;
},
);
installTauriRuntime({
core: { invoke },
event: { listen: async () => () => undefined },
});
render(
React.createElement(ComposerReasoningEffortSelect, {
disabled: false,
presentation: 'section',
}),
);
const slider = (await screen.findByRole('slider', {
name: '思维强度',
})) as HTMLInputElement;
await waitFor(() =>
expect(slider.getAttribute('aria-valuetext')).toBe('默认'),
);
fireEvent.input(slider, { target: { value: '2' } });
expect(writes, '拖动过程中不落盘').toEqual([]);
await act(async () => {
slider.dispatchEvent(new Event('pointercancel', { bubbles: true }));
await Promise.resolve();
});
await waitFor(() => expect(writes).toEqual(['medium']));
});
it('shows model service unavailability as a toast and leaves no persistent line', async () => {
const { surface } = await openDirectCodexSurface({
load_game_creator_llm_models: () => {