Skip to content

Commit b510c77

Browse files
authored
[fix] fold tool runs in history (#2697)
* fix: fold tool runs in history * docs: record history folding PR
1 parent 2b7911c commit b510c77

7 files changed

Lines changed: 171 additions & 104 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
date: 2026-08-23
3+
pr: 2697
4+
feature: History tool run folding
5+
impact: History sessions now preserve persisted run markers and group completed tool traces into the same collapsible run cards used by single chat.
6+
---

packages/client/src/components/hermes/chat/HistoryMessageList.vue

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,13 @@ import { ref, computed, nextTick, onBeforeUnmount, onMounted, watch } from "vue"
99
import { useI18n } from "vue-i18n";
1010
import VirtualMessageList from "./VirtualMessageList.vue";
1111
import MessageItem from "./MessageItem.vue";
12+
import ToolRunCard from "./ToolRunCard.vue";
1213
import { useChatStore } from "@/stores/hermes/chat";
1314
import { useToolTraceVisibility } from "@/composables/useToolTraceVisibility";
1415
import type { Session } from "@/stores/hermes/chat";
1516
import { messageScrollPositionKey, rememberMessageScrollPosition } from "./message-scroll-position";
1617
import { chatSessionAgentAvatar } from "@/utils/chat-agent-avatar";
18+
import { groupCompletedToolsByRun } from "./tool-run-grouping";
1719
1820
const props = withDefaults(defineProps<{
1921
session?: Session | null; // Optional: use this session instead of chatStore.activeSession
@@ -37,13 +39,13 @@ const activeSessionScrollKey = computed(() =>
3739
const listInstanceKey = computed(() => activeSessionScrollKey.value || "history-empty");
3840
3941
const displayMessages = computed(() =>
40-
(activeSession.value?.messages || []).filter((m) => {
42+
groupCompletedToolsByRun((activeSession.value?.messages || []).filter((m) => {
4143
// Tool messages without a name are internal use only and remain hidden.
4244
if (m.role === 'tool') return toolTraceVisible.value && !!m.toolName
4345
// Filter out messages with empty content.
4446
if (!m.content?.trim()) return false
4547
return true
46-
}),
48+
})),
4749
);
4850
4951
function isNearBottom(threshold = 200): boolean {
@@ -209,7 +211,13 @@ defineExpose({
209211
</div>
210212
</template>
211213
<template #item="{ message: msg }">
214+
<ToolRunCard
215+
v-if="msg.systemType === 'tool-run' && msg.toolRunId && msg.toolMessages"
216+
:run-id="msg.toolRunId"
217+
:tools="msg.toolMessages"
218+
/>
212219
<MessageItem
220+
v-else
213221
:message="msg"
214222
:assistant-agent="assistantAgent"
215223
:highlight="chatStore.focusMessageId === msg.id"

packages/client/src/components/hermes/chat/MessageList.vue

Lines changed: 1 addition & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { openSubagentStream, subagentIdFromToolCall } from "@/utils/hermes/subag
2424
import { messageScrollPositionKey, rememberMessageScrollPosition } from "./message-scroll-position";
2525
import { chatSessionAgentAvatar } from "@/utils/chat-agent-avatar";
2626
import { parseThinking } from "@/utils/thinking-parser";
27+
import { groupCompletedToolsByRun } from "./tool-run-grouping";
2728
2829
const props = withDefaults(defineProps<{
2930
approvalPortalToBody?: boolean
@@ -195,48 +196,6 @@ function hasRenderableAssistantContent(message: Message): boolean {
195196
);
196197
}
197198
198-
function groupCompletedToolsByRun(messages: Message[]): Message[] {
199-
const toolsByRun = new Map<string, Message[]>();
200-
for (const message of messages) {
201-
const runId = message.runMarker?.trim();
202-
if (message.role !== "tool" || message.toolStatus === "running" || !runId) continue;
203-
const tools = toolsByRun.get(runId) || [];
204-
tools.push(message);
205-
toolsByRun.set(runId, tools);
206-
}
207-
if (toolsByRun.size === 0) return messages;
208-
209-
const emittedRuns = new Set<string>();
210-
const grouped: Message[] = [];
211-
for (const message of messages) {
212-
const runId = message.role === "tool" && message.toolStatus !== "running"
213-
? message.runMarker?.trim()
214-
: undefined;
215-
if (!runId) {
216-
grouped.push(message);
217-
continue;
218-
}
219-
if (emittedRuns.has(runId)) continue;
220-
emittedRuns.add(runId);
221-
const tools = toolsByRun.get(runId);
222-
if (!tools?.length) {
223-
grouped.push(message);
224-
continue;
225-
}
226-
grouped.push({
227-
id: `tool-run:${runId}`,
228-
role: "system",
229-
content: "",
230-
timestamp: tools[0].timestamp,
231-
systemType: "tool-run",
232-
runMarker: runId,
233-
toolRunId: runId,
234-
toolMessages: tools,
235-
});
236-
}
237-
return grouped;
238-
}
239-
240199
const displayMessages = computed(() => {
241200
const messages = chatStore.messages;
242201
const currentToolIds = new Set(currentToolCalls.value.map((tool) => tool.id));
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import type { Message } from '@/stores/hermes/chat'
2+
3+
export function groupCompletedToolsByRun(messages: Message[]): Message[] {
4+
const toolsByRun = new Map<string, Message[]>()
5+
for (const message of messages) {
6+
const runId = message.runMarker?.trim()
7+
if (message.role !== 'tool' || message.toolStatus === 'running' || !runId) continue
8+
const tools = toolsByRun.get(runId) || []
9+
tools.push(message)
10+
toolsByRun.set(runId, tools)
11+
}
12+
if (toolsByRun.size === 0) return messages
13+
14+
const emittedRuns = new Set<string>()
15+
const grouped: Message[] = []
16+
for (const message of messages) {
17+
const runId = message.role === 'tool' && message.toolStatus !== 'running'
18+
? message.runMarker?.trim()
19+
: undefined
20+
if (!runId) {
21+
grouped.push(message)
22+
continue
23+
}
24+
if (emittedRuns.has(runId)) continue
25+
emittedRuns.add(runId)
26+
const tools = toolsByRun.get(runId)
27+
if (!tools?.length) {
28+
grouped.push(message)
29+
continue
30+
}
31+
grouped.push({
32+
id: `tool-run:${runId}`,
33+
role: 'system',
34+
content: '',
35+
timestamp: tools[0].timestamp,
36+
systemType: 'tool-run',
37+
runMarker: runId,
38+
toolRunId: runId,
39+
toolMessages: tools,
40+
})
41+
}
42+
return grouped
43+
}

packages/client/src/views/hermes/HistoryView.vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,7 @@ function mapHistoryMessages(messages: HermesMessage[]): Session['messages'] {
177177
timestamp: m.timestamp * 1000,
178178
reasoning: m.reasoning || undefined,
179179
systemType: displayRole === 'command' ? 'command' : undefined,
180+
runMarker: m.run_marker,
180181
}
181182
182183
if (m.role === 'tool' || isHistoryMoaToolDisplay(m)) {

tests/client/tool-trace-visibility.test.ts

Lines changed: 45 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
import { beforeEach, describe, expect, it, vi } from 'vitest'
33
import { mount } from '@vue/test-utils'
44
import { createPinia, setActivePinia } from 'pinia'
5-
import { defineComponent } from 'vue'
65

76
vi.mock('vue-i18n', () => ({
87
useI18n: () => ({
@@ -19,13 +18,18 @@ import HistoryMessageList from '@/components/hermes/chat/HistoryMessageList.vue'
1918
import { useChatStore, type Message, type Session } from '@/stores/hermes/chat'
2019
import { useToolTraceVisibility } from '@/composables/useToolTraceVisibility'
2120

22-
const MessageItemStub = defineComponent({
23-
name: 'MessageItem',
24-
props: {
25-
message: { type: Object, required: true },
26-
highlight: { type: Boolean, default: false },
27-
},
28-
template: '<div class="stub-message" :data-role="message.role" :data-id="message.id">{{ message.toolName || message.content }}</div>',
21+
vi.mock('@/components/hermes/chat/MessageItem.vue', async () => {
22+
const { defineComponent } = await import('vue')
23+
return {
24+
default: defineComponent({
25+
name: 'MessageItem',
26+
props: {
27+
message: { type: Object, required: true },
28+
highlight: { type: Boolean, default: false },
29+
},
30+
template: '<div class="stub-message" :data-role="message.role" :data-id="message.id">{{ message.toolName || message.content }}</div>',
31+
}),
32+
}
2933
})
3034

3135
function makeSession(messages: Message[]): Session {
@@ -61,14 +65,7 @@ describe('tool trace visibility', () => {
6165
])
6266
chatStore.abortState = { aborting: true, synced: false }
6367

64-
return mount(MessageList, {
65-
global: {
66-
stubs: {
67-
MessageItem: MessageItemStub,
68-
Transition: false,
69-
},
70-
},
71-
})
68+
return mount(MessageList)
7269
}
7370

7471
it('shows named transcript and live tool traces by default while keeping unnamed internal tools hidden', () => {
@@ -85,9 +82,6 @@ describe('tool trace visibility', () => {
8582
it('applies the same default-visible rule to history sessions', () => {
8683
const wrapper = mount(HistoryMessageList, {
8784
props: { session: makeSession(sampleMessages) },
88-
global: {
89-
stubs: { MessageItem: MessageItemStub },
90-
},
9185
})
9286

9387
expect(wrapper.findAll('.stub-message').map(node => node.attributes('data-id'))).toEqual([
@@ -97,15 +91,43 @@ describe('tool trace visibility', () => {
9791
])
9892
})
9993

94+
it('groups and folds completed history tools from the same run', async () => {
95+
const wrapper = mount(HistoryMessageList, {
96+
props: {
97+
session: makeSession([
98+
{ id: 'user-1', role: 'user', content: 'inspect repo', timestamp: 1 },
99+
{ id: 'tool-1', role: 'tool', content: '', timestamp: 2, toolName: 'read_file', toolResult: 'one', toolStatus: 'done', runMarker: 'history-run' },
100+
{ id: 'tool-2', role: 'tool', content: '', timestamp: 3, toolName: 'search', toolResult: 'two', toolStatus: 'done', runMarker: 'history-run' },
101+
{ id: 'assistant-1', role: 'assistant', content: 'done', timestamp: 4 },
102+
]),
103+
},
104+
})
105+
106+
const card = wrapper.get('.tool-run-card')
107+
const toggle = card.get('.tool-run-header')
108+
expect(toggle.attributes('aria-expanded')).toBe('false')
109+
expect(wrapper.find('[data-id="tool-1"]').exists()).toBe(false)
110+
expect(wrapper.find('[data-id="tool-2"]').exists()).toBe(false)
111+
112+
await toggle.trigger('click')
113+
expect(toggle.attributes('aria-expanded')).toBe('true')
114+
expect(wrapper.find('[data-id="tool-1"]').exists()).toBe(true)
115+
expect(wrapper.find('[data-id="tool-2"]').exists()).toBe(true)
116+
117+
await toggle.trigger('click')
118+
expect(toggle.attributes('aria-expanded')).toBe('false')
119+
await vi.waitFor(() => {
120+
expect(wrapper.find('[data-id="tool-1"]').exists()).toBe(false)
121+
expect(wrapper.find('[data-id="tool-2"]').exists()).toBe(false)
122+
})
123+
})
124+
100125
it('does not fall back to the live chat session while history session data is loading', () => {
101126
const chatStore = useChatStore()
102127
chatStore.activeSessionId = 'session-1'
103128
chatStore.activeSession = makeSession(sampleMessages)
104129

105130
const wrapper = mount(HistoryMessageList, {
106-
global: {
107-
stubs: { MessageItem: MessageItemStub },
108-
},
109131
})
110132

111133
expect(wrapper.findAll('.stub-message')).toHaveLength(0)
@@ -123,9 +145,6 @@ describe('tool trace visibility', () => {
123145

124146
const historyWrapper = mount(HistoryMessageList, {
125147
props: { session: makeSession(sampleMessages) },
126-
global: {
127-
stubs: { MessageItem: MessageItemStub },
128-
},
129148
})
130149
expect(historyWrapper.findAll('.stub-message').map(node => node.attributes('data-id'))).toEqual([
131150
'user-1',
@@ -143,14 +162,7 @@ describe('tool trace visibility', () => {
143162
])
144163
chatStore.abortState = { aborting: true, synced: false }
145164

146-
const wrapper = mount(MessageList, {
147-
global: {
148-
stubs: {
149-
MessageItem: MessageItemStub,
150-
Transition: false,
151-
},
152-
},
153-
})
165+
const wrapper = mount(MessageList)
154166

155167
expect(wrapper.findAll('.stub-message').map(node => node.attributes('data-id'))).toContain('tool-weather')
156168
expect(wrapper.findAll('.tool-call-name').map(node => node.text())).not.toContain('weather')

0 commit comments

Comments
 (0)