fix(chat): group run output and hide empty messages
Build / build (push) Canceled after 0s
Playwright / e2e (push) Canceled after 0s

This commit is contained in:
2026-11-05 11:06:36 +08:00
parent 586776998b
commit 276e2389d6
8 changed files with 155 additions and 3 deletions
@@ -0,0 +1,12 @@
---
date: 2026-11-05
pr: pending
feature: Empty assistant bubbles during tool activity
impact: Whitespace-only assistant messages are hidden, and assistant text within a turn is displayed together with a completion marker. Stream data and tool activity are unchanged.
---
Tool cycles can leave assistant messages containing only whitespace. The chat
list excludes these empty bubbles while retaining the underlying streamed data,
so later text can still be appended to the same message.
Assistant messages within a turn are combined for display; tool rows remain
available below the combined text, and a separator marks the end of the turn.
@@ -19,6 +19,7 @@ import { ref, computed, nextTick, onBeforeUnmount, onMounted, watch } from "vue"
import { useI18n } from "vue-i18n";
import VirtualMessageList from "./VirtualMessageList.vue";
import MessageItem from "./MessageItem.vue";
import { groupRunMessages } from "./groupRunMessages";
import { LIVE_CHAT_MAX_LOADED_MESSAGES, useChatStore, type Message } from "@/stores/hermes/chat";
import thinkingImage from "@/assets/thinking.gif";
import { useToolTraceVisibility } from "@/composables/useToolTraceVisibility";
@@ -100,10 +101,15 @@ const emptyState = computed(() => ({
const displayMessages = computed(() => {
const currentToolIds = new Set(currentToolCalls.value.map((tool) => tool.id));
return chatStore.messages.filter((m) => {
const messages = chatStore.messages.filter((m) => {
if (m.role === "tool") {
return toolTraceVisible.value && !!m.toolName && !(chatStore.isRunActive && currentToolIds.has(m.id));
}
// Tool cycles can produce whitespace-only assistant deltas. Keep them in
// the store for subsequent deltas, but don't render an empty bubble.
if (m.role === "assistant" && !m.content?.trim() && !m.reasoning?.trim() && !m.attachments?.length) {
return false;
}
if (
m.role === "assistant" &&
m.isStreaming &&
@@ -115,6 +121,7 @@ const displayMessages = computed(() => {
}
return true;
});
return groupRunMessages(messages, chatStore.isRunActive || !!chatStore.abortState);
});
function forkDividerId(sessionId: string): string {
@@ -484,7 +491,8 @@ defineExpose({
</div>
</template>
<template #item="{ message: msg }">
<div v-if="msg.systemType === 'fork-divider' && forkLineage" class="fork-divider" role="separator">
<div v-if="msg.systemType === 'run-end'" class="run-end" role="separator">{{ t('chat.runOutputEnded') }}</div>
<div v-else-if="msg.systemType === 'fork-divider' && forkLineage" class="fork-divider" role="separator">
<div class="fork-divider-line" aria-hidden="true"></div>
<div class="fork-divider-pill">
<span class="fork-divider-icon" aria-hidden="true">
@@ -867,6 +875,13 @@ defineExpose({
<style scoped lang="scss">
@use "@/styles/variables" as *;
.run-end {
margin: 20px auto;
text-align: center;
color: var(--color-text-3);
font-size: 12px;
}
.message-list-shell {
flex: 1;
min-height: 0;
@@ -0,0 +1,45 @@
import type { Message } from '@/stores/hermes/chat'
// Group only within an uninterrupted turn. Tool rows remain available after
// the combined answer; user, command and system messages are hard boundaries.
export function groupRunMessages(messages: Message[], runActive: boolean): Message[] {
const result: Message[] = []
let assistants: Message[] = []
let tools: Message[] = []
let marker: string | null = null
function flush(completed: boolean) {
if (assistants.length) {
const first = assistants[0]
result.push({
...first,
id: assistants[assistants.length - 1].id,
content: assistants.map(m => m.content).filter(text => text.trim()).join('\n\n'),
reasoning: assistants.map(m => m.reasoning).filter(Boolean).join('\n\n') || undefined,
attachments: assistants.flatMap(m => m.attachments || []),
isStreaming: assistants.some(m => m.isStreaming),
})
}
result.push(...tools)
if (completed && assistants.length) {
result.push({ id: `run-end-${assistants[0].id}`, role: 'system', content: '', timestamp: assistants[assistants.length - 1].timestamp, systemType: 'run-end' })
}
assistants = []
tools = []
marker = null
}
for (const message of messages) {
if (message.role !== 'assistant' && message.role !== 'tool') {
flush(true)
result.push(message)
continue
}
if (marker && message.runMarker && marker !== message.runMarker) flush(true)
marker ||= message.runMarker || null
if (message.role === 'assistant') assistants.push(message)
else tools.push(message)
}
flush(!runActive)
return result
}
+1
View File
@@ -459,6 +459,7 @@ export default {
// Chat
chat: {
runOutputEnded: 'Run output ended',
messageLoadFailed: 'Could not load this conversation. Please retry.',
contextRemaining: 'remaining',
contextClickToEdit: 'Click to edit context length',
+1
View File
@@ -459,6 +459,7 @@ export default {
// 对话
chat: {
runOutputEnded: '本轮输出结束',
messageLoadFailed: '会话加载失败,请重试。',
contextRemaining: '剩余',
contextClickToEdit: '点击编辑上下文长度',
+1 -1
View File
@@ -63,7 +63,7 @@ export interface Message {
// 不含 <think> 包裹标签;内容自身可以为多段纯文本。
reasoning?: string
queued?: boolean
systemType?: 'command' | 'error' | 'fork-divider'
systemType?: 'command' | 'error' | 'fork-divider' | 'run-end'
commandAction?: string
commandData?: Record<string, unknown>
finishReason?: string | null
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import { groupRunMessages } from '../../packages/client/src/components/hermes/chat/groupRunMessages'
import type { Message } from '../../packages/client/src/stores/hermes/chat'
const message = (id: string, role: Message['role'], content = ''): Message => ({ id, role, content, timestamp: 1 })
describe('groupRunMessages', () => {
it('groups text across tool calls and marks completion only when the run ends', () => {
const input = [message('u', 'user', 'question'), message('a', 'assistant', 'first'), message('t', 'tool'), message('b', 'assistant', 'second')]
const active = groupRunMessages(input, true)
expect(active.map(m => m.role)).toEqual(['user', 'assistant', 'tool'])
expect(active[1]).toMatchObject({ id: 'b', content: 'first\n\nsecond' })
const finished = groupRunMessages(input, false)
expect(finished.at(-1)?.systemType).toBe('run-end')
})
it('keeps separate user turns and explicit run markers separate', () => {
const input = [message('u', 'user'), { ...message('a', 'assistant', 'one'), runMarker: '1' }, { ...message('b', 'assistant', 'two'), runMarker: '2' }, message('u2', 'user'), message('c', 'assistant', 'three')]
expect(groupRunMessages(input, false).filter(m => m.role === 'assistant').map(m => m.content)).toEqual(['one', 'two', 'three'])
expect(groupRunMessages(input, false).filter(m => m.systemType === 'run-end')).toHaveLength(3)
})
})
+56
View File
@@ -82,6 +82,62 @@ test('streams a run and renders markdown, code, math and mermaid without leaking
expect(api.unexpectedRequests).toEqual([])
})
test('does not render empty assistant bubbles between tool calls', async ({ page }) => {
await authenticate(page, TEST_ACCESS_KEY, 'research')
await mockHermesApi(page)
await mockChatSocket(page)
await page.goto('/#/hermes/chat')
await sendChatMessage(page, 'run tools')
const { sessionId } = await waitForRunSessionId(page)
await page.evaluate((sid) => {
const socket = (window as any).__PW_CHAT_SOCKET__.latest
const common = { session_id: sid, run_id: 'blank-bubbles' }
socket.__trigger('run.started', { ...common, event: 'run.started' })
for (let i = 0; i < 3; i++) {
socket.__trigger('message.delta', { ...common, event: 'message.delta', delta: '\n ' })
socket.__trigger('tool.started', { ...common, event: 'tool.started', tool_call_id: `tool-${i}`, tool: 'terminal' })
socket.__trigger('tool.completed', { ...common, event: 'tool.completed', tool_call_id: `tool-${i}`, output: 'done' })
}
}, sessionId)
await expect(page.locator('.message.assistant')).toHaveCount(0)
await page.evaluate((sid) => {
const socket = (window as any).__PW_CHAT_SOCKET__.latest
socket.__trigger('message.delta', { event: 'message.delta', session_id: sid, run_id: 'blank-bubbles', delta: '\n' })
socket.__trigger('message.delta', { event: 'message.delta', session_id: sid, run_id: 'blank-bubbles', delta: 'Visible answer' })
}, sessionId)
await expect(page.locator('.message.assistant')).toHaveCount(1)
await expect(page.locator('.message.assistant')).toContainText('Visible answer')
})
test('groups assistant output across tool calls until the run ends', async ({ page }) => {
await authenticate(page, TEST_ACCESS_KEY, 'research')
await mockHermesApi(page)
await mockChatSocket(page)
await page.goto('/#/hermes/chat')
await sendChatMessage(page, 'inspect')
const { sessionId } = await waitForRunSessionId(page)
await page.evaluate((sid) => {
const socket = (window as any).__PW_CHAT_SOCKET__.latest
const common = { session_id: sid, run_id: 'grouped-run' }
socket.__trigger('run.started', { ...common, event: 'run.started' })
socket.__trigger('message.delta', { ...common, event: 'message.delta', delta: 'First update' })
socket.__trigger('tool.started', { ...common, event: 'tool.started', tool_call_id: 't1', tool: 'terminal' })
socket.__trigger('tool.completed', { ...common, event: 'tool.completed', tool_call_id: 't1', output: 'done' })
socket.__trigger('message.delta', { ...common, event: 'message.delta', delta: 'Second update' })
}, sessionId)
const assistant = page.locator('.message.assistant')
await expect(assistant).toHaveCount(1)
await expect(assistant).toContainText('First update')
await expect(assistant).toContainText('Second update')
await expect(page.locator('.run-end')).toHaveCount(0)
await page.evaluate((sid) => {
(window as any).__PW_CHAT_SOCKET__.latest.__trigger('run.completed', { event: 'run.completed', session_id: sid, run_id: 'grouped-run' })
}, sessionId)
await expect(page.locator('.run-end')).toHaveText('Run output ended')
})
test('opens the session model dialog and the session search from the chat header', async ({ page }) => {
await authenticate(page, TEST_ACCESS_KEY, 'research')
await mockHermesApi(page, { sessions: [sampleSession] })