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]);
 

Reply via email to