[codex] Add local tool trace toggle (#806)
* test: harden tool approval browser contract * test: cover tool trace display edge cases * test: cover resumed tool trace edge cases * feat: hide tool traces by default * Add local tool trace toggle --------- Co-authored-by: Zhicheng Han <zhicheng.han@mathematik.uni-goettingen.de>
This commit is contained in:
@@ -8,10 +8,12 @@ import { setModelContext } from '@/api/hermes/model-context'
|
|||||||
import { NButton, NTooltip, NSwitch, NModal, NInputNumber, useMessage } from 'naive-ui'
|
import { NButton, NTooltip, NSwitch, NModal, NInputNumber, useMessage } from 'naive-ui'
|
||||||
import { computed, ref, nextTick, onMounted, onUnmounted, watch } from 'vue'
|
import { computed, ref, nextTick, onMounted, onUnmounted, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useToolTraceVisibility } from '@/composables/useToolTraceVisibility'
|
||||||
|
|
||||||
const chatStore = useChatStore()
|
const chatStore = useChatStore()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
|
const { toolTraceVisible, toggleToolTraceVisible } = useToolTraceVisibility()
|
||||||
const inputText = ref('')
|
const inputText = ref('')
|
||||||
const textareaRef = ref<HTMLTextAreaElement>()
|
const textareaRef = ref<HTMLTextAreaElement>()
|
||||||
const commandDropdownRef = ref<HTMLDivElement>()
|
const commandDropdownRef = ref<HTMLDivElement>()
|
||||||
@@ -430,6 +432,24 @@ function isImage(type: string): boolean {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<NTooltip trigger="hover">
|
||||||
|
<template #trigger>
|
||||||
|
<NButton
|
||||||
|
quaternary
|
||||||
|
size="tiny"
|
||||||
|
class="tool-trace-toggle"
|
||||||
|
:class="{ active: toolTraceVisible }"
|
||||||
|
:aria-label="toolTraceVisible ? t('chat.hideToolCalls') : t('chat.showToolCalls')"
|
||||||
|
@click="toggleToolTraceVisible"
|
||||||
|
>
|
||||||
|
<svg class="tool-trace-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M14.7 6.3a4.5 4.5 0 0 0-5.8 5.8L3.5 17.5a2.1 2.1 0 0 0 3 3l5.4-5.4a4.5 4.5 0 0 0 5.8-5.8l-3 3-3-3 3-3z"/>
|
||||||
|
</svg>
|
||||||
|
</NButton>
|
||||||
|
</template>
|
||||||
|
{{ toolTraceVisible ? t('chat.hideToolCalls') : t('chat.showToolCalls') }}
|
||||||
|
</NTooltip>
|
||||||
|
|
||||||
<span v-if="totalTokens > 0" class="context-info" :class="{ 'context-warning': usagePercent > 80 }">
|
<span v-if="totalTokens > 0" class="context-info" :class="{ 'context-warning': usagePercent > 80 }">
|
||||||
{{ formatTokens(totalTokens) }} /
|
{{ formatTokens(totalTokens) }} /
|
||||||
<NTooltip trigger="hover">
|
<NTooltip trigger="hover">
|
||||||
@@ -614,20 +634,65 @@ function isImage(type: string): boolean {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 0 8px;
|
padding: 0 0 0 8px;
|
||||||
border-left: 1px solid $border-light;
|
border-left: 1px solid $border-light;
|
||||||
margin-left: 4px;
|
margin-left: 4px;
|
||||||
|
|
||||||
.switch-label {
|
.switch-label {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
color: $text-muted;
|
justify-content: center;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
color: #999999;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|
||||||
svg {
|
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 {
|
.context-info {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ref, computed, watch, nextTick } from "vue";
|
|||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
import MessageItem from "./MessageItem.vue";
|
import MessageItem from "./MessageItem.vue";
|
||||||
import { useChatStore } from "@/stores/hermes/chat";
|
import { useChatStore } from "@/stores/hermes/chat";
|
||||||
|
import { useToolTraceVisibility } from "@/composables/useToolTraceVisibility";
|
||||||
import type { Session } from "@/stores/hermes/chat";
|
import type { Session } from "@/stores/hermes/chat";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -10,6 +11,7 @@ const props = defineProps<{
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
const chatStore = useChatStore();
|
const chatStore = useChatStore();
|
||||||
|
const { toolTraceVisible } = useToolTraceVisibility();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const listRef = ref<HTMLElement>();
|
const listRef = ref<HTMLElement>();
|
||||||
|
|
||||||
@@ -18,10 +20,10 @@ const activeSession = computed(() => props.session || chatStore.activeSession);
|
|||||||
|
|
||||||
const displayMessages = computed(() =>
|
const displayMessages = computed(() =>
|
||||||
(activeSession.value?.messages || []).filter((m) => {
|
(activeSession.value?.messages || []).filter((m) => {
|
||||||
// Filter out tool messages without name (internal use only)
|
// Tool messages without a name are internal use only and remain hidden.
|
||||||
if (m.role === 'tool' && !m.toolName) return false
|
if (m.role === 'tool') return toolTraceVisible.value && !!m.toolName
|
||||||
// Filter out messages with empty content (except tool messages)
|
// Filter out messages with empty content.
|
||||||
if (m.role !== 'tool' && !m.content?.trim()) return false
|
if (!m.content?.trim()) return false
|
||||||
return true
|
return true
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -19,7 +19,13 @@ import { useGlobalSpeech } from "@/composables/useSpeech";
|
|||||||
import { useVoiceSettings } from "@/composables/useVoiceSettings";
|
import { useVoiceSettings } from "@/composables/useVoiceSettings";
|
||||||
import { speedToEdgeRate, hzToEdgePitch } from "@/utils/ttsHelpers";
|
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 props = defineProps<{ message: Message; highlight?: boolean }>();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -353,19 +359,96 @@ type ToolPayload = {
|
|||||||
language?: string;
|
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<object>();
|
||||||
|
|
||||||
|
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<string, unknown>);
|
||||||
|
const result: Record<string, unknown> = {};
|
||||||
|
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 {
|
function formatToolPayload(raw?: string): ToolPayload {
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
return { full: "", display: "" };
|
return { full: "", display: "" };
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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 {
|
return {
|
||||||
full,
|
full,
|
||||||
display:
|
display,
|
||||||
full.length > TOOL_PAYLOAD_DISPLAY_LIMIT
|
|
||||||
? full.slice(0, TOOL_PAYLOAD_DISPLAY_LIMIT) + "\n" + t("chat.truncated")
|
|
||||||
: full,
|
|
||||||
language: "json",
|
language: "json",
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ import { useChatStore } from "@/stores/hermes/chat";
|
|||||||
import thinkingVideoLight from "@/assets/thinking-light.mp4";
|
import thinkingVideoLight from "@/assets/thinking-light.mp4";
|
||||||
import thinkingVideoDark from "@/assets/thinking-dark.mp4";
|
import thinkingVideoDark from "@/assets/thinking-dark.mp4";
|
||||||
import { useTheme } from "@/composables/useTheme";
|
import { useTheme } from "@/composables/useTheme";
|
||||||
|
import { useToolTraceVisibility } from "@/composables/useToolTraceVisibility";
|
||||||
|
|
||||||
const chatStore = useChatStore();
|
const chatStore = useChatStore();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { isDark } = useTheme();
|
const { isDark } = useTheme();
|
||||||
|
const { toolTraceVisible } = useToolTraceVisibility();
|
||||||
const listRef = ref<HTMLElement>();
|
const listRef = ref<HTMLElement>();
|
||||||
|
|
||||||
function formatTokens(n: number): string {
|
function formatTokens(n: number): string {
|
||||||
@@ -41,9 +43,16 @@ const currentToolCalls = computed(() => {
|
|||||||
return [...tools].reverse();
|
return [...tools].reverse();
|
||||||
});
|
});
|
||||||
|
|
||||||
const displayMessages = computed(() =>
|
const visibleToolCalls = computed(() =>
|
||||||
chatStore.messages.filter((m) => {
|
toolTraceVisible.value ? currentToolCalls.value.filter((tool) => !!tool.toolName) : [],
|
||||||
if (m.role === "tool") return false;
|
);
|
||||||
|
|
||||||
|
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 (
|
if (
|
||||||
m.role === "assistant" &&
|
m.role === "assistant" &&
|
||||||
m.isStreaming &&
|
m.isStreaming &&
|
||||||
@@ -54,8 +63,8 @@ const displayMessages = computed(() =>
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}),
|
});
|
||||||
);
|
});
|
||||||
|
|
||||||
const queuedMessages = computed(() => {
|
const queuedMessages = computed(() => {
|
||||||
const sid = chatStore.activeSessionId;
|
const sid = chatStore.activeSessionId;
|
||||||
@@ -171,7 +180,7 @@ watch(currentToolCalls, () => {
|
|||||||
playsinline
|
playsinline
|
||||||
class="thinking-video"
|
class="thinking-video"
|
||||||
/>
|
/>
|
||||||
<div v-if="currentToolCalls.length > 0 || chatStore.compressionState || chatStore.abortState" class="tool-calls-panel">
|
<div v-if="visibleToolCalls.length > 0 || chatStore.compressionState || chatStore.abortState" class="tool-calls-panel">
|
||||||
<!-- Abort indicator -->
|
<!-- Abort indicator -->
|
||||||
<div v-if="chatStore.abortState" class="tool-call-item compression-item">
|
<div v-if="chatStore.abortState" class="tool-call-item compression-item">
|
||||||
<svg
|
<svg
|
||||||
@@ -254,7 +263,7 @@ watch(currentToolCalls, () => {
|
|||||||
</div>
|
</div>
|
||||||
<!-- Tool calls -->
|
<!-- Tool calls -->
|
||||||
<div
|
<div
|
||||||
v-for="tc in currentToolCalls"
|
v-for="tc in visibleToolCalls"
|
||||||
:key="tc.id"
|
:key="tc.id"
|
||||||
class="tool-call-item"
|
class="tool-call-item"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -139,6 +139,8 @@ export default {
|
|||||||
destroy: 'Bridge-Agent für diese Sitzung freigeben',
|
destroy: 'Bridge-Agent für diese Sitzung freigeben',
|
||||||
},
|
},
|
||||||
attachFiles: 'Dateien anhangen',
|
attachFiles: 'Dateien anhangen',
|
||||||
|
showToolCalls: 'Tool-Aufrufe anzeigen',
|
||||||
|
hideToolCalls: 'Tool-Aufrufe ausblenden',
|
||||||
messageQueue: 'Nachrichtenwarteschlange',
|
messageQueue: 'Nachrichtenwarteschlange',
|
||||||
removeQueuedMessage: 'Nachricht aus Warteschlange entfernen',
|
removeQueuedMessage: 'Nachricht aus Warteschlange entfernen',
|
||||||
stop: 'Stopp',
|
stop: 'Stopp',
|
||||||
|
|||||||
@@ -153,6 +153,8 @@ export default {
|
|||||||
},
|
},
|
||||||
attachFiles: 'Attach files',
|
attachFiles: 'Attach files',
|
||||||
autoPlaySpeech: 'Auto-play voice',
|
autoPlaySpeech: 'Auto-play voice',
|
||||||
|
showToolCalls: 'Show tool calls',
|
||||||
|
hideToolCalls: 'Hide tool calls',
|
||||||
messageQueue: 'Message queue',
|
messageQueue: 'Message queue',
|
||||||
removeQueuedMessage: 'Remove queued message',
|
removeQueuedMessage: 'Remove queued message',
|
||||||
stop: 'Stop',
|
stop: 'Stop',
|
||||||
|
|||||||
@@ -139,6 +139,8 @@ export default {
|
|||||||
destroy: 'Liberar el agente Bridge de esta sesión',
|
destroy: 'Liberar el agente Bridge de esta sesión',
|
||||||
},
|
},
|
||||||
attachFiles: 'Adjuntar archivos',
|
attachFiles: 'Adjuntar archivos',
|
||||||
|
showToolCalls: 'Mostrar llamadas de herramientas',
|
||||||
|
hideToolCalls: 'Ocultar llamadas de herramientas',
|
||||||
messageQueue: 'Cola de mensajes',
|
messageQueue: 'Cola de mensajes',
|
||||||
removeQueuedMessage: 'Quitar mensaje de la cola',
|
removeQueuedMessage: 'Quitar mensaje de la cola',
|
||||||
stop: 'Detener',
|
stop: 'Detener',
|
||||||
|
|||||||
@@ -139,6 +139,8 @@ export default {
|
|||||||
destroy: 'Libérer l’agent Bridge de cette session',
|
destroy: 'Libérer l’agent Bridge de cette session',
|
||||||
},
|
},
|
||||||
attachFiles: 'Joindre des fichiers',
|
attachFiles: 'Joindre des fichiers',
|
||||||
|
showToolCalls: 'Afficher les appels d’outils',
|
||||||
|
hideToolCalls: 'Masquer les appels d’outils',
|
||||||
messageQueue: 'File de messages',
|
messageQueue: 'File de messages',
|
||||||
removeQueuedMessage: 'Retirer le message de la file',
|
removeQueuedMessage: 'Retirer le message de la file',
|
||||||
stop: 'Arreter',
|
stop: 'Arreter',
|
||||||
|
|||||||
@@ -139,6 +139,8 @@ export default {
|
|||||||
destroy: 'このセッションの Bridge Agent を解放',
|
destroy: 'このセッションの Bridge Agent を解放',
|
||||||
},
|
},
|
||||||
attachFiles: 'ファイルを添付',
|
attachFiles: 'ファイルを添付',
|
||||||
|
showToolCalls: 'ツール呼び出しを表示',
|
||||||
|
hideToolCalls: 'ツール呼び出しを非表示',
|
||||||
messageQueue: 'メッセージキュー',
|
messageQueue: 'メッセージキュー',
|
||||||
removeQueuedMessage: 'キューのメッセージを削除',
|
removeQueuedMessage: 'キューのメッセージを削除',
|
||||||
stop: '停止',
|
stop: '停止',
|
||||||
|
|||||||
@@ -139,6 +139,8 @@ export default {
|
|||||||
destroy: '이 세션의 Bridge Agent 해제',
|
destroy: '이 세션의 Bridge Agent 해제',
|
||||||
},
|
},
|
||||||
attachFiles: '파일 첨부',
|
attachFiles: '파일 첨부',
|
||||||
|
showToolCalls: '도구 호출 표시',
|
||||||
|
hideToolCalls: '도구 호출 숨기기',
|
||||||
messageQueue: '메시지 대기열',
|
messageQueue: '메시지 대기열',
|
||||||
removeQueuedMessage: '대기열 메시지 제거',
|
removeQueuedMessage: '대기열 메시지 제거',
|
||||||
stop: '중지',
|
stop: '중지',
|
||||||
|
|||||||
@@ -139,6 +139,8 @@ export default {
|
|||||||
destroy: 'Liberar o Bridge Agent desta sessão',
|
destroy: 'Liberar o Bridge Agent desta sessão',
|
||||||
},
|
},
|
||||||
attachFiles: 'Anexar arquivos',
|
attachFiles: 'Anexar arquivos',
|
||||||
|
showToolCalls: 'Mostrar chamadas de ferramentas',
|
||||||
|
hideToolCalls: 'Ocultar chamadas de ferramentas',
|
||||||
messageQueue: 'Fila de mensagens',
|
messageQueue: 'Fila de mensagens',
|
||||||
removeQueuedMessage: 'Remover mensagem da fila',
|
removeQueuedMessage: 'Remover mensagem da fila',
|
||||||
stop: 'Parar',
|
stop: 'Parar',
|
||||||
|
|||||||
@@ -152,6 +152,8 @@ export default {
|
|||||||
},
|
},
|
||||||
attachFiles: '新增附件',
|
attachFiles: '新增附件',
|
||||||
autoPlaySpeech: '自動播放語音',
|
autoPlaySpeech: '自動播放語音',
|
||||||
|
showToolCalls: '顯示工具呼叫',
|
||||||
|
hideToolCalls: '隱藏工具呼叫',
|
||||||
messageQueue: '訊息佇列',
|
messageQueue: '訊息佇列',
|
||||||
removeQueuedMessage: '移除佇列訊息',
|
removeQueuedMessage: '移除佇列訊息',
|
||||||
stop: '停止',
|
stop: '停止',
|
||||||
|
|||||||
@@ -153,6 +153,8 @@ export default {
|
|||||||
},
|
},
|
||||||
attachFiles: '添加附件',
|
attachFiles: '添加附件',
|
||||||
autoPlaySpeech: '自动播放语音',
|
autoPlaySpeech: '自动播放语音',
|
||||||
|
showToolCalls: '显示工具调用',
|
||||||
|
hideToolCalls: '隐藏工具调用',
|
||||||
messageQueue: '消息队列',
|
messageQueue: '消息队列',
|
||||||
removeQueuedMessage: '移除队列消息',
|
removeQueuedMessage: '移除队列消息',
|
||||||
stop: '停止',
|
stop: '停止',
|
||||||
|
|||||||
@@ -136,10 +136,11 @@ async function buildContentBlocks(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function mapHermesMessages(msgs: HermesMessage[]): Message[] {
|
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 => {
|
const filteredMsgs = msgs.filter(m => {
|
||||||
if (m.role === 'assistant') {
|
if (m.role === 'assistant') {
|
||||||
return m.content && m.content.trim() !== ''
|
return (m.tool_calls?.length || 0) > 0 || (m.content && m.content.trim() !== '')
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
@@ -169,7 +170,7 @@ function mapHermesMessages(msgs: HermesMessage[]): Message[] {
|
|||||||
role: 'tool',
|
role: 'tool',
|
||||||
content: '',
|
content: '',
|
||||||
timestamp: Math.round(msg.timestamp * 1000),
|
timestamp: Math.round(msg.timestamp * 1000),
|
||||||
toolName: tc.function?.name || 'tool',
|
toolName: tc.function?.name || undefined,
|
||||||
toolCallId: tc.id,
|
toolCallId: tc.id,
|
||||||
toolArgs: tc.function?.arguments || undefined,
|
toolArgs: tc.function?.arguments || undefined,
|
||||||
toolStatus: 'done',
|
toolStatus: 'done',
|
||||||
@@ -181,7 +182,7 @@ function mapHermesMessages(msgs: HermesMessage[]): Message[] {
|
|||||||
// Tool result messages
|
// Tool result messages
|
||||||
if (msg.role === 'tool') {
|
if (msg.role === 'tool') {
|
||||||
const tcId = msg.tool_call_id || ''
|
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
|
const toolArgs = toolArgsMap.get(tcId) || undefined
|
||||||
// Extract a short preview from the content
|
// Extract a short preview from the content
|
||||||
let preview = ''
|
let preview = ''
|
||||||
|
|||||||
@@ -120,9 +120,11 @@ describe('MessageItem tool details', () => {
|
|||||||
|
|
||||||
const expected = JSON.stringify(message, null, 2)
|
const expected = JSON.stringify(message, null, 2)
|
||||||
const code = wrapper.find('.tool-details code.hljs')
|
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.find('.tool-details .code-lang').text()).toBe('json')
|
||||||
expect(wrapper.html()).toContain('chat.truncated')
|
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')
|
await wrapper.find('.tool-details [data-copy-code="true"]').trigger('click')
|
||||||
expect(writeText).toHaveBeenCalledWith(expected)
|
expect(writeText).toHaveBeenCalledWith(expected)
|
||||||
@@ -150,14 +152,45 @@ describe('MessageItem tool details', () => {
|
|||||||
|
|
||||||
await wrapper.find('.tool-line').trigger('click')
|
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.find('.tool-details .code-lang').text()).toBe('json')
|
||||||
expect(wrapper.html()).toContain('chat.truncated')
|
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')
|
await wrapper.find('.tool-details [data-copy-code="true"]').trigger('click')
|
||||||
expect(writeText).toHaveBeenCalledWith(JSON.stringify(fullResult, null, 2))
|
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 () => {
|
it('copies the full large raw tool result even when the display is truncated', async () => {
|
||||||
const writeText = vi.mocked(navigator.clipboard.writeText)
|
const writeText = vi.mocked(navigator.clipboard.writeText)
|
||||||
const fullResult = 'line\n'.repeat(1200)
|
const fullResult = 'line\n'.repeat(1200)
|
||||||
@@ -177,9 +210,11 @@ describe('MessageItem tool details', () => {
|
|||||||
|
|
||||||
await wrapper.find('.tool-line').trigger('click')
|
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.find('.tool-details .code-lang').text()).toBe('text')
|
||||||
expect(wrapper.html()).toContain('chat.truncated')
|
expect(code.text()).toBe(displayedResult)
|
||||||
expect(wrapper.find('.tool-details code.hljs').findAll('span')).toHaveLength(0)
|
expect(code.findAll('span')).toHaveLength(0)
|
||||||
|
|
||||||
await wrapper.find('.tool-details [data-copy-code="true"]').trigger('click')
|
await wrapper.find('.tool-details [data-copy-code="true"]').trigger('click')
|
||||||
expect(writeText).toHaveBeenCalledWith(fullResult)
|
expect(writeText).toHaveBeenCalledWith(fullResult)
|
||||||
|
|||||||
@@ -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: '<div class="stub-message" :data-role="message.role" :data-id="message.id">{{ message.toolName || message.content }}</div>',
|
||||||
|
})
|
||||||
|
|
||||||
|
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',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
await expect(page.getByRole('button', { name: 'Stop' })).toHaveCount(0)
|
||||||
expect(api.unexpectedRequests).toEqual([])
|
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([])
|
||||||
|
})
|
||||||
|
|||||||
+10
-1
@@ -13,6 +13,7 @@ export interface MockedRequest {
|
|||||||
interface MockHermesApiOptions {
|
interface MockHermesApiOptions {
|
||||||
tokenValidationStatus?: number
|
tokenValidationStatus?: number
|
||||||
initialProfileName?: 'default' | 'research'
|
initialProfileName?: 'default' | 'research'
|
||||||
|
sessions?: unknown[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const sampleModelGroup = {
|
const sampleModelGroup = {
|
||||||
@@ -102,7 +103,7 @@ export async function mockHermesApi(page: Page, options: MockHermesApiOptions =
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (pathname === '/api/hermes/sessions') {
|
if (pathname === '/api/hermes/sessions') {
|
||||||
await route.fulfill(jsonResponse({ sessions: [] }, tokenValidationStatus))
|
await route.fulfill(jsonResponse({ sessions: options.sessions ?? [] }, tokenValidationStatus))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,6 +250,14 @@ function makeSocket(url, options) {
|
|||||||
},
|
},
|
||||||
emit(event, payload) {
|
emit(event, payload) {
|
||||||
state.emitted.push({ 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
|
return this
|
||||||
},
|
},
|
||||||
removeAllListeners() {
|
removeAllListeners() {
|
||||||
|
|||||||
Reference in New Issue
Block a user