diff --git a/packages/client/src/components/hermes/chat/ChatInput.vue b/packages/client/src/components/hermes/chat/ChatInput.vue index efdea73..089cebf 100644 --- a/packages/client/src/components/hermes/chat/ChatInput.vue +++ b/packages/client/src/components/hermes/chat/ChatInput.vue @@ -8,10 +8,12 @@ import { setModelContext } from '@/api/hermes/model-context' import { NButton, NTooltip, NSwitch, NModal, NInputNumber, useMessage } from 'naive-ui' import { computed, ref, nextTick, onMounted, onUnmounted, watch } from 'vue' import { useI18n } from 'vue-i18n' +import { useToolTraceVisibility } from '@/composables/useToolTraceVisibility' const chatStore = useChatStore() const { t } = useI18n() const message = useMessage() +const { toolTraceVisible, toggleToolTraceVisible } = useToolTraceVisibility() const inputText = ref('') const textareaRef = ref() const commandDropdownRef = ref() @@ -430,6 +432,24 @@ function isImage(type: string): boolean { /> + + + {{ toolTraceVisible ? t('chat.hideToolCalls') : t('chat.showToolCalls') }} + + {{ formatTokens(totalTokens) }} / @@ -614,20 +634,65 @@ function isImage(type: string): boolean { display: flex; align-items: center; gap: 6px; - padding: 0 8px; + padding: 0 0 0 8px; border-left: 1px solid $border-light; margin-left: 4px; .switch-label { display: flex; align-items: center; - color: $text-muted; + justify-content: center; + width: 16px; + height: 16px; + color: #999999; font-size: 12px; svg { - opacity: 0.7; + opacity: 1; } } + + :deep(.n-switch), + :deep(.n-switch__rail) { + margin-right: 0; + } +} + +.tool-trace-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + color: #999999; + width: 24px; + min-width: 24px; + height: 22px; + margin-left: -4px; + padding: 0; + background: transparent !important; + opacity: 1; + + :deep(.n-button__state-border), + :deep(.n-button__border), + :deep(.n-button__ripple) { + display: none; + } + + .tool-trace-icon { + display: block; + flex: 0 0 16px; + width: 16px; + height: 16px; + } + + &.active { + color: #999999; + opacity: 1; + } + + &:hover { + color: #999999; + opacity: 1; + } } .context-info { diff --git a/packages/client/src/components/hermes/chat/HistoryMessageList.vue b/packages/client/src/components/hermes/chat/HistoryMessageList.vue index ee2c8ec..e7f9ca9 100644 --- a/packages/client/src/components/hermes/chat/HistoryMessageList.vue +++ b/packages/client/src/components/hermes/chat/HistoryMessageList.vue @@ -3,6 +3,7 @@ import { ref, computed, watch, nextTick } from "vue"; import { useI18n } from "vue-i18n"; import MessageItem from "./MessageItem.vue"; import { useChatStore } from "@/stores/hermes/chat"; +import { useToolTraceVisibility } from "@/composables/useToolTraceVisibility"; import type { Session } from "@/stores/hermes/chat"; const props = defineProps<{ @@ -10,6 +11,7 @@ const props = defineProps<{ }>(); const chatStore = useChatStore(); +const { toolTraceVisible } = useToolTraceVisibility(); const { t } = useI18n(); const listRef = ref(); @@ -18,10 +20,10 @@ const activeSession = computed(() => props.session || chatStore.activeSession); const displayMessages = computed(() => (activeSession.value?.messages || []).filter((m) => { - // Filter out tool messages without name (internal use only) - if (m.role === 'tool' && !m.toolName) return false - // Filter out messages with empty content (except tool messages) - if (m.role !== 'tool' && !m.content?.trim()) return false + // Tool messages without a name are internal use only and remain hidden. + if (m.role === 'tool') return toolTraceVisible.value && !!m.toolName + // Filter out messages with empty content. + if (!m.content?.trim()) return false return true }), ); diff --git a/packages/client/src/components/hermes/chat/MessageItem.vue b/packages/client/src/components/hermes/chat/MessageItem.vue index f5d9790..efab97c 100644 --- a/packages/client/src/components/hermes/chat/MessageItem.vue +++ b/packages/client/src/components/hermes/chat/MessageItem.vue @@ -19,7 +19,13 @@ import { useGlobalSpeech } from "@/composables/useSpeech"; import { useVoiceSettings } from "@/composables/useVoiceSettings"; import { speedToEdgeRate, hzToEdgePitch } from "@/utils/ttsHelpers"; -const TOOL_PAYLOAD_DISPLAY_LIMIT = 2000; +const TOOL_PAYLOAD_DISPLAY_LIMIT = 1000; +const JSON_STRING_DISPLAY_LIMIT = 200; +const JSON_MAX_DEPTH = 6; +const JSON_MAX_NODES = 1000; +const JSON_MAX_KEYS_PER_OBJECT = 50; +const JSON_MAX_ITEMS_PER_ARRAY = 50; +const JSON_TRUNCATED_KEY = "__truncated__"; const props = defineProps<{ message: Message; highlight?: boolean }>(); const { t } = useI18n(); @@ -353,19 +359,96 @@ type ToolPayload = { language?: string; }; +function truncateLongString(value: string, marker: string): string { + return value.length > JSON_STRING_DISPLAY_LIMIT + ? value.slice(0, JSON_STRING_DISPLAY_LIMIT) + "\n" + marker + : value; +} + +function truncateJsonValue(value: unknown, marker: string): unknown { + let nodeCount = 0; + const seen = new WeakSet(); + + function stringifyLength(candidate: unknown): number { + return JSON.stringify(candidate, null, 2).length; + } + + function visit(current: unknown, depth: number): unknown { + nodeCount += 1; + if (nodeCount > JSON_MAX_NODES) { + return marker; + } + + if (typeof current === "string") return truncateLongString(current, marker); + if (current === null || typeof current !== "object") return current; + + if (seen.has(current)) return `[Circular ${marker}]`; + if (depth >= JSON_MAX_DEPTH) { + return Array.isArray(current) ? `[Array ${marker}]` : `[Object ${marker}]`; + } + + seen.add(current); + + if (Array.isArray(current)) { + const result: unknown[] = []; + const maxItems = Math.min(current.length, JSON_MAX_ITEMS_PER_ARRAY); + for (let i = 0; i < maxItems; i += 1) { + const remaining = current.length - i; + result.push(visit(current[i], depth + 1)); + if (stringifyLength(result) > TOOL_PAYLOAD_DISPLAY_LIMIT) { + result.pop(); + result.push(`${marker}: ${remaining} more items`); + seen.delete(current); + return result; + } + } + if (current.length > maxItems) { + result.push(`${marker}: ${current.length - maxItems} more items`); + } + seen.delete(current); + return result; + } + + const entries = Object.entries(current as Record); + const result: Record = {}; + const maxKeys = Math.min(entries.length, JSON_MAX_KEYS_PER_OBJECT); + for (let i = 0; i < maxKeys; i += 1) { + const [key, val] = entries[i]; + const remaining = entries.length - i; + result[key] = visit(val, depth + 1); + if (stringifyLength(result) > TOOL_PAYLOAD_DISPLAY_LIMIT) { + delete result[key]; + result[JSON_TRUNCATED_KEY] = `${marker}: ${remaining} more keys`; + seen.delete(current); + return result; + } + } + if (entries.length > maxKeys) { + result[JSON_TRUNCATED_KEY] = `${marker}: ${entries.length - maxKeys} more keys`; + } + seen.delete(current); + return result; + } + + const truncated = visit(value, 0); + if (stringifyLength(truncated) <= TOOL_PAYLOAD_DISPLAY_LIMIT) return truncated; + return { [JSON_TRUNCATED_KEY]: marker }; +} + function formatToolPayload(raw?: string): ToolPayload { if (!raw) { return { full: "", display: "" }; } try { - const full = JSON.stringify(JSON.parse(raw), null, 2); + const parsed = JSON.parse(raw); + const full = JSON.stringify(parsed, null, 2); + const display = full.length > TOOL_PAYLOAD_DISPLAY_LIMIT + ? JSON.stringify(truncateJsonValue(parsed, t("chat.truncated")), null, 2) + : full; return { full, - display: - full.length > TOOL_PAYLOAD_DISPLAY_LIMIT - ? full.slice(0, TOOL_PAYLOAD_DISPLAY_LIMIT) + "\n" + t("chat.truncated") - : full, + display, language: "json", }; } catch { diff --git a/packages/client/src/components/hermes/chat/MessageList.vue b/packages/client/src/components/hermes/chat/MessageList.vue index 6449237..8442e86 100644 --- a/packages/client/src/components/hermes/chat/MessageList.vue +++ b/packages/client/src/components/hermes/chat/MessageList.vue @@ -6,10 +6,12 @@ import { useChatStore } from "@/stores/hermes/chat"; import thinkingVideoLight from "@/assets/thinking-light.mp4"; import thinkingVideoDark from "@/assets/thinking-dark.mp4"; import { useTheme } from "@/composables/useTheme"; +import { useToolTraceVisibility } from "@/composables/useToolTraceVisibility"; const chatStore = useChatStore(); const { t } = useI18n(); const { isDark } = useTheme(); +const { toolTraceVisible } = useToolTraceVisibility(); const listRef = ref(); function formatTokens(n: number): string { @@ -41,9 +43,16 @@ const currentToolCalls = computed(() => { return [...tools].reverse(); }); -const displayMessages = computed(() => - chatStore.messages.filter((m) => { - if (m.role === "tool") return false; +const visibleToolCalls = computed(() => + toolTraceVisible.value ? currentToolCalls.value.filter((tool) => !!tool.toolName) : [], +); + +const displayMessages = computed(() => { + const currentToolIds = new Set(currentToolCalls.value.map((tool) => tool.id)); + return chatStore.messages.filter((m) => { + if (m.role === "tool") { + return toolTraceVisible.value && !!m.toolName && !(chatStore.isRunActive && currentToolIds.has(m.id)); + } if ( m.role === "assistant" && m.isStreaming && @@ -54,8 +63,8 @@ const displayMessages = computed(() => return false; } return true; - }), -); + }); +}); const queuedMessages = computed(() => { const sid = chatStore.activeSessionId; @@ -171,7 +180,7 @@ watch(currentToolCalls, () => { playsinline class="thinking-video" /> -
+
{
diff --git a/packages/client/src/composables/useToolTraceVisibility.ts b/packages/client/src/composables/useToolTraceVisibility.ts new file mode 100644 index 0000000..2ebded2 --- /dev/null +++ b/packages/client/src/composables/useToolTraceVisibility.ts @@ -0,0 +1,34 @@ +import { ref } from 'vue' + +const STORAGE_KEY = 'hermes_show_tool_calls' + +function readInitialValue(): boolean { + try { + return localStorage.getItem(STORAGE_KEY) !== 'false' + } catch { + return true + } +} + +const toolTraceVisible = ref(readInitialValue()) + +function setToolTraceVisible(value: boolean) { + toolTraceVisible.value = value + try { + localStorage.setItem(STORAGE_KEY, String(value)) + } catch { + // Ignore storage failures; the in-memory toggle still works for this tab. + } +} + +function toggleToolTraceVisible() { + setToolTraceVisible(!toolTraceVisible.value) +} + +export function useToolTraceVisibility() { + return { + toolTraceVisible, + setToolTraceVisible, + toggleToolTraceVisible, + } +} diff --git a/packages/client/src/i18n/locales/de.ts b/packages/client/src/i18n/locales/de.ts index fdfd6fc..58fd26a 100644 --- a/packages/client/src/i18n/locales/de.ts +++ b/packages/client/src/i18n/locales/de.ts @@ -139,6 +139,8 @@ export default { destroy: 'Bridge-Agent für diese Sitzung freigeben', }, attachFiles: 'Dateien anhangen', + showToolCalls: 'Tool-Aufrufe anzeigen', + hideToolCalls: 'Tool-Aufrufe ausblenden', messageQueue: 'Nachrichtenwarteschlange', removeQueuedMessage: 'Nachricht aus Warteschlange entfernen', stop: 'Stopp', diff --git a/packages/client/src/i18n/locales/en.ts b/packages/client/src/i18n/locales/en.ts index c46b957..4273fc6 100644 --- a/packages/client/src/i18n/locales/en.ts +++ b/packages/client/src/i18n/locales/en.ts @@ -153,6 +153,8 @@ export default { }, attachFiles: 'Attach files', autoPlaySpeech: 'Auto-play voice', + showToolCalls: 'Show tool calls', + hideToolCalls: 'Hide tool calls', messageQueue: 'Message queue', removeQueuedMessage: 'Remove queued message', stop: 'Stop', diff --git a/packages/client/src/i18n/locales/es.ts b/packages/client/src/i18n/locales/es.ts index 62e054a..c6a351b 100644 --- a/packages/client/src/i18n/locales/es.ts +++ b/packages/client/src/i18n/locales/es.ts @@ -139,6 +139,8 @@ export default { destroy: 'Liberar el agente Bridge de esta sesión', }, attachFiles: 'Adjuntar archivos', + showToolCalls: 'Mostrar llamadas de herramientas', + hideToolCalls: 'Ocultar llamadas de herramientas', messageQueue: 'Cola de mensajes', removeQueuedMessage: 'Quitar mensaje de la cola', stop: 'Detener', diff --git a/packages/client/src/i18n/locales/fr.ts b/packages/client/src/i18n/locales/fr.ts index fa8a5b7..a0ce361 100644 --- a/packages/client/src/i18n/locales/fr.ts +++ b/packages/client/src/i18n/locales/fr.ts @@ -139,6 +139,8 @@ export default { destroy: 'Libérer l’agent Bridge de cette session', }, attachFiles: 'Joindre des fichiers', + showToolCalls: 'Afficher les appels d’outils', + hideToolCalls: 'Masquer les appels d’outils', messageQueue: 'File de messages', removeQueuedMessage: 'Retirer le message de la file', stop: 'Arreter', diff --git a/packages/client/src/i18n/locales/ja.ts b/packages/client/src/i18n/locales/ja.ts index 7272ea7..381a3f4 100644 --- a/packages/client/src/i18n/locales/ja.ts +++ b/packages/client/src/i18n/locales/ja.ts @@ -139,6 +139,8 @@ export default { destroy: 'このセッションの Bridge Agent を解放', }, attachFiles: 'ファイルを添付', + showToolCalls: 'ツール呼び出しを表示', + hideToolCalls: 'ツール呼び出しを非表示', messageQueue: 'メッセージキュー', removeQueuedMessage: 'キューのメッセージを削除', stop: '停止', diff --git a/packages/client/src/i18n/locales/ko.ts b/packages/client/src/i18n/locales/ko.ts index 7b18024..202e80f 100644 --- a/packages/client/src/i18n/locales/ko.ts +++ b/packages/client/src/i18n/locales/ko.ts @@ -139,6 +139,8 @@ export default { destroy: '이 세션의 Bridge Agent 해제', }, attachFiles: '파일 첨부', + showToolCalls: '도구 호출 표시', + hideToolCalls: '도구 호출 숨기기', messageQueue: '메시지 대기열', removeQueuedMessage: '대기열 메시지 제거', stop: '중지', diff --git a/packages/client/src/i18n/locales/pt.ts b/packages/client/src/i18n/locales/pt.ts index a5ad7e1..38a5847 100644 --- a/packages/client/src/i18n/locales/pt.ts +++ b/packages/client/src/i18n/locales/pt.ts @@ -139,6 +139,8 @@ export default { destroy: 'Liberar o Bridge Agent desta sessão', }, attachFiles: 'Anexar arquivos', + showToolCalls: 'Mostrar chamadas de ferramentas', + hideToolCalls: 'Ocultar chamadas de ferramentas', messageQueue: 'Fila de mensagens', removeQueuedMessage: 'Remover mensagem da fila', stop: 'Parar', diff --git a/packages/client/src/i18n/locales/zh-TW.ts b/packages/client/src/i18n/locales/zh-TW.ts index 85f9906..8ecee9c 100644 --- a/packages/client/src/i18n/locales/zh-TW.ts +++ b/packages/client/src/i18n/locales/zh-TW.ts @@ -152,6 +152,8 @@ export default { }, attachFiles: '新增附件', autoPlaySpeech: '自動播放語音', + showToolCalls: '顯示工具呼叫', + hideToolCalls: '隱藏工具呼叫', messageQueue: '訊息佇列', removeQueuedMessage: '移除佇列訊息', stop: '停止', diff --git a/packages/client/src/i18n/locales/zh.ts b/packages/client/src/i18n/locales/zh.ts index 33ca621..e9675eb 100644 --- a/packages/client/src/i18n/locales/zh.ts +++ b/packages/client/src/i18n/locales/zh.ts @@ -153,6 +153,8 @@ export default { }, attachFiles: '添加附件', autoPlaySpeech: '自动播放语音', + showToolCalls: '显示工具调用', + hideToolCalls: '隐藏工具调用', messageQueue: '消息队列', removeQueuedMessage: '移除队列消息', stop: '停止', diff --git a/packages/client/src/stores/hermes/chat.ts b/packages/client/src/stores/hermes/chat.ts index bc0cb18..7636be9 100644 --- a/packages/client/src/stores/hermes/chat.ts +++ b/packages/client/src/stores/hermes/chat.ts @@ -136,10 +136,11 @@ async function buildContentBlocks( } function mapHermesMessages(msgs: HermesMessage[]): Message[] { - // Filter out assistant messages with empty content + // Filter out assistant messages with no display content unless they carry tool call metadata + // needed to name later tool result rows when resuming persisted history. const filteredMsgs = msgs.filter(m => { if (m.role === 'assistant') { - return m.content && m.content.trim() !== '' + return (m.tool_calls?.length || 0) > 0 || (m.content && m.content.trim() !== '') } return true }) @@ -169,7 +170,7 @@ function mapHermesMessages(msgs: HermesMessage[]): Message[] { role: 'tool', content: '', timestamp: Math.round(msg.timestamp * 1000), - toolName: tc.function?.name || 'tool', + toolName: tc.function?.name || undefined, toolCallId: tc.id, toolArgs: tc.function?.arguments || undefined, toolStatus: 'done', @@ -181,7 +182,7 @@ function mapHermesMessages(msgs: HermesMessage[]): Message[] { // Tool result messages if (msg.role === 'tool') { const tcId = msg.tool_call_id || '' - const toolName = msg.tool_name || toolNameMap.get(tcId) || 'tool' + const toolName = msg.tool_name || toolNameMap.get(tcId) || undefined const toolArgs = toolArgsMap.get(tcId) || undefined // Extract a short preview from the content let preview = '' diff --git a/tests/client/message-item-highlight.test.ts b/tests/client/message-item-highlight.test.ts index 3f22337..d48d4c4 100644 --- a/tests/client/message-item-highlight.test.ts +++ b/tests/client/message-item-highlight.test.ts @@ -120,9 +120,11 @@ describe('MessageItem tool details', () => { const expected = JSON.stringify(message, null, 2) const code = wrapper.find('.tool-details code.hljs') + const displayed = JSON.parse(code.text()) expect(wrapper.find('.tool-details .code-lang').text()).toBe('json') expect(wrapper.html()).toContain('chat.truncated') - expect(code.findAll('span')).toHaveLength(0) + expect(displayed.content).toContain('chat.truncated') + expect(code.findAll('span').length).toBeGreaterThan(0) await wrapper.find('.tool-details [data-copy-code="true"]').trigger('click') expect(writeText).toHaveBeenCalledWith(expected) @@ -150,14 +152,45 @@ describe('MessageItem tool details', () => { await wrapper.find('.tool-line').trigger('click') + const code = wrapper.find('.tool-details code.hljs') + const displayed = JSON.parse(code.text()) expect(wrapper.find('.tool-details .code-lang').text()).toBe('json') expect(wrapper.html()).toContain('chat.truncated') - expect(wrapper.find('.tool-details code.hljs').findAll('span')).toHaveLength(0) + expect(displayed.content).toContain('chat.truncated') + expect(code.findAll('span').length).toBeGreaterThan(0) await wrapper.find('.tool-details [data-copy-code="true"]').trigger('click') expect(writeText).toHaveBeenCalledWith(JSON.stringify(fullResult, null, 2)) }) + it('truncates large JSON arrays at item boundaries so display remains parseable JSON', async () => { + const fullResult = Array.from({ length: 100 }, (_, index) => ({ + index, + value: `item-${index}-${'x'.repeat(80)}`, + })) + const wrapper = mount(MessageItem, { + props: { + message: { + id: 'tool-array', + role: 'tool', + content: '', + timestamp: Date.now(), + toolName: 'browser_snapshot', + toolResult: JSON.stringify(fullResult), + toolStatus: 'done', + } satisfies Message, + }, + }) + + await wrapper.find('.tool-line').trigger('click') + + const code = wrapper.find('.tool-details code.hljs') + const displayed = JSON.parse(code.text()) + expect(Array.isArray(displayed)).toBe(true) + expect(displayed.at(-1)).toContain('chat.truncated') + expect(code.text().length).toBeLessThanOrEqual(1000) + }) + it('copies the full large raw tool result even when the display is truncated', async () => { const writeText = vi.mocked(navigator.clipboard.writeText) const fullResult = 'line\n'.repeat(1200) @@ -177,9 +210,11 @@ describe('MessageItem tool details', () => { await wrapper.find('.tool-line').trigger('click') + const displayedResult = fullResult.slice(0, 1000) + '\nchat.truncated' + const code = wrapper.find('.tool-details code.hljs') expect(wrapper.find('.tool-details .code-lang').text()).toBe('text') - expect(wrapper.html()).toContain('chat.truncated') - expect(wrapper.find('.tool-details code.hljs').findAll('span')).toHaveLength(0) + expect(code.text()).toBe(displayedResult) + expect(code.findAll('span')).toHaveLength(0) await wrapper.find('.tool-details [data-copy-code="true"]').trigger('click') expect(writeText).toHaveBeenCalledWith(fullResult) diff --git a/tests/client/tool-trace-visibility.test.ts b/tests/client/tool-trace-visibility.test.ts new file mode 100644 index 0000000..2da0722 --- /dev/null +++ b/tests/client/tool-trace-visibility.test.ts @@ -0,0 +1,118 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { defineComponent } from 'vue' + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: (key: string) => key, + }), +})) + +vi.mock('@/composables/useTheme', () => ({ + useTheme: () => ({ isDark: false }), +})) + +import MessageList from '@/components/hermes/chat/MessageList.vue' +import HistoryMessageList from '@/components/hermes/chat/HistoryMessageList.vue' +import { useChatStore, type Message, type Session } from '@/stores/hermes/chat' +import { useToolTraceVisibility } from '@/composables/useToolTraceVisibility' + +const MessageItemStub = defineComponent({ + name: 'MessageItem', + props: { + message: { type: Object, required: true }, + highlight: { type: Boolean, default: false }, + }, + template: '
{{ message.toolName || message.content }}
', +}) + +function makeSession(messages: Message[]): Session { + return { + id: 'session-1', + title: 'Tool trace visibility', + messages, + createdAt: Date.now(), + updatedAt: Date.now(), + } +} + +const sampleMessages: Message[] = [ + { id: 'user-1', role: 'user', content: 'inspect repo', timestamp: 1 }, + { id: 'tool-named', role: 'tool', content: '', timestamp: 2, toolName: 'read_file', toolResult: 'ok', toolStatus: 'done' }, + { id: 'tool-internal', role: 'tool', content: '', timestamp: 3, toolResult: 'internal', toolStatus: 'done' }, + { id: 'assistant-1', role: 'assistant', content: 'done', timestamp: 4 }, +] + +describe('tool trace visibility', () => { + beforeEach(() => { + setActivePinia(createPinia()) + localStorage.removeItem('hermes_show_tool_calls') + useToolTraceVisibility().setToolTraceVisible(true) + }) + + function mountLiveList() { + const chatStore = useChatStore() + chatStore.activeSessionId = 'session-1' + chatStore.activeSession = makeSession(sampleMessages) + chatStore.abortState = { aborting: true, synced: false } + + return mount(MessageList, { + global: { + stubs: { + MessageItem: MessageItemStub, + Transition: false, + }, + }, + }) + } + + it('shows named transcript and live tool traces by default while keeping unnamed internal tools hidden', () => { + const wrapper = mountLiveList() + + expect(wrapper.findAll('.stub-message').map(node => node.attributes('data-id'))).toEqual([ + 'user-1', + 'tool-named', + 'assistant-1', + ]) + expect(wrapper.findAll('.tool-call-name').map(node => node.text())).toContain('read_file') + }) + + it('applies the same default-visible rule to history sessions', () => { + const wrapper = mount(HistoryMessageList, { + props: { session: makeSession(sampleMessages) }, + global: { + stubs: { MessageItem: MessageItemStub }, + }, + }) + + expect(wrapper.findAll('.stub-message').map(node => node.attributes('data-id'))).toEqual([ + 'user-1', + 'tool-named', + 'assistant-1', + ]) + }) + + it('hides named live and history tool traces when the localStorage toggle is off', () => { + useToolTraceVisibility().setToolTraceVisible(false) + + const liveWrapper = mountLiveList() + expect(liveWrapper.findAll('.stub-message').map(node => node.attributes('data-id'))).toEqual([ + 'user-1', + 'assistant-1', + ]) + expect(liveWrapper.findAll('.tool-call-name').map(node => node.text())).not.toContain('read_file') + + const historyWrapper = mount(HistoryMessageList, { + props: { session: makeSession(sampleMessages) }, + global: { + stubs: { MessageItem: MessageItemStub }, + }, + }) + expect(historyWrapper.findAll('.stub-message').map(node => node.attributes('data-id'))).toEqual([ + 'user-1', + 'assistant-1', + ]) + }) +}) diff --git a/tests/e2e/chat-streaming.spec.ts b/tests/e2e/chat-streaming.spec.ts index 3ee3b79..e307490 100644 --- a/tests/e2e/chat-streaming.spec.ts +++ b/tests/e2e/chat-streaming.spec.ts @@ -246,3 +246,575 @@ test('surfaces an empty completed run as an error instead of leaving chat stalle await expect(page.getByRole('button', { name: 'Stop' })).toHaveCount(0) expect(api.unexpectedRequests).toEqual([]) }) + +test('renders tool trace and sends explicit approval decisions over the chat-run socket', async ({ page }) => { + await authenticate(page, TEST_ACCESS_KEY, 'research') + const api = await mockHermesApi(page) + await mockChatSocket(page) + + await page.goto('/#/hermes/chat') + + await sendChatMessage(page, 'Use write_file with approval') + const { run } = await waitForRun(page) + + await page.evaluate((sid) => { + const socket = (window as any).__PW_CHAT_SOCKET__.latest + socket.__trigger('run.started', { event: 'run.started', session_id: sid, run_id: 'run-approval' }) + socket.__trigger('tool.started', { + event: 'tool.started', + session_id: sid, + run_id: 'run-approval', + tool_call_id: 'tool-call-1', + tool: 'write_file', + preview: 'Writing approved file', + arguments: JSON.stringify({ path: '/tmp/approved.txt', content: 'hello' }), + }) + socket.__trigger('approval.requested', { + event: 'approval.requested', + session_id: sid, + run_id: 'run-approval', + approval_id: 'approval-1', + command: 'write_file /tmp/approved.txt', + description: 'Allow write_file to create /tmp/approved.txt', + choices: ['once', 'deny'], + allow_permanent: false, + }) + }, run.session_id) + + await expect(page.getByText('write_file', { exact: true })).toBeVisible() + await expect(page.getByText('Writing approved file')).toBeVisible() + await expect(page.locator('.message.tool .tool-line')).toHaveCount(0) + await expect(page.locator('.tool-calls-panel .tool-call-name').filter({ hasText: 'write_file' })).toBeVisible() + await expect(page.getByText('Allow write_file to create /tmp/approved.txt')).toBeVisible() + await expect(page.getByText('write_file /tmp/approved.txt')).toBeVisible() + await expect(page.getByRole('button', { name: 'Allow once' })).toBeVisible() + await expect(page.getByRole('button', { name: 'Allow session' })).toHaveCount(0) + await expect(page.getByRole('button', { name: 'Deny' })).toBeVisible() + + await page.evaluate((sid) => { + const socket = (window as any).__PW_CHAT_SOCKET__.latest + socket.__trigger('approval.resolved', { + event: 'approval.resolved', + session_id: sid, + run_id: 'run-approval', + approval_id: 'approval-other', + choice: 'deny', + resolved: true, + }) + }, run.session_id) + await expect(page.getByText('Allow write_file to create /tmp/approved.txt')).toBeVisible() + await expect(page.getByRole('button', { name: 'Allow once' })).toBeVisible() + + await page.getByRole('button', { name: 'Allow once' }).click() + + await expect(page.getByText('Allow write_file to create /tmp/approved.txt')).toHaveCount(0) + await expect(page.getByRole('button', { name: 'Allow once' })).toHaveCount(0) + await expect.poll(async () => page.evaluate(() => { + const emitted = (window as any).__PW_CHAT_SOCKET__.emitted + return emitted.filter((item: any) => item.event === 'approval.respond') + })).toEqual([ + { + event: 'approval.respond', + payload: { + session_id: run.session_id, + approval_id: 'approval-1', + choice: 'once', + }, + }, + ]) + + await page.evaluate((sid) => { + const socket = (window as any).__PW_CHAT_SOCKET__.latest + socket.__trigger('approval.resolved', { + event: 'approval.resolved', + session_id: sid, + run_id: 'run-approval', + approval_id: 'approval-1', + choice: 'once', + resolved: true, + }) + socket.__trigger('tool.completed', { + event: 'tool.completed', + session_id: sid, + run_id: 'run-approval', + tool_call_id: 'tool-call-1', + tool: 'write_file', + output: JSON.stringify({ ok: true, path: '/tmp/approved.txt' }), + duration: 42, + }) + socket.__trigger('message.delta', { + event: 'message.delta', + session_id: sid, + run_id: 'run-approval', + delta: 'Delta-only approved tool result.', + }) + socket.__trigger('run.completed', { + event: 'run.completed', + session_id: sid, + run_id: 'run-approval', + output: 'Completion fallback should stay hidden.', + }) + }, run.session_id) + + const persistedToolTrace = page.locator('.message.tool .tool-line').filter({ hasText: 'write_file' }) + await expect(persistedToolTrace).toHaveCount(1) + await persistedToolTrace.click() + const toolDetails = page.locator('.message.tool .tool-details') + await expect(toolDetails).toContainText('/tmp/approved.txt') + await expect(toolDetails).toContainText('ok') + await expect(page.getByText('Delta-only approved tool result.')).toBeVisible() + await expect(page.getByText('Completion fallback should stay hidden.')).toHaveCount(0) + await expect(page.locator('.tool-calls-panel .tool-call-name').filter({ hasText: 'write_file' })).toHaveCount(0) + await expect(page.getByRole('button', { name: 'Stop' })).toHaveCount(0) + expect(api.unexpectedRequests).toEqual([]) +}) + +test('keeps prior tool trace visible while hiding only the active run tool trace', async ({ page }) => { + await authenticate(page, TEST_ACCESS_KEY, 'research') + const api = await mockHermesApi(page) + await mockChatSocket(page) + + await page.goto('/#/hermes/chat') + + await sendChatMessage(page, 'First tool trace') + const first = await waitForRun(page) + await page.evaluate((sid) => { + const socket = (window as any).__PW_CHAT_SOCKET__.latest + socket.__trigger('run.started', { event: 'run.started', session_id: sid, run_id: 'run-history-1' }) + socket.__trigger('tool.started', { + event: 'tool.started', + session_id: sid, + run_id: 'run-history-1', + tool_call_id: 'tool-history-1', + tool: 'read_file', + preview: 'Read historical file', + arguments: JSON.stringify({ path: '/tmp/history.txt' }), + }) + socket.__trigger('tool.completed', { + event: 'tool.completed', + session_id: sid, + run_id: 'run-history-1', + tool_call_id: 'tool-history-1', + tool: 'read_file', + output: JSON.stringify({ ok: true, path: '/tmp/history.txt' }), + duration: 12, + }) + socket.__trigger('message.delta', { + event: 'message.delta', + session_id: sid, + run_id: 'run-history-1', + delta: 'First tool answer.', + }) + socket.__trigger('run.completed', { + event: 'run.completed', + session_id: sid, + run_id: 'run-history-1', + output: 'First fallback should stay hidden.', + }) + }, first.run.session_id) + + const transcriptTools = page.locator('.message.tool .tool-line') + await expect(transcriptTools.filter({ hasText: 'read_file' })).toHaveCount(1) + await expect(page.locator('.tool-calls-panel .tool-call-name').filter({ hasText: 'read_file' })).toHaveCount(0) + + await sendChatMessage(page, 'Second tool trace') + const second = await waitForRun(page, 1) + await page.evaluate((sid) => { + const socket = (window as any).__PW_CHAT_SOCKET__.latest + socket.__trigger('run.started', { event: 'run.started', session_id: sid, run_id: 'run-history-2' }) + socket.__trigger('tool.started', { + event: 'tool.started', + session_id: sid, + run_id: 'run-history-2', + tool_call_id: 'tool-history-2', + tool: 'write_file', + preview: 'Write current file', + arguments: JSON.stringify({ path: '/tmp/current.txt', content: 'now' }), + }) + }, second.run.session_id) + + await expect(transcriptTools.filter({ hasText: 'read_file' })).toHaveCount(1) + await expect(transcriptTools.filter({ hasText: 'write_file' })).toHaveCount(0) + await expect(page.locator('.tool-calls-panel .tool-call-name').filter({ hasText: 'read_file' })).toHaveCount(0) + await expect(page.locator('.tool-calls-panel .tool-call-name').filter({ hasText: 'write_file' })).toHaveCount(1) + + await page.evaluate((sid) => { + const socket = (window as any).__PW_CHAT_SOCKET__.latest + socket.__trigger('tool.completed', { + event: 'tool.completed', + session_id: sid, + run_id: 'run-history-2', + tool_call_id: 'tool-history-2', + tool: 'write_file', + output: JSON.stringify({ ok: true, path: '/tmp/current.txt' }), + duration: 15, + }) + socket.__trigger('message.delta', { + event: 'message.delta', + session_id: sid, + run_id: 'run-history-2', + delta: 'Second tool answer.', + }) + socket.__trigger('run.completed', { + event: 'run.completed', + session_id: sid, + run_id: 'run-history-2', + output: 'Second fallback should stay hidden.', + }) + }, second.run.session_id) + + await expect(transcriptTools).toHaveCount(2) + await expect(transcriptTools.filter({ hasText: 'read_file' })).toHaveCount(1) + await expect(transcriptTools.filter({ hasText: 'write_file' })).toHaveCount(1) + await expect(page.getByText('First fallback should stay hidden.')).toHaveCount(0) + await expect(page.getByText('Second fallback should stay hidden.')).toHaveCount(0) + await expect(page.getByRole('button', { name: 'Stop' })).toHaveCount(0) + expect(api.unexpectedRequests).toEqual([]) +}) + +test('keeps completed same-run tool traces hidden until the run finishes', async ({ page }) => { + await authenticate(page, TEST_ACCESS_KEY, 'research') + const api = await mockHermesApi(page) + await mockChatSocket(page) + + await page.goto('/#/hermes/chat') + + await sendChatMessage(page, 'Run multiple tools') + const { run } = await waitForRun(page) + await page.evaluate((sid) => { + const socket = (window as any).__PW_CHAT_SOCKET__.latest + socket.__trigger('run.started', { event: 'run.started', session_id: sid, run_id: 'run-multi-tool' }) + socket.__trigger('tool.started', { + event: 'tool.started', + session_id: sid, + run_id: 'run-multi-tool', + tool_call_id: 'tool-multi-1', + tool: 'read_file', + preview: 'Read config', + arguments: JSON.stringify({ path: '/tmp/config.json' }), + }) + socket.__trigger('tool.started', { + event: 'tool.started', + session_id: sid, + run_id: 'run-multi-tool', + tool_call_id: 'tool-multi-2', + tool: 'shell_exec', + preview: 'Run command', + arguments: JSON.stringify({ command: 'false' }), + }) + }, run.session_id) + + const transcriptTools = page.locator('.message.tool .tool-line') + await expect(transcriptTools).toHaveCount(0) + await expect(page.locator('.tool-calls-panel .tool-call-name').filter({ hasText: 'read_file' })).toHaveCount(1) + await expect(page.locator('.tool-calls-panel .tool-call-name').filter({ hasText: 'shell_exec' })).toHaveCount(1) + + await page.evaluate((sid) => { + const socket = (window as any).__PW_CHAT_SOCKET__.latest + socket.__trigger('tool.completed', { + event: 'tool.completed', + session_id: sid, + run_id: 'run-multi-tool', + tool_call_id: 'tool-multi-1', + tool: 'read_file', + output: JSON.stringify({ ok: true, path: '/tmp/config.json' }), + duration: 11, + }) + socket.__trigger('tool.completed', { + event: 'tool.completed', + session_id: sid, + run_id: 'run-multi-tool', + tool_call_id: 'tool-multi-2', + tool: 'shell_exec', + output: 'exit status 1', + error: true, + duration: 13, + }) + }, run.session_id) + + await expect(transcriptTools).toHaveCount(0) + await expect(page.locator('.tool-calls-panel .tool-call-name').filter({ hasText: 'read_file' })).toHaveCount(1) + await expect(page.locator('.tool-calls-panel .tool-call-name').filter({ hasText: 'shell_exec' })).toHaveCount(1) + + 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: 'run-multi-tool', + delta: 'Multiple tools finished.', + }) + socket.__trigger('run.completed', { + event: 'run.completed', + session_id: sid, + run_id: 'run-multi-tool', + output: 'Multi-tool fallback should stay hidden.', + }) + }, run.session_id) + + await expect(transcriptTools).toHaveCount(2) + await expect(transcriptTools.filter({ hasText: 'read_file' })).toHaveCount(1) + await expect(transcriptTools.filter({ hasText: 'shell_exec' })).toHaveCount(1) + await expect(page.locator('.tool-calls-panel .tool-call-name').filter({ hasText: 'read_file' })).toHaveCount(0) + await expect(page.locator('.tool-calls-panel .tool-call-name').filter({ hasText: 'shell_exec' })).toHaveCount(0) + await expect(page.locator('.message.tool .tool-error-badge')).toHaveCount(1) + await transcriptTools.filter({ hasText: 'shell_exec' }).click() + await expect(page.locator('.message.tool .tool-details')).toContainText('exit status 1') + await expect(page.getByText('Multi-tool fallback should stay hidden.')).toHaveCount(0) + await expect(page.getByRole('button', { name: 'Stop' })).toHaveCount(0) + expect(api.unexpectedRequests).toEqual([]) +}) + +test('keeps unnamed tool trace messages out of the transcript after completion', async ({ page }) => { + await authenticate(page, TEST_ACCESS_KEY, 'research') + const api = await mockHermesApi(page) + await mockChatSocket(page) + + await page.goto('/#/hermes/chat') + + await sendChatMessage(page, 'Run internal unnamed tool') + const { run } = await waitForRun(page) + await page.evaluate((sid) => { + const socket = (window as any).__PW_CHAT_SOCKET__.latest + socket.__trigger('run.started', { event: 'run.started', session_id: sid, run_id: 'run-unnamed-tool' }) + socket.__trigger('tool.started', { + event: 'tool.started', + session_id: sid, + run_id: 'run-unnamed-tool', + tool_call_id: 'tool-unnamed-1', + preview: 'Internal unnamed work', + arguments: JSON.stringify({ internal: true }), + }) + socket.__trigger('tool.completed', { + event: 'tool.completed', + session_id: sid, + run_id: 'run-unnamed-tool', + tool_call_id: 'tool-unnamed-1', + output: JSON.stringify({ internal: true, ok: true }), + duration: 9, + }) + socket.__trigger('message.delta', { + event: 'message.delta', + session_id: sid, + run_id: 'run-unnamed-tool', + delta: 'Unnamed internal tool finished.', + }) + socket.__trigger('run.completed', { + event: 'run.completed', + session_id: sid, + run_id: 'run-unnamed-tool', + output: 'Unnamed fallback should stay hidden.', + }) + }, run.session_id) + + await expect(page.locator('.message.tool .tool-line')).toHaveCount(0) + await expect(page.getByText('Unnamed internal tool finished.')).toBeVisible() + await expect(page.getByText('Unnamed fallback should stay hidden.')).toHaveCount(0) + await expect(page.getByRole('button', { name: 'Stop' })).toHaveCount(0) + expect(api.unexpectedRequests).toEqual([]) +}) + +test('keeps unnamed resumed tool traces hidden after session reload', async ({ page }) => { + const sessionId = 'session-history-unnamed-tool' + const sessionSummary = { + id: sessionId, + source: 'api_server', + model: 'test-model', + title: 'Unnamed tool history', + preview: 'History answer visible.', + started_at: 1, + ended_at: 4, + last_active: 4, + message_count: 4, + tool_call_count: 1, + input_tokens: 0, + output_tokens: 0, + cache_read_tokens: 0, + cache_write_tokens: 0, + reasoning_tokens: 0, + billing_provider: 'test-provider', + estimated_cost_usd: 0, + actual_cost_usd: null, + cost_status: 'none', + workspace: null, + } + await authenticate(page, TEST_ACCESS_KEY, 'research') + await page.addInitScript((sid) => { + ;(window as any).__PW_CHAT_SOCKET_RESUMES__ = { + [sid]: { + session_id: sid, + isWorking: false, + events: [], + messages: [ + { + id: 1, + session_id: sid, + role: 'user', + content: 'Resume unnamed internal tool', + tool_call_id: null, + tool_calls: null, + tool_name: null, + timestamp: 1, + token_count: null, + finish_reason: null, + reasoning: null, + }, + { + id: 2, + session_id: sid, + role: 'assistant', + content: '', + tool_call_id: null, + tool_calls: [{ id: 'tool-resume-unnamed-1', type: 'function', function: { arguments: JSON.stringify({ internal: true }) } }], + tool_name: null, + timestamp: 2, + token_count: null, + finish_reason: 'tool_calls', + reasoning: null, + }, + { + id: 3, + session_id: sid, + role: 'tool', + content: JSON.stringify({ internal: true, ok: true }), + tool_call_id: 'tool-resume-unnamed-1', + tool_calls: null, + tool_name: null, + timestamp: 3, + token_count: null, + finish_reason: null, + reasoning: null, + }, + { + id: 4, + session_id: sid, + role: 'assistant', + content: 'History answer visible.', + tool_call_id: null, + tool_calls: null, + tool_name: null, + timestamp: 4, + token_count: null, + finish_reason: 'stop', + reasoning: null, + }, + ], + }, + } + }, sessionId) + const api = await mockHermesApi(page, { sessions: [sessionSummary] }) + await mockChatSocket(page) + + await page.goto('/#/hermes/chat') + + await expect(page.getByText('History answer visible.')).toBeVisible() + await expect(page.locator('.message.tool .tool-line')).toHaveCount(0) + await expect(page.locator('.message.tool')).toHaveCount(0) + const resumeRequest = await page.waitForFunction((sid) => { + const state = (window as any).__PW_CHAT_SOCKET__ + return state?.emitted?.some((item: any) => item.event === 'resume' && item.payload?.session_id === sid) + }, sessionId) + expect(await resumeRequest.jsonValue()).toBe(true) + expect(api.unexpectedRequests).toEqual([]) +}) + +test('restores named resumed tool traces from assistant tool calls after session reload', async ({ page }) => { + const sessionId = 'session-history-named-tool' + const sessionSummary = { + id: sessionId, + source: 'api_server', + model: 'test-model', + title: 'Named tool history', + preview: 'Named history answer visible.', + started_at: 1, + ended_at: 4, + last_active: 4, + message_count: 4, + tool_call_count: 1, + input_tokens: 0, + output_tokens: 0, + cache_read_tokens: 0, + cache_write_tokens: 0, + reasoning_tokens: 0, + billing_provider: 'test-provider', + estimated_cost_usd: 0, + actual_cost_usd: null, + cost_status: 'none', + workspace: null, + } + await authenticate(page, TEST_ACCESS_KEY, 'research') + await page.addInitScript((sid) => { + ;(window as any).__PW_CHAT_SOCKET_RESUMES__ = { + [sid]: { + session_id: sid, + isWorking: false, + events: [], + messages: [ + { + id: 1, + session_id: sid, + role: 'user', + content: 'Resume named tool', + tool_call_id: null, + tool_calls: null, + tool_name: null, + timestamp: 1, + token_count: null, + finish_reason: null, + reasoning: null, + }, + { + id: 2, + session_id: sid, + role: 'assistant', + content: '', + tool_call_id: null, + tool_calls: [{ id: 'tool-resume-named-1', type: 'function', function: { name: 'read_file', arguments: JSON.stringify({ path: '/tmp/history.txt' }) } }], + tool_name: null, + timestamp: 2, + token_count: null, + finish_reason: 'tool_calls', + reasoning: null, + }, + { + id: 3, + session_id: sid, + role: 'tool', + content: JSON.stringify({ ok: true, path: '/tmp/history.txt' }), + tool_call_id: 'tool-resume-named-1', + tool_calls: null, + tool_name: null, + timestamp: 3, + token_count: null, + finish_reason: null, + reasoning: null, + }, + { + id: 4, + session_id: sid, + role: 'assistant', + content: 'Named history answer visible.', + tool_call_id: null, + tool_calls: null, + tool_name: null, + timestamp: 4, + token_count: null, + finish_reason: 'stop', + reasoning: null, + }, + ], + }, + } + }, sessionId) + const api = await mockHermesApi(page, { sessions: [sessionSummary] }) + await mockChatSocket(page) + + await page.goto('/#/hermes/chat') + + await expect(page.getByText('Named history answer visible.')).toBeVisible() + const restoredTrace = page.locator('.message.tool .tool-line').filter({ hasText: 'read_file' }) + await expect(restoredTrace).toHaveCount(1) + await restoredTrace.click() + await expect(page.locator('.message.tool .tool-details')).toContainText('/tmp/history.txt') + expect(api.unexpectedRequests).toEqual([]) +}) diff --git a/tests/e2e/fixtures.ts b/tests/e2e/fixtures.ts index 3143a71..e243799 100644 --- a/tests/e2e/fixtures.ts +++ b/tests/e2e/fixtures.ts @@ -13,6 +13,7 @@ export interface MockedRequest { interface MockHermesApiOptions { tokenValidationStatus?: number initialProfileName?: 'default' | 'research' + sessions?: unknown[] } const sampleModelGroup = { @@ -102,7 +103,7 @@ export async function mockHermesApi(page: Page, options: MockHermesApiOptions = } if (pathname === '/api/hermes/sessions') { - await route.fulfill(jsonResponse({ sessions: [] }, tokenValidationStatus)) + await route.fulfill(jsonResponse({ sessions: options.sessions ?? [] }, tokenValidationStatus)) return } @@ -249,6 +250,14 @@ function makeSocket(url, options) { }, emit(event, payload) { state.emitted.push({ event, payload }) + if (event === 'resume') { + const sessionId = payload && payload.session_id + const resumes = window.__PW_CHAT_SOCKET_RESUMES__ || {} + const response = sessionId ? resumes[sessionId] : null + if (response) { + setTimeout(() => this.__trigger('resumed', response), 0) + } + } return this }, removeAllListeners() {