From 6be5085e10cb62a63421e9e4e868a74eebf6521a Mon Sep 17 00:00:00 2001 From: itsklimov Date: Thu, 23 Jul 2026 23:42:04 -0700 Subject: [PATCH 1/2] feat(agent): honest thought-level display driven by backend current_value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The thinking-level pill and submenus now render ONLY what the backend reports as active. The single source of truth is the config option's current_value (which the Core seeds from the assistant default and keeps reconciled); the UI no longer invents a selection: - getCurrentThoughtLevelLabel/composeRuntimeSelectorLabel accept a localized default label and render 'model · Default' when the thought axis exists but no current is known — never options[0], which may not be what the backend actually runs. - GuidModelSelector drops its options[0] normalization, and useGuidAssistantSelection no longer pre-seeds the selection: an untouched picker sends NO thought_level override at create, so the backend resolves the real default (the old implicit override silently defeated assistant fixed defaults). - useAcpConfigOptions gains an anti-flicker merge: a whole-snapshot replace whose options carry no current information at all preserves known non-null currents (matched by category then id, only when still selectable); a frame with any non-null current stays authoritative, so the Core's reject re-push still clears a refused level. - Thought-level matching falls back to ids effort/thinking/ thinking_budget (category thought_level stays priority; 'thinking' keeps this compatible with upstream PR #3597). Reuses the existing common.default i18n key (present in all locales). --- .../components/agent/AcpModelSelector.tsx | 9 +- .../agent/runtimeSelectorOptions.tsx | 28 ++++-- .../hooks/agent/useAcpConfigOptions.ts | 60 ++++++++++++- .../platforms/aionrs/AionrsModelSelector.tsx | 5 +- .../guid/components/GuidModelSelector.tsx | 29 ++++--- .../guid/hooks/useGuidAssistantSelection.ts | 14 +-- tests/unit/acpConfigOptions.test.ts | 87 ++++++++++++++++++- .../hooks/guidModelSelector.dom.test.tsx | 30 +++++++ .../renderer/runtimeSelectorLabel.test.ts | 67 ++++++++++++++ 9 files changed, 296 insertions(+), 33 deletions(-) create mode 100644 tests/unit/renderer/runtimeSelectorLabel.test.ts diff --git a/packages/desktop/src/renderer/components/agent/AcpModelSelector.tsx b/packages/desktop/src/renderer/components/agent/AcpModelSelector.tsx index 52b048d355..18d98f5fc9 100644 --- a/packages/desktop/src/renderer/components/agent/AcpModelSelector.tsx +++ b/packages/desktop/src/renderer/components/agent/AcpModelSelector.tsx @@ -81,7 +81,12 @@ const AcpModelSelector: React.FC<{ defaultModelLabel, fallbackLabel: t('conversation.welcome.useCliModel'), }); - const combinedLabel = composeRuntimeSelectorLabel({ modelLabel: display_label, thoughtLevel }); + const defaultThoughtLevelLabel = t('common.default'); + const combinedLabel = composeRuntimeSelectorLabel({ + modelLabel: display_label, + thoughtLevel, + defaultThoughtLevelLabel, + }); const isRuntimeSetting = isConfigSetting(setStatus); const handleThoughtLevelSelect = useCallback( async (value: string) => { @@ -171,7 +176,7 @@ const AcpModelSelector: React.FC<{ title={ } > diff --git a/packages/desktop/src/renderer/components/agent/runtimeSelectorOptions.tsx b/packages/desktop/src/renderer/components/agent/runtimeSelectorOptions.tsx index 733e312c78..9e934ce3b9 100644 --- a/packages/desktop/src/renderer/components/agent/runtimeSelectorOptions.tsx +++ b/packages/desktop/src/renderer/components/agent/runtimeSelectorOptions.tsx @@ -24,23 +24,39 @@ export type RuntimeSelectorModelGroup = { key: string; title: string; models: Ru const matchesModelQuery = (model: RuntimeSelectorModel, keyword: string): boolean => (model.label || model.id).toLowerCase().includes(keyword); -export const getCurrentThoughtLevelLabel = (thoughtLevel: AcpDerivedOption | null | undefined): string => { +/** Structural subset both AcpDerivedOption and AgentRuntimeDerivedOption satisfy. */ +type ThoughtLevelLike = Pick & { currentValue?: string | null }; + +/** + * Resolve the display label for the ACTIVE thinking level. The backend's + * `current_value` is the single source of truth: a known value maps to its + * option label (or itself). When the axis exists but no current is known, + * return `defaultLabel` (the caller passes the localized "Default") — an + * honest neutral, never a guess like `options[0]`, which may not be what the + * backend actually runs. No axis at all → empty (no suffix). + */ +export const getCurrentThoughtLevelLabel = ( + thoughtLevel: ThoughtLevelLike | null | undefined, + defaultLabel = '' +): string => { if (!thoughtLevel) return ''; + if (!thoughtLevel.currentValue) return defaultLabel; return ( - thoughtLevel.options.find((item) => item.value === thoughtLevel.currentValue)?.label || - thoughtLevel.currentValue || - '' + thoughtLevel.options.find((item) => item.value === thoughtLevel.currentValue)?.label || thoughtLevel.currentValue ); }; export const composeRuntimeSelectorLabel = ({ modelLabel, thoughtLevel, + defaultThoughtLevelLabel, }: { modelLabel: string; - thoughtLevel?: AcpDerivedOption | null; + thoughtLevel?: ThoughtLevelLike | null; + /** Localized "Default" shown when the thought axis exists but no current is known. */ + defaultThoughtLevelLabel?: string; }): string => { - const thoughtLevelLabel = getCurrentThoughtLevelLabel(thoughtLevel); + const thoughtLevelLabel = getCurrentThoughtLevelLabel(thoughtLevel, defaultThoughtLevelLabel); if (!thoughtLevelLabel) return modelLabel; return `${modelLabel} · ${thoughtLevelLabel}`; }; diff --git a/packages/desktop/src/renderer/hooks/agent/useAcpConfigOptions.ts b/packages/desktop/src/renderer/hooks/agent/useAcpConfigOptions.ts index 2131fd9614..a3d5b53f02 100644 --- a/packages/desktop/src/renderer/hooks/agent/useAcpConfigOptions.ts +++ b/packages/desktop/src/renderer/hooks/agent/useAcpConfigOptions.ts @@ -76,6 +76,59 @@ export function deriveSelectOption( }; } +/** + * Fallback option ids for the thought-level axis, matched only when no option + * carries `category: 'thought_level'` (the category stays authoritative). + * Covers every id the known backends emit: our Core's `reasoning_effort`, the + * legacy ACP aliases `effort`/`thinking_budget`, and `thinking` (also the id + * upstream PR #3597 matches, so the two changes stay compatible). + */ +export const THOUGHT_LEVEL_FALLBACK_IDS = [ + 'thought_level', + 'reasoning_effort', + 'effort', + 'thinking', + 'thinking_budget', +]; + +/** + * Anti-flicker merge for whole-snapshot replaces (`acp_config_option` push / + * REST reload): keep a known non-null `current_value` when the incoming frame + * carries NO current information at all. + * + * A frame where EVERY option's `current_value` is null is "informationless" — + * the backend simply had nothing selected to report yet (e.g. an early catalog + * push before its currents landed, or an older Core that never stamped + * currents) — and letting it clobber a current the UI already observed makes + * the picker flash Model-only. For those frames the previous per-option + * current is preserved (matched by category, then id). + * + * A frame with AT LEAST ONE non-null current is an informed snapshot: its + * nulls are authoritative and pass through. This is what keeps the Core's + * reject re-push working — after a backend refuses an effort set, the + * corrected frame still carries the model current, so its effort null WIPES + * the stale highlight instead of being "protected". + */ +export function mergeSnapshotPreservingKnownCurrents( + previous: AcpConfigOptionDto[] | null | undefined, + next: AcpConfigOptionDto[] +): AcpConfigOptionDto[] { + if (!previous?.length) return next; + const informed = next.some((option) => option.current_value != null); + if (informed) return next; + return next.map((option) => { + if (option.current_value != null) return option; + const prior = previous.find((candidate) => + option.category ? candidate.category === option.category : candidate.id === option.id + ); + if (prior?.current_value == null) return option; + // Only revive a current the incoming option can still represent — a stale + // value outside the new choice list would be its own lie. + const stillSelectable = option.options?.some((choice) => choice.value === prior.current_value); + return stillSelectable ? { ...option, current_value: prior.current_value } : option; + }); +} + export function hasObservedValue( response: SetConfigOptionResponse, optionId: string, @@ -200,8 +253,9 @@ export function useAcpConfigOptions({ const replaceSnapshot = useCallback( (next: AcpConfigOptionDto[]) => { - optionsRef.current = next; - void mutate(next, false); + const merged = mergeSnapshotPreservingKnownCurrents(optionsRef.current, next); + optionsRef.current = merged; + void mutate(merged, false); }, [mutate] ); @@ -276,7 +330,7 @@ export function useAcpConfigOptions({ setStatus, mode: deriveSelectOption(configOptions, 'mode', ['mode']), model: deriveSelectOption(configOptions, 'model', ['model']), - thoughtLevel: deriveSelectOption(configOptions, 'thought_level', ['thought_level', 'reasoning_effort']), + thoughtLevel: deriveSelectOption(configOptions, 'thought_level', THOUGHT_LEVEL_FALLBACK_IDS), reload, setConfigOption, }; diff --git a/packages/desktop/src/renderer/pages/conversation/platforms/aionrs/AionrsModelSelector.tsx b/packages/desktop/src/renderer/pages/conversation/platforms/aionrs/AionrsModelSelector.tsx index b688ee6234..73ed75e22d 100644 --- a/packages/desktop/src/renderer/pages/conversation/platforms/aionrs/AionrsModelSelector.tsx +++ b/packages/desktop/src/renderer/pages/conversation/platforms/aionrs/AionrsModelSelector.tsx @@ -77,7 +77,8 @@ const AionrsModelSelector: React.FC<{ defaultModelLabel, fallbackLabel: t('conversation.welcome.selectModel'), }); - const combinedLabel = composeRuntimeSelectorLabel({ modelLabel: label, thoughtLevel }); + const defaultThoughtLevelLabel = t('common.default'); + const combinedLabel = composeRuntimeSelectorLabel({ modelLabel: label, thoughtLevel, defaultThoughtLevelLabel }); const handleThoughtLevelSelect = (value: string) => { if (!thoughtLevel || value === thoughtLevel.currentValue || !onSetThoughtLevel) return; void onSetThoughtLevel(thoughtLevel.id, value); @@ -135,7 +136,7 @@ const AionrsModelSelector: React.FC<{ title={ } > diff --git a/packages/desktop/src/renderer/pages/guid/components/GuidModelSelector.tsx b/packages/desktop/src/renderer/pages/guid/components/GuidModelSelector.tsx index db02327eed..729299beb6 100644 --- a/packages/desktop/src/renderer/pages/guid/components/GuidModelSelector.tsx +++ b/packages/desktop/src/renderer/pages/guid/components/GuidModelSelector.tsx @@ -99,17 +99,18 @@ const GuidModelSelector: React.FC = ({ fallbackLabel: defaultModelLabel, }); }, [acpSelectedLabel, currentAcpCachedModelInfo?.current_model_id, defaultModelLabel, selectedAcpModel]); - const selectedThoughtLevelValue = thoughtLevelOption?.currentValue || thoughtLevelOption?.options[0]?.value || ''; - const normalizedThoughtLevelOption = - thoughtLevelOption && thoughtLevelOption.options.length > 0 - ? { - ...thoughtLevelOption, - currentValue: selectedThoughtLevelValue || null, - } - : null; + // The thought-level current is HONEST: only a real known value (user pick or + // backend-reported current) highlights; an unknown current renders as the + // localized "Default" instead of pretending options[0] is active — the + // backend resolves the actual default (assistant fixed default / its own + // launch default), and options[0] may not be it. + const defaultThoughtLevelLabel = t('common.default'); + const visibleThoughtLevelOption = + thoughtLevelOption && thoughtLevelOption.options.length > 0 ? thoughtLevelOption : null; const combinedAcpButtonLabel = composeRuntimeSelectorLabel({ modelLabel: acpButtonLabel, - thoughtLevel: normalizedThoughtLevelOption, + thoughtLevel: visibleThoughtLevelOption, + defaultThoughtLevelLabel, }); if (isGeminiMode) { @@ -208,7 +209,7 @@ const GuidModelSelector: React.FC = ({ trigger='click' droplist={ - {normalizedThoughtLevelOption ? ( + {visibleThoughtLevelOption ? ( <> {/* Two-level layout: model row on top, thought-level row below; each expands into a left-side submenu. */} @@ -230,18 +231,18 @@ const GuidModelSelector: React.FC = ({ title={ } > - {normalizedThoughtLevelOption.options.map((item) => ( + {visibleThoughtLevelOption.options.map((item) => ( onThoughtLevelSelect?.(item.value)} > {item.label} diff --git a/packages/desktop/src/renderer/pages/guid/hooks/useGuidAssistantSelection.ts b/packages/desktop/src/renderer/pages/guid/hooks/useGuidAssistantSelection.ts index 1bdc214aad..e1e2b4e73f 100644 --- a/packages/desktop/src/renderer/pages/guid/hooks/useGuidAssistantSelection.ts +++ b/packages/desktop/src/renderer/pages/guid/hooks/useGuidAssistantSelection.ts @@ -287,10 +287,6 @@ export const useGuidAssistantSelection = ({ const thoughtLevelSelectionScopeRef = useRef(null); useEffect(() => { const optionValues = new Set(selectedAgentRuntimeThoughtLevelOption?.options.map((option) => option.value) ?? []); - const fallbackThoughtLevel = - selectedAgentRuntimeThoughtLevelOption?.currentValue || - selectedAgentRuntimeThoughtLevelOption?.options[0]?.value || - ''; const selectionScope = selectedAssistantId ?? ''; _setSelectedThoughtLevelValue((previousValue) => { @@ -305,7 +301,15 @@ export const useGuidAssistantSelection = ({ return previousValue; } - return fallbackThoughtLevel; + // No implicit selection: `''` means the user has not picked a level, so + // the create request sends NO thought_level override and the backend + // resolves the real default (assistant fixed default / auto preference / + // its own launch default). The old fallback pre-selected `currentValue` + // or even `options[0]` here, which the send path then SENT as an explicit + // conversation override — silently defeating the assistant's default. + // Display-wise the selector falls back to the catalog current or the + // localized "Default" (see GuidModelSelector). + return ''; }); }, [selectedAgentRuntimeThoughtLevelOption, selectedAssistantId]); diff --git a/tests/unit/acpConfigOptions.test.ts b/tests/unit/acpConfigOptions.test.ts index bcf460e275..aa8dc7eaac 100644 --- a/tests/unit/acpConfigOptions.test.ts +++ b/tests/unit/acpConfigOptions.test.ts @@ -1,5 +1,10 @@ import type { AcpConfigOptionDto, SetConfigOptionResponse } from '@/common/types/platform/acpTypes'; -import { deriveSelectOption, hasObservedValue } from '@/renderer/hooks/agent/useAcpConfigOptions'; +import { + deriveSelectOption, + hasObservedValue, + mergeSnapshotPreservingKnownCurrents, + THOUGHT_LEVEL_FALLBACK_IDS, +} from '@/renderer/hooks/agent/useAcpConfigOptions'; import { describe, expect, it } from 'vitest'; const options: AcpConfigOptionDto[] = [ @@ -75,3 +80,83 @@ describe('ACP config option derivation', () => { expect(hasObservedValue(response, 'model', 'gpt-5.5')).toBe(false); }); }); + +describe('thought-level option matching', () => { + const thoughtDto = (overrides: Partial): AcpConfigOptionDto => + ({ + id: 'reasoning_effort', + option_type: 'select', + current_value: 'high', + options: [ + { value: 'low', name: 'Low' }, + { value: 'high', name: 'High' }, + ], + ...overrides, + }) as AcpConfigOptionDto; + + it.each(['thinking', 'thinking_budget', 'effort'])('matches the %s fallback id without a category', (id) => { + const derived = deriveSelectOption( + [thoughtDto({ id, category: undefined })], + 'thought_level', + THOUGHT_LEVEL_FALLBACK_IDS + ); + expect(derived?.id).toBe(id); + expect(derived?.currentValue).toBe('high'); + }); + + it('prefers the thought_level category over a fallback-id match', () => { + const byCategory = thoughtDto({ id: 'custom', category: 'thought_level', current_value: 'low' }); + const byId = thoughtDto({ id: 'thinking', category: undefined }); + const derived = deriveSelectOption([byId, byCategory], 'thought_level', THOUGHT_LEVEL_FALLBACK_IDS); + expect(derived?.id).toBe('custom'); + expect(derived?.currentValue).toBe('low'); + }); +}); + +describe('mergeSnapshotPreservingKnownCurrents (anti-flicker)', () => { + const snapshot = (modelCurrent: string | null, effortCurrent: string | null): AcpConfigOptionDto[] => [ + { + id: 'model', + category: 'model', + option_type: 'select', + current_value: modelCurrent, + options: [{ value: 'gpt-5.5', name: 'GPT-5.5' }], + }, + { + id: 'reasoning_effort', + category: 'thought_level', + option_type: 'select', + current_value: effortCurrent, + options: [ + { value: 'low', name: 'Low' }, + { value: 'high', name: 'High' }, + ], + }, + ]; + + it('preserves known currents when the incoming frame carries no current at all', () => { + const merged = mergeSnapshotPreservingKnownCurrents(snapshot('gpt-5.5', 'high'), snapshot(null, null)); + expect(merged.find((o) => o.category === 'model')?.current_value).toBe('gpt-5.5'); + expect(merged.find((o) => o.category === 'thought_level')?.current_value).toBe('high'); + }); + + it('lets an informed frame clear a sibling current (the reject re-push)', () => { + // The Core reject re-push still knows the model current but deliberately + // nulls the refused effort — the null MUST win, not be "protected". + const merged = mergeSnapshotPreservingKnownCurrents(snapshot('gpt-5.5', 'high'), snapshot('gpt-5.5', null)); + expect(merged.find((o) => o.category === 'thought_level')?.current_value).toBeNull(); + }); + + it('does not revive a current the new option list no longer offers', () => { + const next = snapshot(null, null); + next[1] = { ...next[1], options: [{ value: 'medium', name: 'Medium' }] }; + const merged = mergeSnapshotPreservingKnownCurrents(snapshot(null, 'high'), next); + expect(merged.find((o) => o.category === 'thought_level')?.current_value).toBeNull(); + }); + + it('passes the frame through untouched when there is no previous snapshot', () => { + const next = snapshot(null, null); + expect(mergeSnapshotPreservingKnownCurrents(null, next)).toBe(next); + expect(mergeSnapshotPreservingKnownCurrents([], next)).toBe(next); + }); +}); diff --git a/tests/unit/renderer/hooks/guidModelSelector.dom.test.tsx b/tests/unit/renderer/hooks/guidModelSelector.dom.test.tsx index 3440603fc0..55c679c5b8 100644 --- a/tests/unit/renderer/hooks/guidModelSelector.dom.test.tsx +++ b/tests/unit/renderer/hooks/guidModelSelector.dom.test.tsx @@ -32,6 +32,7 @@ vi.mock('react-i18next', () => ({ if (key === 'common.model') return 'Model'; if (key === 'conversation.welcome.modelSwitchNotSupported') return 'Model switch is not supported'; if (key === 'agent.thoughtLevel.label') return 'Thinking Level'; + if (key === 'common.default') return 'Default'; return key; }, }), @@ -188,6 +189,35 @@ describe('GuidModelSelector', () => { expect(onThoughtLevelSelect).toHaveBeenCalledWith('high'); }); + it('renders the honest Default when no thought level current is known', () => { + // No user pick and no backend-reported current: the selector must NOT + // pretend options[0] ("Low") is active — the backend resolves the real + // default. Pill and submenu title both fall back to the localized Default. + render( + + ); + + expect(screen.getByText('gpt-5.3-codex · Default')).toBeInTheDocument(); + const titles = screen.getAllByTestId('submenu-title'); + expect(titles[1]).toHaveTextContent('Thinking Level'); + expect(titles[1]).toHaveTextContent('Default'); + expect(titles[1]).not.toHaveTextContent('Low'); + }); + it('does not add thought level options to the Aion CLI provider model menu', () => { render( ({ + id: 'reasoning_effort', + category: 'thought_level', + currentValue, + options: [ + { value: 'low', label: 'Low' }, + { value: 'high', label: 'High' }, + ], +}); + +describe('getCurrentThoughtLevelLabel', () => { + it('maps a known current to its option label', () => { + expect(getCurrentThoughtLevelLabel(thoughtLevel('high'))).toBe('High'); + }); + + it('falls back to the raw current when it has no option entry', () => { + expect(getCurrentThoughtLevelLabel(thoughtLevel('ultra'))).toBe('ultra'); + }); + + it('returns the localized Default when the axis exists but no current is known', () => { + // Honest neutral — never options[0]: the backend resolves the real default. + expect(getCurrentThoughtLevelLabel(thoughtLevel(null), 'Default')).toBe('Default'); + }); + + it('returns empty without a default label so the pill shows model-only', () => { + expect(getCurrentThoughtLevelLabel(thoughtLevel(null))).toBe(''); + }); + + it('returns empty when there is no thought axis at all', () => { + expect(getCurrentThoughtLevelLabel(null, 'Default')).toBe(''); + expect(getCurrentThoughtLevelLabel(undefined, 'Default')).toBe(''); + }); +}); + +describe('composeRuntimeSelectorLabel', () => { + it('joins model and known level with a middle dot', () => { + expect(composeRuntimeSelectorLabel({ modelLabel: 'Fable', thoughtLevel: thoughtLevel('high') })).toBe( + 'Fable · High' + ); + }); + + it('shows model · Default when the level is unknown and a default label is given', () => { + expect( + composeRuntimeSelectorLabel({ + modelLabel: 'Fable', + thoughtLevel: thoughtLevel(null), + defaultThoughtLevelLabel: 'Default', + }) + ).toBe('Fable · Default'); + }); + + it('shows model only when there is no thought axis', () => { + expect(composeRuntimeSelectorLabel({ modelLabel: 'Fable', thoughtLevel: null })).toBe('Fable'); + }); +}); From d68e759a95742a42c41a7b2184b3d4d16ae3a6b5 Mon Sep 17 00:00:00 2001 From: itsklimov Date: Fri, 24 Jul 2026 16:51:50 -0700 Subject: [PATCH 2/2] fix(agent): stop GuidPage defaults from re-seeding the thought-level override MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The defaults effect fell back to `options[0]` when an assistant had no resolved thought-level default and the backend reported no current value. That pre-selected a concrete level which the send path then serialized as an explicit `thought_level`, silently defeating the assistant's backend default — the same implicit override useGuidAssistantSelection already stopped emitting. Drop the `options[0]` fallback so an untouched picker stays empty (`''`) unless the backend actually reports a current value; the send path then omits `thought_level` and the backend resolves the real default. Add an end-to-end send-path regression test asserting that an untouched (empty) selection serializes to `thought_level: undefined` in the conversation create request. --- .../src/renderer/pages/guid/GuidPage.tsx | 11 +- .../guidSendThoughtLevel.dom.test.tsx | 118 ++++++++++++++++++ 2 files changed, 125 insertions(+), 4 deletions(-) create mode 100644 tests/unit/renderer/guidSendThoughtLevel.dom.test.tsx diff --git a/packages/desktop/src/renderer/pages/guid/GuidPage.tsx b/packages/desktop/src/renderer/pages/guid/GuidPage.tsx index 3e700e6761..335e7467c3 100644 --- a/packages/desktop/src/renderer/pages/guid/GuidPage.tsx +++ b/packages/desktop/src/renderer/pages/guid/GuidPage.tsx @@ -445,10 +445,13 @@ const GuidPage: React.FC = () => { if (resolvedDefaults.thoughtLevel && availableThoughtLevelValues.has(resolvedDefaults.thoughtLevel)) { agentSelection.setSelectedThoughtLevelValue(resolvedDefaults.thoughtLevel, { persistPreference: false }); } else { - const fallbackThoughtLevel = - agentSelection.currentThoughtLevelOption.currentValue || - agentSelection.currentThoughtLevelOption.options[0]?.value || - ''; + // No resolved default: mirror the backend current if it reported one, + // otherwise leave the selection empty (`''`). Falling back to + // `options[0]` here re-seeded the implicit override that the send path + // then sent as an explicit `thought_level`, silently defeating the + // assistant's backend default — the exact behavior this change removes + // (matches useGuidAssistantSelection's `''` fallback). + const fallbackThoughtLevel = agentSelection.currentThoughtLevelOption.currentValue || ''; agentSelection.setSelectedThoughtLevelValue(fallbackThoughtLevel, { persistPreference: false }); } } diff --git a/tests/unit/renderer/guidSendThoughtLevel.dom.test.tsx b/tests/unit/renderer/guidSendThoughtLevel.dom.test.tsx new file mode 100644 index 0000000000..65ad31a9d6 --- /dev/null +++ b/tests/unit/renderer/guidSendThoughtLevel.dom.test.tsx @@ -0,0 +1,118 @@ +/** + * @license + * Copyright 2025 AionUi (aionui.com) + * SPDX-License-Identifier: Apache-2.0 + * + * Regression test for the thought-level send path (see PR #3734, P1). + * + * An untouched thought-level picker must NOT override the assistant's backend + * default. GuidPage's defaults effect leaves the selection empty (`''`) when + * the backend reports no current value, and the send path serializes that + * empty selection to `thought_level: undefined` — so no explicit override + * reaches `conversation.create`. This locks the end-to-end contract: an empty + * `selectedThoughtLevelValue` never leaks a concrete level (e.g. `options[0]`) + * into the create request. + */ + +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ipcBridge } from '@/common'; +import { useGuidSend, type GuidSendDeps } from '@/renderer/pages/guid/hooks/useGuidSend'; + +vi.mock('@/common', () => ({ + ipcBridge: { + conversation: { + create: { + invoke: vi.fn(), + }, + }, + }, +})); + +vi.mock('@arco-design/web-react', () => ({ + Message: { error: vi.fn(), warning: vi.fn(), success: vi.fn() }, +})); + +vi.mock('@/renderer/hooks/mcp/catalog', () => ({ + toSessionMcpServer: (server: unknown) => server, +})); + +vi.mock('@/renderer/utils/emitter', () => ({ + emitter: { emit: vi.fn() }, +})); + +vi.mock('@/renderer/utils/workspace/workspaceHistory', () => ({ + updateWorkspaceTime: vi.fn(), +})); + +vi.mock('swr', () => ({ + mutate: vi.fn(), +})); + +vi.mock('@/renderer/pages/conversation/utils/conversationCreateError', () => ({ + getConversationCreateErrorMessage: vi.fn(() => 'error'), +})); + +const createInvoke = ipcBridge.conversation.create.invoke as unknown as ReturnType; + +const buildDeps = (overrides: Partial = {}): GuidSendDeps => { + const noop = vi.fn(); + return { + input: 'hello', + setInput: noop, + files: [], + setFiles: noop, + dir: '', + setDir: noop, + setLoading: noop, + loading: false, + selectedAssistantId: 'assistant-1', + // Non-aionrs backend → exercises the ACP create path. + selectedAssistantBackend: 'gemini', + selectedMode: '', + selectedAcpModel: null, + // Untouched picker: no backend current, no user pick. + selectedThoughtLevelValue: '', + currentAcpCachedModelInfo: null, + current_model: undefined, + guidDisabledBuiltinSkills: undefined, + guidEnabledSkills: undefined, + availableMcpServers: [], + selectedMcpServerIds: undefined, + isGoogleAuth: false, + setMentionOpen: noop, + setMentionQuery: noop, + setMentionSelectorOpen: noop, + setMentionActiveIndex: noop, + navigate: vi.fn(async () => undefined) as unknown as GuidSendDeps['navigate'], + t: ((key: string) => key) as unknown as GuidSendDeps['t'], + localeKey: 'en-US', + ...overrides, + }; +}; + +describe('useGuidSend — thought-level send path (PR #3734, P1)', () => { + beforeEach(() => { + createInvoke.mockReset(); + createInvoke.mockResolvedValue({ id: 'conversation-1' }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('serializes an untouched (empty) thought-level selection as thought_level: undefined', async () => { + const { result } = renderHook(() => useGuidSend(buildDeps())); + + await act(async () => { + await result.current.handleSend(); + }); + + expect(createInvoke).toHaveBeenCalledTimes(1); + const payload = createInvoke.mock.calls[0][0]; + const overrides = payload.assistant.conversation_overrides; + // The empty selection must not leak a concrete level into the create request. + expect(overrides.thought_level).toBeUndefined(); + }); +});