From 0fb460efca5ad05c2f6dbfba7c31ad44b5dd82ea Mon Sep 17 00:00:00 2001 From: paisley <8197966+su8su@users.noreply.github.com> Date: Wed, 20 May 2026 10:48:45 +0800 Subject: [PATCH] fix(chat): fold tool steps into execution graph and remove duplicate tool pills (#1048) --- src/pages/Chat/ChatMessage.tsx | 76 +++++------ src/pages/Chat/index.tsx | 34 +++-- src/pages/Chat/message-utils.ts | 5 + src/pages/Chat/task-visualization.ts | 61 +++++++++ tests/unit/chat-leading-orphan-tools.test.tsx | 101 ++++++++++++++ tests/unit/chat-message.test.tsx | 54 ++++++++ .../unit/chat-tool-card-suppression.test.tsx | 125 ++++++++++++++++++ tests/unit/run-segment-indices.test.ts | 49 +++++++ 8 files changed, 456 insertions(+), 49 deletions(-) create mode 100644 tests/unit/chat-leading-orphan-tools.test.tsx create mode 100644 tests/unit/chat-tool-card-suppression.test.tsx create mode 100644 tests/unit/run-segment-indices.test.ts diff --git a/src/pages/Chat/ChatMessage.tsx b/src/pages/Chat/ChatMessage.tsx index 6c389a9..b11c634 100644 --- a/src/pages/Chat/ChatMessage.tsx +++ b/src/pages/Chat/ChatMessage.tsx @@ -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({ )} - {/* Tool use cards */} - {visibleTools.length > 0 && ( -
- {visibleTools.map((tool, i) => ( - - ))} -
- )} - {/* 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 ( -
- - {expanded && input != null && ( -
-          {typeof input === 'string' ? input : JSON.stringify(input, null, 2) as string}
-        
- )} -
- ); -} diff --git a/src/pages/Chat/index.tsx b/src/pages/Chat/index.tsx index 3d2ab34..62501e7 100644 --- a/src/pages/Chat/index.tsx +++ b/src/pages/Chat/index.tsx @@ -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 => 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(); @@ -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 (
0} message={(() => { const base = streamMsg ? { diff --git a/src/pages/Chat/message-utils.ts b/src/pages/Chat/message-utils.ts index 0abf98f..fc3e497 100644 --- a/src/pages/Chat/message-utils.ts +++ b/src/pages/Chat/message-utils.ts @@ -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 diff --git a/src/pages/Chat/task-visualization.ts b/src/pages/Chat/task-visualization.ts index 0f1aaae..a621058 100644 --- a/src/pages/Chat/task-visualization.ts +++ b/src/pages/Chat/task-visualization.ts @@ -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 { + const indices = new Set(); + 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; diff --git a/tests/unit/chat-leading-orphan-tools.test.tsx b/tests/unit/chat-leading-orphan-tools.test.tsx new file mode 100644 index 0000000..83c5fcb --- /dev/null +++ b/tests/unit/chat-leading-orphan-tools.test.tsx @@ -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>, + 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) => { + 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(); + + 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(); + }); +}); diff --git a/tests/unit/chat-message.test.tsx b/tests/unit/chat-message.test.tsx index 7f13f1a..2cc1db5 100644 --- a/tests/unit/chat-message.test.tsx +++ b/tests/unit/chat-message.test.tsx @@ -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(); + + 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(); + + 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(); + + expect(screen.getByText('SKILL.md')).toBeInTheDocument(); + }); + it('keeps pdf and spreadsheet artifacts visible when process attachments are suppressed', () => { const message: RawMessage = { role: 'assistant', diff --git a/tests/unit/chat-tool-card-suppression.test.tsx b/tests/unit/chat-tool-card-suppression.test.tsx new file mode 100644 index 0000000..fd4f4fa --- /dev/null +++ b/tests/unit/chat-tool-card-suppression.test.tsx @@ -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>, + 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) => { + 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(); + + 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(); + }); +}); diff --git a/tests/unit/run-segment-indices.test.ts b/tests/unit/run-segment-indices.test.ts new file mode 100644 index 0000000..7ec6470 --- /dev/null +++ b/tests/unit/run-segment-indices.test.ts @@ -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); + }); +});