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 b555ccbce fix(ai): preserve live answers when timeline refresh fails
(#4867)
b555ccbce is described below
commit b555ccbce8952b50736b8868325cdea3256aca8c
Author: Hanabi <[email protected]>
AuthorDate: Thu Sep 24 18:19:29 2026 +0800
fix(ai): preserve live answers when timeline refresh fails (#4867)
fix(ai): preserve live answers when timeline refresh fails
---
.../pages/ai/hooks/useAgentRun.timeline.test.ts | 66 ++++++++++++++++++++++
web/src/pages/ai/hooks/useConversationTimeline.ts | 5 +-
2 files changed, 69 insertions(+), 2 deletions(-)
diff --git a/web/src/pages/ai/hooks/useAgentRun.timeline.test.ts
b/web/src/pages/ai/hooks/useAgentRun.timeline.test.ts
new file mode 100644
index 000000000..7b306882d
--- /dev/null
+++ b/web/src/pages/ai/hooks/useAgentRun.timeline.test.ts
@@ -0,0 +1,66 @@
+/*
+ * 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, waitFor } from '@testing-library/react';
+import { expect, it, vi } from 'vitest';
+import { useAgentRun } from './useAgentRun';
+import { useConversationTimeline } from './useConversationTimeline';
+import { openRunStream, type RunStreamHandlers } from '../../../api/ai';
+import { getConversationTimeline } from '../../../api/aiConversations';
+
+vi.mock('../../../api/ai', () => ({ openRunStream: vi.fn(), attachRunStream:
vi.fn() }));
+vi.mock('../../../api/aiConversations', () => ({
+ getConversationTimeline: vi.fn(),
+ stopRun: vi.fn(),
+ reportRunSpeed: vi.fn(),
+}));
+
+it('keepsTheLiveAnswerWhenTheRealTimelineRefreshFailsTest', async () => {
+ vi.mocked(getConversationTimeline)
+ .mockResolvedValueOnce({ items: [], nextAfter: null, activeRun: null })
+ .mockRejectedValueOnce(new Error('timeline unavailable'));
+ let handlers!: RunStreamHandlers;
+ let finish!: () => void;
+ vi.mocked(openRunStream).mockImplementation((_id, _body, callbacks) => {
+ handlers = callbacks;
+ return new Promise<void>((resolve) => {
+ finish = resolve;
+ });
+ });
+ const { result } = renderHook(() => {
+ const timeline = useConversationTimeline(7);
+ const run = useAgentRun(7, { refetchTimeline: timeline.refetch });
+ return { timeline, run };
+ });
+ await waitFor(() => expect(result.current.timeline.loading).toBe(false));
+ let sent!: Promise<void>;
+ await act(async () => {
+ sent = result.current.run.send(7, { message: 'hello' });
+ });
+ await act(async () => {
+ handlers.onEvent({ type: 'text_delta', content: 'answer not yet in
history' });
+ });
+ const liveAnswer = result.current.run.blocksRef.current;
+ expect(liveAnswer.length).toBeGreaterThan(0);
+ await act(async () => {
+ finish();
+ await sent;
+ });
+ expect(result.current.timeline.error).toBe('timeline unavailable');
+ expect(result.current.run.blocksRef.current).toEqual(liveAnswer);
+ expect(result.current.run.error).toBe('timeline unavailable');
+});
diff --git a/web/src/pages/ai/hooks/useConversationTimeline.ts
b/web/src/pages/ai/hooks/useConversationTimeline.ts
index bc900fd6a..33ae3adb0 100644
--- a/web/src/pages/ai/hooks/useConversationTimeline.ts
+++ b/web/src/pages/ai/hooks/useConversationTimeline.ts
@@ -87,7 +87,7 @@ export interface UseConversationTimelineResult {
error: string;
/** True when the bounded forward walk stopped before the tail; `loadMore`
continues it. */
hasMore: boolean;
- /** Reload the whole transcript from `seq > 0`. Awaitable: `useAgentRun`
depends on that. */
+ /** Reload the whole transcript from `seq > 0`; reject on failure so live
blocks are retained. */
refetch: () => Promise<void>;
loadMore: () => Promise<void>;
}
@@ -152,6 +152,7 @@ export function useConversationTimeline(
} catch (loadError) {
if (id !== requestId.current) return;
setError(describeThrownMessage(loadError));
+ throw loadError;
} finally {
if (id === requestId.current) setLoading(false);
}
@@ -188,7 +189,7 @@ export function useConversationTimeline(
useEffect(() => {
// Loading is asynchronous; state updates happen after the timeline API
resolves.
// eslint-disable-next-line react-hooks/set-state-in-effect
- void refetch();
+ void refetch().catch(() => undefined);
return () => {
requestId.current += 1;
};