Merge pull request #61147 from NousResearch/bb/desktop-tool-window-merge

feat(desktop): group tool calls across text-less assistant messages
This commit is contained in:
brooklyn! 2026-07-08 17:10:24 -05:00 committed by GitHub
commit fac85518fc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 67 additions and 2 deletions

View file

@ -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<ChatMessage, ThreadMessage>())
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) {

View file

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