mirror of
https://github.com/ValueCell-ai/ClawX.git
synced 2026-08-14 00:48:10 +00:00
fix: prevent model reload from interrupting ACP startup (#1178)
This commit is contained in:
@@ -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.
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user