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 85f89c959 fix(ai): stop showing unfinished tools as running after
termination (#6419)
85f89c959 is described below
commit 85f89c95944f56e2b625ffee1b2489a67f727160
Author: Loyal-Young <[email protected]>
AuthorDate: Thu Oct 15 18:12:21 2026 +0800
fix(ai): stop showing unfinished tools as running after termination (#6419)
---
web/src/i18n/translations.ts | 5 +++
web/src/pages/ai/components/AssistantBubble.tsx | 1 +
.../components/__tests__/AssistantBubble.test.tsx | 39 ++++++++++++++++++++++
web/src/pages/ai/components/blocks/ToolBlock.tsx | 26 +++++++++++----
4 files changed, 64 insertions(+), 7 deletions(-)
diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts
index 42a99c268..f7d9cf1e3 100644
--- a/web/src/i18n/translations.ts
+++ b/web/src/i18n/translations.ts
@@ -1866,6 +1866,11 @@ const translations: Record<string, Record<Lang, string>>
= {
'ai.tool.unknownName': { zh: '未知工具', en: 'Unknown tool' },
'ai.tool.output': { zh: '执行结果', en: 'Result' },
'ai.tool.running': { zh: '执行中', en: 'Running' },
+ 'ai.tool.missingResult': { zh: '未返回结果', en: 'No result' },
+ 'ai.tool.outputMissing': {
+ zh: '本次运行已结束,未收到工具结果',
+ en: 'The run ended without a tool result',
+ },
'ai.tool.success': { zh: '成功', en: 'Success' },
'ai.tool.failed': { zh: '失败', en: 'Failed' },
'ai.tool.truncated': { zh: '已截断({size})', en: 'Truncated ({size})' },
diff --git a/web/src/pages/ai/components/AssistantBubble.tsx
b/web/src/pages/ai/components/AssistantBubble.tsx
index 9e149b7c2..c230f3f25 100644
--- a/web/src/pages/ai/components/AssistantBubble.tsx
+++ b/web/src/pages/ai/components/AssistantBubble.tsx
@@ -233,6 +233,7 @@ const AssistantBubble = ({
key={`tool-${index}-${block.tcId}`}
block={block}
toolCatalog={toolCatalog}
+ runStatus={bubble.runStatus}
/>
);
case 'notice':
diff --git a/web/src/pages/ai/components/__tests__/AssistantBubble.test.tsx
b/web/src/pages/ai/components/__tests__/AssistantBubble.test.tsx
index 98c716059..a8d39192c 100644
--- a/web/src/pages/ai/components/__tests__/AssistantBubble.test.tsx
+++ b/web/src/pages/ai/components/__tests__/AssistantBubble.test.tsx
@@ -70,6 +70,45 @@ const TOOL_RESULT = {
success: true,
};
+describe('terminal tool rendering', () => {
+ it.each(['STOPPED', 'FAILED', 'COMPLETED'] as const)(
+ 'doesNotClaimAnUnfinishedToolIsStillRunningAfter%sTest',
+ (runStatus) => {
+ const pendingTool = runningToolBlock('pending', 'rmq.topic.list', {
instanceId: 'prod' });
+ const { rerender } = render(
+ <LangProvider>
+ <AssistantBubble bubble={bubble([pendingTool])} streaming />
+ </LangProvider>,
+ );
+ expect(screen.getByTestId('ai-tool-progress')).toBeInTheDocument();
+ rerender(
+ <LangProvider>
+ <AssistantBubble bubble={bubble([pendingTool], { runStatus })} />
+ </LangProvider>,
+ );
+ expect(screen.queryByTestId('ai-tool-progress')).not.toBeInTheDocument();
+ const card = screen.getByTestId('ai-tool-block');
+ expect(within(card).queryByText('执行中')).not.toBeInTheDocument();
+ expect(within(card).queryByText('成功')).not.toBeInTheDocument();
+ expect(within(card).queryByText('失败')).not.toBeInTheDocument();
+ expect(within(card).getByText('未返回结果')).toBeInTheDocument();
+ expect(within(card).getByTestId('tool-input')).toHaveTextContent('prod');
+ expect(pendingTool.status).toBe('running');
+ },
+ );
+
+ it('keepsAnObservedResultOnATerminalRunTest', () => {
+ renderBubble({
+ blocks: withToolResult([], TOOL_RESULT),
+ bubbleExtra: { runStatus: 'STOPPED' },
+ });
+ const card = screen.getByTestId('ai-tool-block');
+ expect(within(card).getByText('成功')).toBeInTheDocument();
+
expect(within(card).getByTestId('tool-result')).toHaveTextContent('TopicA');
+ expect(within(card).queryByText('未返回结果')).not.toBeInTheDocument();
+ });
+});
+
function bubble(blocks: RenderBlock[], extra: Partial<Bubble> = {}): Bubble {
return { role: 'assistant', blocks, ...extra };
}
diff --git a/web/src/pages/ai/components/blocks/ToolBlock.tsx
b/web/src/pages/ai/components/blocks/ToolBlock.tsx
index ece2ccd7b..9b0eefc4d 100644
--- a/web/src/pages/ai/components/blocks/ToolBlock.tsx
+++ b/web/src/pages/ai/components/blocks/ToolBlock.tsx
@@ -17,9 +17,10 @@
import { memo, type CSSProperties } from 'react';
import { Progress, Tag, theme } from 'antd';
-import { CaretRight, CheckCircle, CircleNotch, XCircle } from
'@phosphor-icons/react';
+import { CaretRight, CheckCircle, CircleNotch, MinusCircle, XCircle } from
'@phosphor-icons/react';
import { useLang } from '../../../../i18n/LangContext';
import type { McpTool } from '../../../../api/ai';
+import type { RunStatus } from '../../../../api/aiEvents';
import type { ToolBlock as ToolBlockData } from '../../render/blocks';
import { toolDisplayName } from '../../render/blocks';
import { formatBytes } from '../../../../utils/format';
@@ -78,6 +79,7 @@ const usePreStyle = (): CSSProperties => {
export interface ToolBlockProps {
block: ToolBlockData;
+ runStatus?: RunStatus;
/** Catalog from `listTools()`, used for the risk tag. Optional: no catalog,
no tag. */
toolCatalog?: readonly McpTool[];
}
@@ -105,13 +107,15 @@ function resolveCatalogTool(
return catalog.find((entry) => toolDisplayName(entry.name) === display);
}
-const ToolBlock = ({ block, toolCatalog }: ToolBlockProps) => {
+const ToolBlock = ({ block, toolCatalog, runStatus }: ToolBlockProps) => {
const { t } = useLang();
const { token } = theme.useToken();
const preStyle = usePreStyle();
const catalogTool = resolveCatalogTool(toolCatalog, block.tool);
- const running = block.status === 'running';
+ const ended = runStatus === 'COMPLETED' || runStatus === 'STOPPED' ||
runStatus === 'FAILED';
+ const missingResult = block.status === 'running' && ended;
+ const running = block.status === 'running' && !ended;
const failed = block.status === 'done' && block.success === false;
const displayName = toolDisplayName(block.tool) || t('ai.tool.unknownName');
@@ -133,7 +137,7 @@ const ToolBlock = ({ block, toolCatalog }: ToolBlockProps)
=> {
return (
<details
data-testid="ai-tool-block"
- data-status={block.status}
+ data-status={missingResult ? 'missing-result' : block.status}
className="ai-tool-card"
open={running || undefined}
style={{
@@ -161,6 +165,8 @@ const ToolBlock = ({ block, toolCatalog }: ToolBlockProps)
=> {
>
{running ? (
<CircleNotch size={16} className="ai-icon-spin" style={{ color:
token.colorPrimary }} />
+ ) : missingResult ? (
+ <MinusCircle size={16} style={{ color: token.colorTextSecondary }} />
) : failed ? (
<XCircle size={16} weight="fill" style={{ color: token.colorError }}
/>
) : (
@@ -191,10 +197,16 @@ const ToolBlock = ({ block, toolCatalog }:
ToolBlockProps) => {
)}
<Tag
data-testid="ai-tool-status"
- color={running ? 'processing' : failed ? 'error' : 'success'}
+ color={running ? 'processing' : missingResult ? 'default' : failed ?
'error' : 'success'}
style={{ marginInlineEnd: 0, fontSize: 14, flexShrink: 0 }}
>
- {running ? t('ai.tool.running') : failed ? t('ai.tool.failed') :
t('ai.tool.success')}
+ {running
+ ? t('ai.tool.running')
+ : missingResult
+ ? t('ai.tool.missingResult')
+ : failed
+ ? t('ai.tool.failed')
+ : t('ai.tool.success')}
</Tag>
{block.durationMs !== null && (
<span style={{ fontSize: 14, color: token.colorTextTertiary,
flexShrink: 0 }}>
@@ -251,7 +263,7 @@ const ToolBlock = ({ block, toolCatalog }: ToolBlockProps)
=> {
<summary style={summaryStyle}>{t('ai.tool.output')}</summary>
{block.output === null ? (
<div style={{ marginTop: 6, fontSize: 14, color:
token.colorTextTertiary }}>
- {t('ai.tool.outputPending')}
+ {t(missingResult ? 'ai.tool.outputMissing' :
'ai.tool.outputPending')}
</div>
) : (
<pre data-testid="tool-result" style={{ ...preStyle, marginTop: 6 }}>