fix: prevent model reload from interrupting ACP startup (#1178)

This commit is contained in:
paisley
2026-07-17 15:16:44 +08:00
committed by GitHub
parent 91836cd6bc
commit ff61f5dd72
4 changed files with 115 additions and 3 deletions
@@ -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.
+15 -3
View File
@@ -221,6 +221,7 @@ export function ChatInput({
const [selectedSkill, setSelectedSkill] = useState<QuickAccessSkill | null>(null);
const [switchingModelRef, setSwitchingModelRef] = useState<string | null>(null);
const [optimisticModelRef, setOptimisticModelRef] = useState<string | null>(null);
const [providerSnapshotReady, setProviderSnapshotReady] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const pickerRef = useRef<HTMLDivElement>(null);
const skillPickerRef = useRef<HTMLDivElement>(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(() => {
+23
View File
@@ -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<void>((resolve) => {
releaseProviderAccounts = resolve;
});
const originalHostInvoke = (ipcMain as unknown as {
_invokeHandlers?: Map<string, (event: unknown, request: unknown) => Promise<unknown>>;
})._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 });
+34
View File
@@ -21,6 +21,7 @@ const { agentsState, chatState, gatewayState, providersState, artifactPanelMocks
accounts: [] as Array<Record<string, unknown>>,
statuses: [] as Array<Record<string, unknown>>,
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<void>((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(
<TooltipProvider>