fix(chat): fold tool steps into execution graph and remove duplicate tool pills (#1048)

This commit is contained in:
paisley
2026-05-20 10:48:45 +08:00
committed by GitHub
parent d9f42930d7
commit 0fb460efca
8 changed files with 456 additions and 49 deletions

View File

@@ -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>
);
}

View File

@@ -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
? {

View File

@@ -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

View File

@@ -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;

View 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();
});
});

View File

@@ -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',

View 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();
});
});

View 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);
});
});