fix(chat): fold tool steps into execution graph and remove duplicate tool pills (#1048)
This commit is contained in:
@@ -1,11 +1,12 @@
|
||||
/**
|
||||
* Chat Message Component
|
||||
* Renders user / assistant / system / toolresult messages
|
||||
* with markdown, images, and tool cards. Thinking output is
|
||||
* with markdown and images. Tool steps render in ExecutionGraphCard;
|
||||
* streaming runs may show a compact ToolStatusBar. Thinking output is
|
||||
* surfaced via ExecutionGraphCard, not inside message bubbles.
|
||||
*/
|
||||
import { useState, useCallback, useEffect, memo } from 'react';
|
||||
import { Sparkles, Copy, Check, ChevronDown, ChevronRight, Wrench, FileText, Film, Music, FileArchive, File, X, FolderOpen, ZoomIn, Loader2, CheckCircle2, AlertCircle } from 'lucide-react';
|
||||
import { Sparkles, Copy, Check, Wrench, FileText, Film, Music, FileArchive, File, X, FolderOpen, ZoomIn, Loader2, CheckCircle2, AlertCircle } from 'lucide-react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import remarkMath from 'remark-math';
|
||||
@@ -70,7 +71,36 @@ function isDirectoryAttachment(file: AttachedFileMeta): boolean {
|
||||
|
||||
function isSkillFileAttachment(file: AttachedFileMeta): boolean {
|
||||
const path = file.filePath ?? '';
|
||||
return /(?:^|[\\/])\.openclaw[\\/]skills[\\/][^\\/]+[\\/].+\.[A-Za-z0-9]+$/i.test(path);
|
||||
return (
|
||||
/(?:^|[\\/])\.openclaw[\\/]skills[\\/][^\\/]+[\\/].+\.[A-Za-z0-9]+$/i.test(path)
|
||||
|| /(?:^|[\\/])skills[\\/][^\\/]+[\\/]SKILL\.md$/i.test(path)
|
||||
);
|
||||
}
|
||||
|
||||
function isHtmlOrMarkdownPreview(file: AttachedFileMeta): boolean {
|
||||
const name = file.fileName.toLowerCase();
|
||||
const mime = file.mimeType.toLowerCase();
|
||||
return (
|
||||
mime === 'text/html'
|
||||
|| mime === 'text/markdown'
|
||||
|| name.endsWith('.html')
|
||||
|| name.endsWith('.htm')
|
||||
|| name.endsWith('.md')
|
||||
|| name.endsWith('.markdown')
|
||||
);
|
||||
}
|
||||
|
||||
/** User-facing artifacts that must stay visible when process output is folded into the graph. */
|
||||
function isUserFacingAttachmentWhenFolded(file: AttachedFileMeta): boolean {
|
||||
if (file.mimeType.startsWith('image/')) return true;
|
||||
if (isDirectoryAttachment(file)) return true;
|
||||
if (isSkillFileAttachment(file)) return true;
|
||||
if (isChatPreviewDocument(file)) return true;
|
||||
// Paths parsed from the assistant reply (e.g. "/workspace/demo.html") are
|
||||
// intentional user-facing links. Generic tool-result markdown attachments
|
||||
// (e.g. CHECKLIST.md emitted mid-run) stay folded into the execution graph.
|
||||
if (file.source === 'message-ref' && isHtmlOrMarkdownPreview(file)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function validationKindForAttachment(file: AttachedFileMeta): 'file' | 'dir' | null {
|
||||
@@ -276,13 +306,12 @@ export const ChatMessage = memo(function ChatMessage({
|
||||
});
|
||||
const filteredProcessAttachments = derivedAttachedFiles.filter((file) => {
|
||||
if (file.source !== 'tool-result' && file.source !== 'message-ref') return true;
|
||||
// Runtime-produced user-facing artifacts (images, PDFs, spreadsheets,
|
||||
// Runtime-produced user-facing artifacts (images, HTML/Markdown/PDF/XLSX,
|
||||
// skill directories, ...) must remain visible in the reply bubble even
|
||||
// when generic process attachments are folded into the execution graph.
|
||||
// The graph card itself does not render `_attachedFiles`, so dropping
|
||||
// images here would leave the user with no way to see them at all.
|
||||
if (file.mimeType.startsWith('image/')) return true;
|
||||
return isChatPreviewDocument(file) || isDirectoryAttachment(file) || isSkillFileAttachment(file);
|
||||
// them here would leave the user with no way to open previews from chat.
|
||||
return isUserFacingAttachmentWhenFolded(file);
|
||||
});
|
||||
// When a message is attachment-only, keep those attachments visible even if
|
||||
// process attachments are generally suppressed for this run segment —
|
||||
@@ -328,15 +357,6 @@ export const ChatMessage = memo(function ChatMessage({
|
||||
<ToolStatusBar tools={streamingTools} />
|
||||
)}
|
||||
|
||||
{/* Tool use cards */}
|
||||
{visibleTools.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{visibleTools.map((tool, i) => (
|
||||
<ToolCard key={tool.id || i} name={tool.name} input={tool.input} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Images — rendered ABOVE text bubble for user messages */}
|
||||
{/* Images from content blocks (Gateway session data / channel push photos) */}
|
||||
{isUser && images.length > 0 && (
|
||||
@@ -809,27 +829,3 @@ function ImageLightbox({
|
||||
);
|
||||
}
|
||||
|
||||
// ── Tool Card ───────────────────────────────────────────────────
|
||||
|
||||
function ToolCard({ name, input }: { name: string; input: unknown }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-black/10 dark:border-white/10 bg-black/5 dark:bg-white/5 text-sm">
|
||||
<button
|
||||
className="flex items-center gap-2 w-full px-3 py-1.5 text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-green-500 shrink-0" />
|
||||
<Wrench className="h-3 w-3 shrink-0 opacity-60" />
|
||||
<span className="font-mono text-xs">{name}</span>
|
||||
{expanded ? <ChevronDown className="h-3 w-3 ml-auto" /> : <ChevronRight className="h-3 w-3 ml-auto" />}
|
||||
</button>
|
||||
{expanded && input != null && (
|
||||
<pre className="px-3 pb-2 text-xs text-muted-foreground overflow-x-auto">
|
||||
{typeof input === 'string' ? input : JSON.stringify(input, null, 2) as string}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,8 +18,8 @@ import { ChatMessage } from './ChatMessage';
|
||||
import { ChatInput } from './ChatInput';
|
||||
import { ExecutionGraphCard } from './ExecutionGraphCard';
|
||||
import { ChatToolbar } from './ChatToolbar';
|
||||
import { extractImages, extractText, extractThinking, extractToolUse, stripProcessMessagePrefix } from './message-utils';
|
||||
import { deriveTaskSteps, findReplyMessageIndex, parseSubagentCompletionInfo, type TaskStep } from './task-visualization';
|
||||
import { extractImages, extractText, extractThinking, extractToolUse, normalizeMessageRole, stripProcessMessagePrefix } from './message-utils';
|
||||
import { buildRunSegmentMessageIndices, deriveTaskSteps, findReplyMessageIndex, getRunSegmentMessages, parseSubagentCompletionInfo, type TaskStep } from './task-visualization';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useStickToBottomInstant } from '@/hooks/use-stick-to-bottom-instant';
|
||||
@@ -92,13 +92,13 @@ function buildQuestionDirectoryTitle(message: RawMessage, fallback: string): str
|
||||
}
|
||||
|
||||
function isRealUserMessage(msg: RawMessage): boolean {
|
||||
if (msg.role !== 'user') return false;
|
||||
if (normalizeMessageRole(msg.role) !== 'user') return false;
|
||||
const content = msg.content;
|
||||
if (!Array.isArray(content)) return true;
|
||||
// If every block in the content is a tool_result, this is a Gateway
|
||||
// tool-result wrapper, not a real user message.
|
||||
const blocks = content as Array<{ type?: string }>;
|
||||
return blocks.length === 0 || !blocks.every((b) => b.type === 'tool_result');
|
||||
return blocks.length === 0 || !blocks.every((b) => b.type === 'tool_result' || b.type === 'toolResult');
|
||||
}
|
||||
|
||||
function generatedFileToTarget(file: GeneratedFile): FilePreviewTarget {
|
||||
@@ -338,6 +338,16 @@ export function Chat() {
|
||||
|
||||
const questionDirectoryVisible = questionDirectoryOpenSessionKey === currentSessionKey && questionDirectoryItems.length > 1;
|
||||
|
||||
const isRunTrigger = useCallback(
|
||||
(message: RawMessage, index: number) => isRealUserMessage(message) && !subagentCompletionInfos[index],
|
||||
[subagentCompletionInfos],
|
||||
);
|
||||
|
||||
const runSegmentMessageIndices = useMemo(
|
||||
() => buildRunSegmentMessageIndices(messages, nextUserMessageIndexes, isRunTrigger),
|
||||
[messages, nextUserMessageIndexes, isRunTrigger],
|
||||
);
|
||||
|
||||
// Indices of intermediate assistant process messages that are represented
|
||||
// in the ExecutionGraphCard (narration text and/or thinking). We suppress
|
||||
// them from the chat stream so they don't appear duplicated below the graph.
|
||||
@@ -351,7 +361,7 @@ export function Chat() {
|
||||
: `${currentSessionKey}:trigger-${idx}`;
|
||||
const nextUserIndex = nextUserMessageIndexes[idx];
|
||||
const segmentEnd = nextUserIndex === -1 ? messages.length : nextUserIndex;
|
||||
const segmentMessages = messages.slice(idx + 1, segmentEnd);
|
||||
const segmentMessages = getRunSegmentMessages(messages, idx, nextUserIndex, isRunTrigger);
|
||||
const completionInfos = subagentCompletionInfos
|
||||
.slice(idx + 1, segmentEnd)
|
||||
.filter((value): value is NonNullable<typeof value> => value != null);
|
||||
@@ -590,7 +600,7 @@ export function Chat() {
|
||||
streamingReplyText,
|
||||
suppressThinking,
|
||||
}];
|
||||
}, [messages, subagentCompletionInfos, currentSessionKey, streamingMessage, streamingTools, pendingFinal, sending, hasAnyStreamContent, hasStreamText, hasStreamImages, streamText, streamTools.length, hasRunningStreamToolStatus, childTranscripts, currentAgentId, agents, sessionLabels, graphStepCache, runError]);
|
||||
}, [messages, subagentCompletionInfos, currentSessionKey, streamingMessage, streamingTools, pendingFinal, sending, hasAnyStreamContent, hasStreamText, hasStreamImages, streamText, streamTools.length, hasRunningStreamToolStatus, childTranscripts, currentAgentId, agents, sessionLabels, graphStepCache, runError, isRunTrigger]);
|
||||
const hasActiveExecutionGraph = userRunCards.some((card) => card.active);
|
||||
const replyTextOverrides = useMemo(() => {
|
||||
const map = new Map<number, string>();
|
||||
@@ -776,9 +786,14 @@ export function Chat() {
|
||||
)}
|
||||
{messages.map((msg, idx) => {
|
||||
if (foldedNarrationIndices.has(idx)) return null;
|
||||
const suppressToolCards = userRunCards.some((card) =>
|
||||
idx > card.triggerIndex && idx <= card.segmentEnd,
|
||||
);
|
||||
const suppressToolCards = runSegmentMessageIndices.has(idx);
|
||||
const isToolOnlyAssistant = normalizeMessageRole(msg.role) === 'assistant'
|
||||
&& extractToolUse(msg).length > 0
|
||||
&& extractText(msg).trim().length === 0
|
||||
&& !extractThinking(msg);
|
||||
if (suppressToolCards && isToolOnlyAssistant && !(msg._attachedFiles?.length)) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={msg.id || `msg-${idx}`}
|
||||
@@ -848,6 +863,7 @@ export function Chat() {
|
||||
OR when there's streaming content without an active graph */}
|
||||
{shouldRenderStreaming && (streamingReplyText != null || !hasActiveExecutionGraph) && (
|
||||
<ChatMessage
|
||||
suppressToolCards={hasActiveExecutionGraph || runSegmentMessageIndices.size > 0}
|
||||
message={(() => {
|
||||
const base = streamMsg
|
||||
? {
|
||||
|
||||
@@ -364,6 +364,11 @@ export function extractImages(
|
||||
return images;
|
||||
}
|
||||
|
||||
/** Normalize Gateway/OpenClaw message roles for comparisons. */
|
||||
export function normalizeMessageRole(role: unknown): string {
|
||||
return typeof role === 'string' ? role.trim().toLowerCase() : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract tool use blocks from a message.
|
||||
* Handles both Anthropic format (tool_use in content array) and
|
||||
|
||||
@@ -43,6 +43,67 @@ export function findReplyMessageIndex(messages: RawMessage[], hasStreamingReply:
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Message indices that belong to an agent run segment (strictly after a run
|
||||
* trigger user message up to the next real user message). Used to fold tool
|
||||
* cards and process attachments into ExecutionGraphCard without depending on
|
||||
* whether a graph card was successfully materialized (e.g. after history
|
||||
* reload when the step cache is empty).
|
||||
*/
|
||||
export function buildRunSegmentMessageIndices(
|
||||
messages: RawMessage[],
|
||||
nextUserMessageIndexes: number[],
|
||||
isRunTrigger: (message: RawMessage, index: number) => boolean,
|
||||
): Set<number> {
|
||||
const indices = new Set<number>();
|
||||
messages.forEach((message, triggerIndex) => {
|
||||
if (!isRunTrigger(message, triggerIndex)) return;
|
||||
const nextUserIndex = nextUserMessageIndexes[triggerIndex];
|
||||
const segmentEnd = nextUserIndex === -1 ? messages.length - 1 : nextUserIndex - 1;
|
||||
for (let idx = triggerIndex + 1; idx <= segmentEnd; idx += 1) {
|
||||
indices.add(idx);
|
||||
}
|
||||
});
|
||||
|
||||
// History pagination loads a suffix of the transcript. When the triggering
|
||||
// user turn fell off the window, assistant tool steps remain at the top of
|
||||
// `messages[]` without a preceding user row — fold them into the first run.
|
||||
let firstTriggerIndex = -1;
|
||||
for (let idx = 0; idx < messages.length; idx += 1) {
|
||||
if (isRunTrigger(messages[idx], idx)) {
|
||||
firstTriggerIndex = idx;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (firstTriggerIndex > 0) {
|
||||
for (let idx = 0; idx < firstTriggerIndex; idx += 1) {
|
||||
if (messages[idx]?.role === 'assistant') {
|
||||
indices.add(idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return indices;
|
||||
}
|
||||
|
||||
/**
|
||||
* Slice messages for a user-triggered run, including leading assistant orphans
|
||||
* that belong to the same run but were separated by paginated history.
|
||||
*/
|
||||
export function getRunSegmentMessages(
|
||||
messages: RawMessage[],
|
||||
triggerIndex: number,
|
||||
nextUserIndex: number,
|
||||
isRunTrigger: (message: RawMessage, index: number) => boolean,
|
||||
): RawMessage[] {
|
||||
const segmentEnd = nextUserIndex === -1 ? messages.length : nextUserIndex;
|
||||
const core = messages.slice(triggerIndex + 1, segmentEnd);
|
||||
const hasEarlierUser = messages.some((message, index) => index < triggerIndex && isRunTrigger(message, index));
|
||||
if (hasEarlierUser || triggerIndex === 0) return core;
|
||||
const orphans = messages.slice(0, triggerIndex).filter((message) => message.role === 'assistant');
|
||||
return [...orphans, ...core];
|
||||
}
|
||||
|
||||
interface DeriveTaskStepsInput {
|
||||
messages: RawMessage[];
|
||||
streamingMessage: unknown | null;
|
||||
|
||||
101
tests/unit/chat-leading-orphan-tools.test.tsx
Normal file
101
tests/unit/chat-leading-orphan-tools.test.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
const { gatewayState, agentsState } = vi.hoisted(() => ({
|
||||
gatewayState: { status: { state: 'running', port: 18789 } },
|
||||
agentsState: {
|
||||
agents: [{ id: 'main', name: 'main' }] as Array<Record<string, unknown>>,
|
||||
fetchAgents: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/gateway', () => ({
|
||||
useGatewayStore: (selector: (state: typeof gatewayState) => unknown) => selector(gatewayState),
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/agents', () => ({
|
||||
useAgentsStore: (selector: (state: typeof agentsState) => unknown) => selector(agentsState),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/host-api', () => ({
|
||||
hostApiFetch: vi.fn().mockResolvedValue({ success: true, messages: [] }),
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, params?: Record<string, unknown> | string) => {
|
||||
if (typeof params === 'string') return params;
|
||||
if (key === 'executionGraph.collapsedSummary') {
|
||||
return `collapsed ${String(params?.toolCount ?? '')} ${String(params?.processCount ?? '')}`.trim();
|
||||
}
|
||||
if (key === 'executionGraph.agentRun') return 'Main execution';
|
||||
if (key === 'executionGraph.title') return 'Execution Graph';
|
||||
if (key === 'executionGraph.collapseAction') return 'Collapse';
|
||||
if (key === 'executionGraph.thinkingLabel') return 'Thinking';
|
||||
if (key.startsWith('taskPanel.stepStatus.')) return key.split('.').at(-1) ?? key;
|
||||
return key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/use-stick-to-bottom-instant', () => ({
|
||||
useStickToBottomInstant: vi.fn(() => ({
|
||||
contentRef: { current: null },
|
||||
scrollRef: { current: null },
|
||||
scrollToBottom: vi.fn(),
|
||||
isAtBottom: true,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/use-min-loading', () => ({
|
||||
useMinLoading: () => false,
|
||||
}));
|
||||
|
||||
vi.mock('@/pages/Chat/ChatToolbar', () => ({ ChatToolbar: () => null }));
|
||||
vi.mock('@/pages/Chat/ChatInput', () => ({ ChatInput: () => null }));
|
||||
|
||||
describe('Chat leading orphan tool folding', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('hides paginated-prefix tool rows and folds them into the first user execution graph', async () => {
|
||||
const { useChatStore } = await import('@/stores/chat');
|
||||
useChatStore.setState({
|
||||
messages: [
|
||||
{ role: 'assistant', id: 'orphan-exec', content: [{ type: 'toolCall', id: 'e1', name: 'exec', input: {} }] },
|
||||
{ role: 'assistant', id: 'orphan-image', content: [{ type: 'toolCall', id: 'i1', name: 'image', input: {} }] },
|
||||
{ role: 'user', id: 'user-1', content: 'Continue the task' },
|
||||
{ role: 'assistant', id: 'reply', content: [{ type: 'text', text: 'Finished.' }] },
|
||||
],
|
||||
loading: false,
|
||||
error: null,
|
||||
runError: null,
|
||||
sending: false,
|
||||
activeRunId: null,
|
||||
streamingText: '',
|
||||
streamingMessage: null,
|
||||
streamingTools: [],
|
||||
pendingFinal: false,
|
||||
lastUserMessageAt: Date.now(),
|
||||
pendingToolImages: [],
|
||||
sessions: [{ key: 'agent:main:main' }],
|
||||
currentSessionKey: 'agent:main:main',
|
||||
currentAgentId: 'main',
|
||||
sessionLabels: {},
|
||||
sessionLastActivity: {},
|
||||
thinkingLevel: null,
|
||||
});
|
||||
|
||||
const { Chat } = await import('@/pages/Chat/index');
|
||||
render(<Chat />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('chat-execution-graph')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId('chat-message-0')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('chat-message-1')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Finished.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -102,6 +102,60 @@ describe('ChatMessage attachment dedupe', () => {
|
||||
expect(screen.getByAltText('desktop_screenshot.jpg')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps html artifacts visible when process attachments are suppressed', async () => {
|
||||
const message: RawMessage = {
|
||||
role: 'assistant',
|
||||
content: '已生成 /workspace/demo.html',
|
||||
};
|
||||
|
||||
render(<ChatMessage message={message} suppressProcessAttachments />);
|
||||
|
||||
expect(await screen.findByText('demo.html')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides generic tool-result markdown attachments when process attachments are suppressed', () => {
|
||||
const message: RawMessage = {
|
||||
role: 'assistant',
|
||||
content: 'Coder has finished the analysis, here are the conclusions.',
|
||||
_attachedFiles: [
|
||||
{
|
||||
fileName: 'CHECKLIST.md',
|
||||
mimeType: 'text/markdown',
|
||||
fileSize: 433,
|
||||
preview: null,
|
||||
filePath: '/Users/bytedance/.openclaw/workspace/CHECKLIST.md',
|
||||
source: 'tool-result',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
render(<ChatMessage message={message} suppressProcessAttachments />);
|
||||
|
||||
expect(screen.getByText('Coder has finished the analysis, here are the conclusions.')).toBeInTheDocument();
|
||||
expect(screen.queryByText('CHECKLIST.md')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps attached SKILL.md visible when process attachments are suppressed', () => {
|
||||
const message: RawMessage = {
|
||||
role: 'assistant',
|
||||
content: '这是文件。',
|
||||
_attachedFiles: [
|
||||
{
|
||||
fileName: 'SKILL.md',
|
||||
mimeType: 'text/markdown',
|
||||
fileSize: 128,
|
||||
preview: null,
|
||||
filePath: '/workspace/skills/open-xueqiu/SKILL.md',
|
||||
source: 'tool-result',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
render(<ChatMessage message={message} suppressProcessAttachments />);
|
||||
|
||||
expect(screen.getByText('SKILL.md')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps pdf and spreadsheet artifacts visible when process attachments are suppressed', () => {
|
||||
const message: RawMessage = {
|
||||
role: 'assistant',
|
||||
|
||||
125
tests/unit/chat-tool-card-suppression.test.tsx
Normal file
125
tests/unit/chat-tool-card-suppression.test.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
const { gatewayState, agentsState } = vi.hoisted(() => ({
|
||||
gatewayState: {
|
||||
status: { state: 'running', port: 18789 },
|
||||
},
|
||||
agentsState: {
|
||||
agents: [{ id: 'main', name: 'main' }] as Array<Record<string, unknown>>,
|
||||
fetchAgents: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/gateway', () => ({
|
||||
useGatewayStore: (selector: (state: typeof gatewayState) => unknown) => selector(gatewayState),
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/agents', () => ({
|
||||
useAgentsStore: (selector: (state: typeof agentsState) => unknown) => selector(agentsState),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/host-api', () => ({
|
||||
hostApiFetch: vi.fn().mockResolvedValue({ success: true, messages: [] }),
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, params?: Record<string, unknown> | string) => {
|
||||
if (typeof params === 'string') return params;
|
||||
if (key === 'executionGraph.collapsedSummary') {
|
||||
return `collapsed ${String(params?.toolCount ?? '')} ${String(params?.processCount ?? '')}`.trim();
|
||||
}
|
||||
if (key === 'executionGraph.agentRun') return 'Main execution';
|
||||
if (key === 'executionGraph.title') return 'Execution Graph';
|
||||
if (key === 'executionGraph.collapseAction') return 'Collapse';
|
||||
if (key === 'executionGraph.thinkingLabel') return 'Thinking';
|
||||
if (key.startsWith('taskPanel.stepStatus.')) return key.split('.').at(-1) ?? key;
|
||||
return key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/use-stick-to-bottom-instant', () => ({
|
||||
useStickToBottomInstant: vi.fn(() => ({
|
||||
contentRef: { current: null },
|
||||
scrollRef: { current: null },
|
||||
scrollToBottom: vi.fn(),
|
||||
isAtBottom: true,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/use-min-loading', () => ({
|
||||
useMinLoading: () => false,
|
||||
}));
|
||||
|
||||
vi.mock('@/pages/Chat/ChatToolbar', () => ({
|
||||
ChatToolbar: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('@/pages/Chat/ChatInput', () => ({
|
||||
ChatInput: () => null,
|
||||
}));
|
||||
|
||||
describe('Chat tool card suppression', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('does not render standalone tool cards for messages inside a user run segment', async () => {
|
||||
const { useChatStore } = await import('@/stores/chat');
|
||||
useChatStore.setState({
|
||||
messages: [
|
||||
{ role: 'user', content: 'Generate assets' },
|
||||
{
|
||||
role: 'assistant',
|
||||
id: 'tool-exec',
|
||||
content: [{ type: 'tool_use', id: 'exec-1', name: 'exec', input: { command: 'ls' } }],
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
id: 'tool-image',
|
||||
content: [{ type: 'tool_use', id: 'image-1', name: 'image', input: { path: '/tmp/a.png' } }],
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
id: 'tool-process',
|
||||
content: [{ type: 'tool_use', id: 'process-1', name: 'process', input: { action: 'list' } }],
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
id: 'reply',
|
||||
content: [{ type: 'text', text: 'All done.' }],
|
||||
},
|
||||
],
|
||||
loading: false,
|
||||
error: null,
|
||||
runError: null,
|
||||
sending: false,
|
||||
activeRunId: null,
|
||||
streamingText: '',
|
||||
streamingMessage: null,
|
||||
streamingTools: [],
|
||||
pendingFinal: false,
|
||||
lastUserMessageAt: Date.now(),
|
||||
pendingToolImages: [],
|
||||
sessions: [{ key: 'agent:main:main' }],
|
||||
currentSessionKey: 'agent:main:main',
|
||||
currentAgentId: 'main',
|
||||
sessionLabels: {},
|
||||
sessionLastActivity: {},
|
||||
thinkingLevel: null,
|
||||
});
|
||||
|
||||
const { Chat } = await import('@/pages/Chat/index');
|
||||
render(<Chat />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('chat-execution-graph')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByText('exec')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('All done.')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('chat-execution-graph')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
49
tests/unit/run-segment-indices.test.ts
Normal file
49
tests/unit/run-segment-indices.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildRunSegmentMessageIndices } from '@/pages/Chat/task-visualization';
|
||||
import type { RawMessage } from '@/stores/chat';
|
||||
|
||||
describe('buildRunSegmentMessageIndices', () => {
|
||||
it('marks assistant messages between real user turns', () => {
|
||||
const messages: RawMessage[] = [
|
||||
{ role: 'user', content: 'first question' },
|
||||
{ role: 'assistant', content: [{ type: 'tool_use', id: 't1', name: 'exec', input: {} }] },
|
||||
{ role: 'assistant', content: [{ type: 'tool_use', id: 't2', name: 'image', input: {} }] },
|
||||
{ role: 'assistant', content: [{ type: 'text', text: 'done' }] },
|
||||
{ role: 'user', content: 'follow up' },
|
||||
{ role: 'assistant', content: [{ type: 'text', text: 'ok' }] },
|
||||
];
|
||||
const nextUserMessageIndexes = [4, -1, -1, -1, -1, -1];
|
||||
const indices = buildRunSegmentMessageIndices(
|
||||
messages,
|
||||
nextUserMessageIndexes,
|
||||
(message) => message.role === 'user',
|
||||
);
|
||||
|
||||
expect(indices.has(1)).toBe(true);
|
||||
expect(indices.has(2)).toBe(true);
|
||||
expect(indices.has(3)).toBe(true);
|
||||
expect(indices.has(5)).toBe(true);
|
||||
expect(indices.has(0)).toBe(false);
|
||||
expect(indices.has(4)).toBe(false);
|
||||
});
|
||||
|
||||
it('folds leading assistant orphans before the first user in a paginated suffix', () => {
|
||||
const messages: RawMessage[] = [
|
||||
{ role: 'assistant', content: [{ type: 'toolCall', id: 't1', name: 'exec', input: {} }] },
|
||||
{ role: 'assistant', content: [{ type: 'toolCall', id: 't2', name: 'image', input: {} }] },
|
||||
{ role: 'user', content: 'question fell off the earlier page' },
|
||||
{ role: 'assistant', content: [{ type: 'text', text: 'done' }] },
|
||||
];
|
||||
const nextUserMessageIndexes = [-1, -1, -1, -1];
|
||||
const indices = buildRunSegmentMessageIndices(
|
||||
messages,
|
||||
nextUserMessageIndexes,
|
||||
(message) => message.role === 'user',
|
||||
);
|
||||
|
||||
expect(indices.has(0)).toBe(true);
|
||||
expect(indices.has(1)).toBe(true);
|
||||
expect(indices.has(3)).toBe(true);
|
||||
expect(indices.has(2)).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user