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 806072f30 fix(ai): drop an agent stop rejection whose run ownership
went stale (#4960)
806072f30 is described below
commit 806072f3033e38a26a801eb31d41b7a67b9b5165
Author: aias00 <[email protected]>
AuthorDate: Thu Sep 24 03:21:52 2026 -0700
fix(ai): drop an agent stop rejection whose run ownership went stale (#4960)
fix(studio): ignore stale agent stop failures
Signed-off-by: liuhy <[email protected]>
---
web/src/pages/ai/hooks/useAgentRun.test.ts | 44 ++++++++++++++++++++++++++++++
web/src/pages/ai/hooks/useAgentRun.ts | 5 ++++
2 files changed, 49 insertions(+)
diff --git a/web/src/pages/ai/hooks/useAgentRun.test.ts
b/web/src/pages/ai/hooks/useAgentRun.test.ts
index 3e49bc4d0..b742f98f6 100644
--- a/web/src/pages/ai/hooks/useAgentRun.test.ts
+++ b/web/src/pages/ai/hooks/useAgentRun.test.ts
@@ -83,6 +83,16 @@ function stubStreamFunction(fn: unknown): StreamCall[] {
return calls;
}
+function deferred<T>() {
+ let resolve!: (value: T | PromiseLike<T>) => void;
+ let reject!: (reason?: unknown) => void;
+ const promise = new Promise<T>((res, rej) => {
+ resolve = res;
+ reject = rej;
+ });
+ return { promise, resolve, reject };
+}
+
/** Let the requestAnimationFrame-coalesced tick fire (jsdom paints at ~16ms).
*/
async function flushFrame(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 32));
@@ -407,6 +417,40 @@ describe('useAgentRun', () => {
expect(openedStreams[0].signal.aborted).toBe(false);
});
+ it('ignoresAStopFailureAfterNavigatingToAnotherConversationTest', async ()
=> {
+ const onError = vi.fn();
+ const pendingStop = deferred<Awaited<ReturnType<typeof stopRun>>>();
+ vi.mocked(stopRun).mockReturnValue(pendingStop.promise);
+ const { result, rerender } = render({ onError });
+
+ await act(async () => {
+ void result.current.send(7, { message: 'hi' });
+ openedStreams[0].emit(runStarted());
+ });
+
+ let stopPromise!: Promise<void>;
+ await act(async () => {
+ stopPromise = result.current.stop();
+ await Promise.resolve();
+ });
+ expect(result.current.stopRequested).toBe(true);
+
+ await act(async () => {
+ rerender({ id: 8 });
+ });
+ expect(result.current.stopRequested).toBe(false);
+ expect(result.current.error).toBe('');
+
+ await act(async () => {
+ pendingStop.reject(new Error('conversation 7 stop failed late'));
+ await stopPromise;
+ });
+
+ expect(result.current.error).toBe('');
+ expect(result.current.stopRequested).toBe(false);
+ expect(onError).not.toHaveBeenCalled();
+ });
+
it('attachesToAnAlreadyRunningRunWithTheReplayCursorTest', async () => {
const { result } = render();
diff --git a/web/src/pages/ai/hooks/useAgentRun.ts
b/web/src/pages/ai/hooks/useAgentRun.ts
index cf7f466de..c0b148501 100644
--- a/web/src/pages/ai/hooks/useAgentRun.ts
+++ b/web/src/pages/ai/hooks/useAgentRun.ts
@@ -390,6 +390,8 @@ export function useAgentRun(
// window with nothing to address. The button stays disabled until then
(`canStop`).
if (targetRunId === null) return;
+ const requestId = streamRequestIdRef.current;
+ const generation = generationRef.current;
setStopRequested(true);
try {
// The response is the run row, which the stream reports authoritatively
anyway; the point of
@@ -398,6 +400,9 @@ export function useAgentRun(
// Deliberately NOT aborting the fetch: the open stream is what delivers
the terminal
// `run_status` frame and `done`, and aborting would leave the button in
`stopping` forever.
} catch (stopError) {
+ // Navigation or a newer stream invalidated this Stop request. Its
failure belongs to the old
+ // run and must not paint an error onto the conversation that owns the
hook now.
+ if (requestId !== streamRequestIdRef.current || generation !==
generationRef.current) return;
setStopRequested(false);
setError(describeThrownMessage(stopError));
optionsRef.current.onError?.(stopError);