From 6d6521025297493c5811d448689e5e9c0c75b7c2 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 8 Jul 2026 17:08:21 -0500 Subject: [PATCH] feat(desktop): group tool calls across text-less assistant messages The model often emits a follow-up batch of tool calls as its own assistant message with no prose or reasoning. On screen those rows look like one continuous run, but assistant-ui only groups tool calls within a single message, so the auto-scrolling tool window never triggered on them (e.g. two batches of two searches read as 2 + 2, never reaching the threshold). Coalesce each settled tool-only assistant message into the preceding assistant message in the render pipeline so its calls join that message's tool group. Render-only (never touches the $messages store) and settle-only (pending messages are skipped) so a live turn is never merged/un-merged mid-stream; merged results are cached by source identity so a stable turn yields stable objects with no re-render churn. --- apps/desktop/src/app/chat/index.tsx | 11 +++++- apps/desktop/src/lib/chat-runtime.ts | 58 ++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index 74eb8df8661..bece9b49152 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -19,7 +19,13 @@ import { ErrorState } from '@/components/ui/error-state' import { getGlobalModelOptions, type HermesGateway } from '@/hermes' import { useI18n } from '@/i18n' import type { ChatMessage } from '@/lib/chat-messages' -import { quickModelOptions, sessionTitle, toRuntimeMessage } from '@/lib/chat-runtime' +import { + coalesceToolOnlyAssistants, + createToolMergeCache, + quickModelOptions, + sessionTitle, + toRuntimeMessage +} from '@/lib/chat-runtime' import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-store-runtime' import { cn } from '@/lib/utils' import type { ComposerAttachment } from '@/store/composer' @@ -201,6 +207,7 @@ function ChatRuntimeBoundary({ const storeMessages = useStore($messages) const messages = suppressMessages ? NO_MESSAGES : storeMessages const runtimeMessageCacheRef = useRef(new WeakMap()) + const toolMergeCacheRef = useRef(createToolMergeCache()) const runtimeMessageRepository = useMemo(() => { const items: { message: ThreadMessage; parentId: string | null }[] = [] @@ -208,7 +215,7 @@ function ChatRuntimeBoundary({ let visibleParentId: string | null = null let headId: string | null = null - for (const message of messages) { + for (const message of coalesceToolOnlyAssistants(messages, toolMergeCacheRef.current)) { let parentId = visibleParentId if (message.role === 'assistant' && message.branchGroupId) { diff --git a/apps/desktop/src/lib/chat-runtime.ts b/apps/desktop/src/lib/chat-runtime.ts index 06d8e4c3265..d315617d2ac 100644 --- a/apps/desktop/src/lib/chat-runtime.ts +++ b/apps/desktop/src/lib/chat-runtime.ts @@ -383,3 +383,61 @@ export function toRuntimeMessage(message: ChatMessage): ThreadMessage { } } as ThreadMessage } + +export type ToolMergeCache = WeakMap< + ChatMessage, + { merged: ChatMessage; parts: ChatMessagePart[]; prev: ChatMessage; prevParts: ChatMessagePart[] } +> + +export function createToolMergeCache(): ToolMergeCache { + return new WeakMap() +} + +// A settled assistant message with only tool calls — no prose, no reasoning. +// The model routinely emits a follow-up batch of calls as its own text-less +// message; on screen it looks like one continuous run, but assistant-ui can't +// group tool calls across a message boundary. +function isToolOnlyAssistant(message: ChatMessage): boolean { + return ( + message.role === 'assistant' && + !message.pending && + !message.error && + !message.hidden && + message.parts.length > 0 && + message.parts.every(part => part.type === 'tool-call') + ) +} + +/** + * Fold each settled tool-only assistant message into the preceding assistant + * message so its calls join that message's tool group (and can collapse into + * the auto-scrolling window). Render-only — never mutates the `$messages` store + * — and settle-only: pending messages are left alone, so a live turn is never + * merged/un-merged mid-stream. `cache` keys merged results by source identity, + * so a stable turn yields stable merged objects (no re-render churn). + */ +export function coalesceToolOnlyAssistants(messages: ChatMessage[], cache: ToolMergeCache): ChatMessage[] { + const out: ChatMessage[] = [] + + for (const message of messages) { + const prev = out.at(-1) + + if (prev && prev.role === 'assistant' && !prev.pending && !prev.hidden && isToolOnlyAssistant(message)) { + const cached = cache.get(message) + + const merged = + cached && cached.prev === prev && cached.prevParts === prev.parts && cached.parts === message.parts + ? cached.merged + : { ...prev, parts: [...prev.parts, ...message.parts] } + + cache.set(message, { merged, parts: message.parts, prev, prevParts: prev.parts }) + out[out.length - 1] = merged + + continue + } + + out.push(message) + } + + return out +}