This is an automated email from the ASF dual-hosted git repository.
lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git
The following commit(s) were added to refs/heads/rocketmq-studio by this push:
new 114656085 fix(ai): drop superseded LLM runtime responses instead of
letting them repopulate the state (#4631)
114656085 is described below
commit 114656085b61bebfde9c55b06abcd6c45e56567f
Author: Apulupie <[email protected]>
AuthorDate: Mon Sep 21 21:07:47 2026 +0800
fix(ai): drop superseded LLM runtime responses instead of letting them
repopulate the state (#4631)
`useLlmRuntime.load` had no staleness guard: after `await getLlmConfig()`
it called `setConfig(loaded)` unconditionally, and likewise `setModelOptions` /
`setSelectedModel` after `await getLlmModels()`. `enabled` can flip at runtime
— the mock/real data-mode switch in `MainLayout` toggles the persisted
`dataModeStore` that `ai/index.tsx` feeds into it — and the flip re-runs
`load`, whose disabled branch synchronously clears `config`, `modelOptions` and
`selectedModel`. The two request [...]
A monotonic `loadSeqRef` is incremented before the disabled branch, so
disabling invalidates whatever is in flight, and every state write — both
success paths, the `catch` error report and the `finally` loading flag — checks
that its sequence is still the latest before applying. The new
`useLlmRuntime.test.ts` drives the two requests with deferred promises to pin
each ordering.
Maintainer edits on top of the contribution: the new test passed a value to
the file's `deferred<T>()` helper, which takes none — `tsc -b` failed with
TS2554 while vitest stayed green, because vitest transpiles without type
checking. The dead argument was dropped and the helper's type parameter made
explicit; nothing else was changed.
Fixes #4632
---
web/src/pages/ai/hooks/useLlmRuntime.test.ts | 136 +++++++++++++++++++++++++++
web/src/pages/ai/hooks/useLlmRuntime.ts | 11 ++-
2 files changed, 145 insertions(+), 2 deletions(-)
diff --git a/web/src/pages/ai/hooks/useLlmRuntime.test.ts
b/web/src/pages/ai/hooks/useLlmRuntime.test.ts
new file mode 100644
index 000000000..0713db18a
--- /dev/null
+++ b/web/src/pages/ai/hooks/useLlmRuntime.test.ts
@@ -0,0 +1,136 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { act, renderHook } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import type { LlmConfig } from '../../../api/llm';
+import { useLlmRuntime } from './useLlmRuntime';
+
+vi.mock('../../../api/llm', () => ({
+ getLlmConfig: vi.fn(),
+ getLlmModels: vi.fn(),
+}));
+
+import { getLlmConfig, getLlmModels } from '../../../api/llm';
+
+const configMock = vi.mocked(getLlmConfig);
+const modelsMock = vi.mocked(getLlmModels);
+
+function deferred<T>() {
+ let resolve!: (value: T) => void;
+ const promise = new Promise<T>((res) => {
+ resolve = res;
+ });
+ return { promise, resolve };
+}
+
+const config: LlmConfig = {
+ provider: 'openai',
+ engine: 'http',
+ apiBase: 'https://example.invalid',
+ model: 'gpt-test',
+ maxTokens: 1024,
+ temperature: 0,
+ enabled: true,
+ ready: true,
+};
+
+function render(enabled: boolean) {
+ return renderHook(({ enabled }: { enabled: boolean }) => useLlmRuntime({
enabled }), {
+ initialProps: { enabled },
+ });
+}
+
+describe('useLlmRuntime', () => {
+ beforeEach(() => {
+ configMock.mockReset();
+ modelsMock.mockReset();
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('a late response for a disabled runtime never repopulates the state',
async () => {
+ const configDeferred = deferred<LlmConfig>();
+ const modelsDeferred = deferred<unknown>();
+ configMock.mockReturnValue(configDeferred.promise as never);
+ modelsMock.mockReturnValue(modelsDeferred.promise as never);
+
+ const { result, rerender } = render(true);
+ await act(async () => {
+ // getLlmConfig resolves before the toggle; getLlmModels is still in
flight.
+ configDeferred.resolve(config);
+ });
+ rerender({ enabled: false });
+ expect(result.current.config).toBeNull();
+ expect(result.current.modelOptions).toEqual([]);
+ expect(result.current.llmReady).toBe(false);
+
+ await act(async () => {
+ modelsDeferred.resolve({ status: 0, data: [{ id: 'gpt-test' }] });
+ });
+ expect(result.current.config).toBeNull();
+ expect(result.current.modelOptions).toEqual([]);
+ expect(result.current.selectedModel).toBe('');
+ expect(result.current.llmReady).toBe(false);
+ expect(result.current.modelsLoading).toBe(false);
+ });
+
+ it('never repopulates the state when the config response resolves after the
toggle', async () => {
+ // The config request is still in flight when the runtime is disabled;
once it finally
+ // resolves it belongs to a superseded load and must be dropped entirely.
+ const configDeferred = deferred<LlmConfig>();
+ configMock.mockReturnValue(configDeferred.promise as never);
+ modelsMock.mockResolvedValue({ status: 0, data: [] });
+
+ const { result, rerender } = render(true);
+ rerender({ enabled: false });
+ await act(async () => {
+ configDeferred.resolve(config);
+ });
+ expect(result.current.config).toBeNull();
+ expect(result.current.modelOptions).toEqual([]);
+ expect(result.current.llmReady).toBe(false);
+ expect(modelsMock).not.toHaveBeenCalled();
+ });
+
+ it('keeps the last request winning when reload overlaps', async () => {
+ // The initial effect load, the first reload and the second reload each
suspend on the
+ // deferred the mock hands out at call time. Resolving the latest request
first proves the
+ // later-arriving superseded response cannot overwrite it.
+ const superseded = deferred<LlmConfig>();
+ const latest = deferred<LlmConfig>();
+ const latestConfig: LlmConfig = { ...config, model: 'gpt-latest' };
+ let current = superseded;
+ configMock.mockImplementation(() => current.promise as never);
+ modelsMock.mockResolvedValue({ status: 0, data: [] });
+
+ const { result } = render(true);
+ await act(async () => {
+ const first = result.current.reload();
+ current = latest;
+ const second = result.current.reload();
+ current = superseded;
+ latest.resolve(latestConfig);
+ superseded.resolve(config);
+ await Promise.all([first, second]);
+ });
+ expect(result.current.config).toEqual(latestConfig);
+ expect(result.current.config?.model).toBe('gpt-latest');
+ });
+});
diff --git a/web/src/pages/ai/hooks/useLlmRuntime.ts
b/web/src/pages/ai/hooks/useLlmRuntime.ts
index de9b679b6..655fe54c7 100644
--- a/web/src/pages/ai/hooks/useLlmRuntime.ts
+++ b/web/src/pages/ai/hooks/useLlmRuntime.ts
@@ -87,7 +87,12 @@ export function useLlmRuntime(options:
UseLlmRuntimeOptions): UseLlmRuntimeResul
);
}, []);
+ // Monotonic id per load: a response from a superseded load (an enabled flip
or an overlapping
+ // reload) must not repopulate the state, or a disabled runtime could come
back "ready".
+ const loadSeqRef = useRef(0);
+
const load = useCallback(async () => {
+ const requestId = ++loadSeqRef.current;
if (!enabled) {
setConfig(null);
setModelOptions([]);
@@ -98,10 +103,12 @@ export function useLlmRuntime(options:
UseLlmRuntimeOptions): UseLlmRuntimeResul
setModelsLoading(true);
try {
const loaded = await getLlmConfig();
+ if (requestId !== loadSeqRef.current) return;
setConfig(loaded);
if (isAgentEngine(loaded.engine))
optionsRef.current.onEngine?.(loaded.engine);
if (loaded.model) setSelectedModel((current) => current || loaded.model);
const result = await getLlmModels();
+ if (requestId !== loadSeqRef.current) return;
const models = result?.status === 0 && result.data ? result.data : [];
const options = models
.map((item) => item.id || item.name || '')
@@ -114,9 +121,9 @@ export function useLlmRuntime(options:
UseLlmRuntimeOptions): UseLlmRuntimeResul
setModelOptions([{ value: loaded.model, label: loaded.model }]);
}
} catch (error) {
- optionsRef.current.onError?.(error);
+ if (requestId === loadSeqRef.current)
optionsRef.current.onError?.(error);
} finally {
- setModelsLoading(false);
+ if (requestId === loadSeqRef.current) setModelsLoading(false);
}
}, [enabled]);