diff --git a/harness/specs/tasks/fix-acp-startup-model-reload.md b/harness/specs/tasks/fix-acp-startup-model-reload.md new file mode 100644 index 00000000..6824d7c9 --- /dev/null +++ b/harness/specs/tasks/fix-acp-startup-model-reload.md @@ -0,0 +1,43 @@ +--- +id: fix-acp-startup-model-reload +title: Prevent startup model cleanup from interrupting ACP session load +scenario: gateway-backend-communication +taskType: runtime-bridge +intent: Wait for the provider snapshot before clearing an unavailable agent model override so startup does not schedule a redundant Gateway restart. +touchedAreas: + - harness/specs/tasks/fix-acp-startup-model-reload.md + - src/pages/Chat/ChatInput.tsx + - tests/unit/chat-input.test.tsx + - tests/e2e/chat-model-picker.spec.ts +expectedUserBehavior: + - Starting ClawX with an existing agent model override does not clear the override before configured providers load. + - ACP session loading is not interrupted by a model-triggered Gateway restart during startup. +requiredProfiles: + - fast + - comms + - e2e +requiredRules: + - backend-communication-boundary + - provider-model-selection-authority + - renderer-main-boundary +requiredTests: + - tests/unit/chat-input.test.tsx + - tests/e2e/chat-model-picker.spec.ts +acceptance: + - Model override cleanup waits until the initial provider snapshot request settles. + - A valid persisted override is preserved while provider data is still loading. + - Renderer backend calls remain routed through host-api. + - Focused tests, harness validation, communication replay, and communication compare pass. +docs: + required: false +--- + +## Scope + +- Gate automatic stale-model cleanup on completion of the initial provider snapshot refresh. +- Cover delayed provider startup with unit and Electron E2E regression checks. + +## Out Of Scope + +- Changing Gateway restart or ACP retry policy. +- Repairing unrelated stale OpenClaw extensions. diff --git a/src/pages/Chat/ChatInput.tsx b/src/pages/Chat/ChatInput.tsx index de15de4a..9c2a3875 100644 --- a/src/pages/Chat/ChatInput.tsx +++ b/src/pages/Chat/ChatInput.tsx @@ -221,6 +221,7 @@ export function ChatInput({ const [selectedSkill, setSelectedSkill] = useState(null); const [switchingModelRef, setSwitchingModelRef] = useState(null); const [optimisticModelRef, setOptimisticModelRef] = useState(null); + const [providerSnapshotReady, setProviderSnapshotReady] = useState(false); const textareaRef = useRef(null); const pickerRef = useRef(null); const skillPickerRef = useRef(null); @@ -235,6 +236,7 @@ export function ChatInput({ const providerStatuses = useProviderStore((s) => s.statuses); const providerDefaultAccountId = useProviderStore((s) => s.defaultAccountId); const providerVendors = useProviderStore((s) => s.vendors); + const providerError = useProviderStore((s) => s.error); const refreshProviderSnapshot = useProviderStore((s) => s.refreshProviderSnapshot); const currentAgentId = useChatStore((s) => s.currentAgentId); const currentAgent = useMemo( @@ -289,7 +291,17 @@ export function ChatInput({ const skillTokenRanges = useMemo(() => findSkillTokenRanges(input), [input]); const openArtifactPreview = useArtifactPanel((s) => s.openPreview); useEffect(() => { - void refreshProviderSnapshot(); + let cancelled = false; + void (async () => { + try { + await refreshProviderSnapshot(); + } finally { + if (!cancelled) setProviderSnapshotReady(true); + } + })(); + return () => { + cancelled = true; + }; }, [refreshProviderSnapshot]); useEffect(() => { @@ -319,11 +331,11 @@ export function ChatInput({ }, [workspaceSelectorDisabled]); useEffect(() => { - if (!currentAgent || switchingModelRef || optimisticModelRef) return; + if (!providerSnapshotReady || providerError || !currentAgent || switchingModelRef || optimisticModelRef) return; const override = (currentAgent.overrideModelRef || '').trim(); if (!override || isConfiguredModelRefAvailable(override, modelOptions)) return; void updateAgentModel(currentAgent.id, null).catch(() => {}); - }, [currentAgent, modelOptions, optimisticModelRef, switchingModelRef, updateAgentModel]); + }, [currentAgent, modelOptions, optimisticModelRef, providerError, providerSnapshotReady, switchingModelRef, updateAgentModel]); // Auto-resize textarea useEffect(() => { diff --git a/tests/e2e/chat-model-picker.spec.ts b/tests/e2e/chat-model-picker.spec.ts index d3702879..5da0c950 100644 --- a/tests/e2e/chat-model-picker.spec.ts +++ b/tests/e2e/chat-model-picker.spec.ts @@ -14,6 +14,10 @@ test.describe('ClawX chat model picker', () => { let currentModelRef = refs.alphaModelRef; const hostRequests: Array<{ path: string; method: string; body: unknown }> = []; const now = new Date().toISOString(); + let releaseProviderAccounts: (() => void) | undefined; + const providerAccountsReady = new Promise((resolve) => { + releaseProviderAccounts = resolve; + }); const originalHostInvoke = (ipcMain as unknown as { _invokeHandlers?: Map Promise>; })._invokeHandlers?.get('host:invoke'); @@ -105,6 +109,7 @@ test.describe('ClawX chat model picker', () => { return makeResponse(request.id, agentsSnapshot()); } if (request?.module === 'providers' && request.action === 'accounts') { + await providerAccountsReady; return makeResponse(request.id, [ { id: 'alpha1234', @@ -169,11 +174,29 @@ test.describe('ClawX chat model picker', () => { }); (globalThis as typeof globalThis & { __chatModelPickerRequests?: typeof hostRequests }).__chatModelPickerRequests = hostRequests; + (globalThis as typeof globalThis & { + __releaseChatModelProviders?: () => void; + }).__releaseChatModelProviders = releaseProviderAccounts; }, { alphaModelRef, betaModelRef }); const page = await getStableWindow(app); await page.reload(); await expect(page.getByTestId('main-layout')).toBeVisible(); + await expect.poll(async () => app.evaluate(() => ( + (globalThis as typeof globalThis & { + __chatModelPickerRequests?: Array<{ path: string }>; + }).__chatModelPickerRequests?.some((request) => request.path === 'providers:accounts') ?? false + ))).toBe(true); + expect(await app.evaluate(() => ( + (globalThis as typeof globalThis & { + __chatModelPickerRequests?: Array<{ path: string }>; + }).__chatModelPickerRequests?.some((request) => request.path === 'agents:updateModel') ?? false + ))).toBe(false); + await app.evaluate(() => { + (globalThis as typeof globalThis & { + __releaseChatModelProviders?: () => void; + }).__releaseChatModelProviders?.(); + }); await app.evaluate(({ BrowserWindow }) => { const win = BrowserWindow.getAllWindows()[0]; win?.webContents.send('gateway:status-changed', { state: 'running', port: 18789, pid: 12345, gatewayReady: true }); diff --git a/tests/unit/chat-input.test.tsx b/tests/unit/chat-input.test.tsx index 7430009e..f32c4dfd 100644 --- a/tests/unit/chat-input.test.tsx +++ b/tests/unit/chat-input.test.tsx @@ -21,6 +21,7 @@ const { agentsState, chatState, gatewayState, providersState, artifactPanelMocks accounts: [] as Array>, statuses: [] as Array>, defaultAccountId: null as string | null, + error: null as string | null, refreshProviderSnapshot: vi.fn(), }, artifactPanelMocks: { @@ -225,6 +226,7 @@ describe('ChatInput agent targeting', () => { providersState.accounts = []; providersState.statuses = []; providersState.defaultAccountId = null; + providersState.error = null; providersState.refreshProviderSnapshot.mockReset(); vi.mocked(hostApiFetchMock).mockReset(); vi.mocked(hostApiDialogOpenMock).mockReset(); @@ -248,6 +250,38 @@ describe('ChatInput agent targeting', () => { expect(screen.queryByTestId('chat-composer-zoomies')).not.toBeInTheDocument(); }); + it('waits for the provider snapshot before clearing an unavailable model override', async () => { + let resolveSnapshot!: () => void; + agentsState.updateAgentModel.mockResolvedValue(undefined); + providersState.refreshProviderSnapshot.mockReturnValue(new Promise((resolve) => { + resolveSnapshot = resolve; + })); + agentsState.agents = [{ + id: 'main', + name: 'Main', + modelRef: 'custom-stale/model', + overrideModelRef: 'custom-stale/model', + inheritedModel: false, + workspace: '~/.openclaw/workspace', + agentDir: '~/.openclaw/agents/main/agent', + mainSessionKey: 'agent:main:main', + channelTypes: [], + }]; + + renderChatInput(); + + await waitFor(() => { + expect(providersState.refreshProviderSnapshot).toHaveBeenCalled(); + }); + expect(agentsState.updateAgentModel).not.toHaveBeenCalled(); + + resolveSnapshot(); + + await waitFor(() => { + expect(agentsState.updateAgentModel).toHaveBeenCalledWith('main', null); + }); + }); + it('renders editable workspace selector in the composer footer', () => { render(