mirror of
https://github.com/EKKOLearnAI/hermes-web-ui.git
synced 2026-07-16 13:40:14 +00:00
695 lines
24 KiB
TypeScript
695 lines
24 KiB
TypeScript
// @vitest-environment jsdom
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { createPinia, setActivePinia } from 'pinia'
|
|
import { nextTick } from 'vue'
|
|
|
|
const chatApi = vi.hoisted(() => ({
|
|
startRunViaSocket: vi.fn(),
|
|
resumeSession: vi.fn(),
|
|
registerSessionHandlers: vi.fn(),
|
|
unregisterSessionHandlers: vi.fn(),
|
|
}))
|
|
|
|
vi.mock('@/api/hermes/chat', () => ({
|
|
startRunViaSocket: chatApi.startRunViaSocket,
|
|
resumeSession: chatApi.resumeSession,
|
|
registerSessionHandlers: chatApi.registerSessionHandlers,
|
|
unregisterSessionHandlers: chatApi.unregisterSessionHandlers,
|
|
getChatRunSocket: vi.fn(() => ({ emit: vi.fn() })),
|
|
respondToolApproval: vi.fn(),
|
|
respondClarify: vi.fn(),
|
|
onPeerUserMessage: vi.fn(() => vi.fn()),
|
|
onSessionCommand: vi.fn(() => vi.fn()),
|
|
onSessionTitleUpdated: vi.fn(() => vi.fn()),
|
|
}))
|
|
|
|
vi.mock('@/api/client', () => ({
|
|
getActiveProfileName: () => 'default',
|
|
hasApiKey: () => false,
|
|
}))
|
|
|
|
vi.mock('@/api/hermes/sessions', () => ({
|
|
archiveSession: vi.fn(),
|
|
deleteSession: vi.fn(),
|
|
fetchSession: vi.fn(),
|
|
fetchSessions: vi.fn(),
|
|
fetchWorkspaceRunChangesForSession: vi.fn(async () => []),
|
|
fetchWorkspaceRunChangeFile: vi.fn(async () => null),
|
|
setSessionModel: vi.fn(),
|
|
}))
|
|
|
|
vi.mock('@/api/hermes/download', () => ({
|
|
getDownloadUrl: (_path: string, name: string) => `/download/${name}`,
|
|
}))
|
|
|
|
vi.mock('@/api/hermes/system', () => ({
|
|
checkHealth: vi.fn(),
|
|
fetchAvailableModels: vi.fn(),
|
|
addCustomModel: vi.fn(),
|
|
removeCustomModel: vi.fn(),
|
|
updateDefaultModel: vi.fn(),
|
|
updateModelVisibility: vi.fn(),
|
|
triggerUpdate: vi.fn(),
|
|
updateModelAlias: vi.fn(),
|
|
}))
|
|
|
|
vi.mock('@/utils/completion-sound', () => ({
|
|
primeCompletionSound: vi.fn(),
|
|
playCompletionSound: vi.fn(),
|
|
}))
|
|
|
|
import { useChatStore, type Message, type Session } from '@/stores/hermes/chat'
|
|
|
|
function makeSession(id: string): Session {
|
|
return {
|
|
id,
|
|
title: id,
|
|
messages: [],
|
|
createdAt: Date.now(),
|
|
updatedAt: Date.now(),
|
|
}
|
|
}
|
|
|
|
describe('chat store compression state', () => {
|
|
let handlers: any
|
|
|
|
beforeEach(() => {
|
|
handlers = undefined
|
|
vi.resetAllMocks()
|
|
setActivePinia(createPinia())
|
|
chatApi.startRunViaSocket.mockReturnValue({ abort: vi.fn() })
|
|
chatApi.resumeSession.mockImplementation((sessionId: string, onResumed: (data: any) => void) => {
|
|
onResumed({
|
|
session_id: sessionId,
|
|
messages: [],
|
|
isWorking: sessionId === 'session-1',
|
|
events: [],
|
|
})
|
|
return {} as any
|
|
})
|
|
chatApi.registerSessionHandlers.mockImplementation((_sessionId: string, registeredHandlers: any) => {
|
|
handlers = registeredHandlers
|
|
return vi.fn()
|
|
})
|
|
})
|
|
|
|
it('does not show a background session compression indicator in the active session', async () => {
|
|
const store = useChatStore()
|
|
store.sessions = [makeSession('session-1'), makeSession('session-2')]
|
|
|
|
await store.switchSession('session-1')
|
|
const sessionHandlers = chatApi.registerSessionHandlers.mock.calls.find(call => call[0] === 'session-1')?.[1]
|
|
expect(sessionHandlers).toBeTruthy()
|
|
|
|
await store.switchSession('session-2')
|
|
sessionHandlers.onCompressionStarted({
|
|
event: 'compression.started',
|
|
session_id: 'session-1',
|
|
message_count: 6,
|
|
token_count: 1234,
|
|
})
|
|
|
|
expect(store.activeSessionId).toBe('session-2')
|
|
expect(store.compressionState).toBeNull()
|
|
|
|
await store.switchSession('session-1')
|
|
expect(store.compressionState).toEqual(expect.objectContaining({
|
|
compressing: true,
|
|
messageCount: 6,
|
|
beforeTokens: 1234,
|
|
}))
|
|
})
|
|
|
|
it('surfaces non-terminal reattach warnings replayed in a non-working resume payload', async () => {
|
|
chatApi.resumeSession.mockImplementationOnce((sessionId: string, onResumed: (data: any) => void) => {
|
|
onResumed({
|
|
session_id: sessionId,
|
|
messages: [],
|
|
isWorking: false,
|
|
events: [{
|
|
event: 'run.reattach_failed',
|
|
data: {
|
|
event: 'run.reattach_failed',
|
|
session_id: sessionId,
|
|
error: 'connect ECONNREFUSED configured endpoint',
|
|
message: 'Unable to confirm Agent Bridge status while resuming: connect ECONNREFUSED configured endpoint',
|
|
text: 'Unable to confirm Agent Bridge status while resuming: connect ECONNREFUSED configured endpoint',
|
|
},
|
|
}],
|
|
})
|
|
return {} as any
|
|
})
|
|
const store = useChatStore()
|
|
store.sessions = [makeSession('session-reattach')]
|
|
|
|
await store.switchSession('session-reattach')
|
|
await nextTick()
|
|
|
|
expect(store.activeSession?.messages).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({
|
|
role: 'system',
|
|
commandAction: 'agent.event',
|
|
content: 'Unable to confirm Agent Bridge status while resuming: connect ECONNREFUSED configured endpoint',
|
|
}),
|
|
]))
|
|
})
|
|
|
|
it('preserves streamed content when run.completed parsed_content is blank', async () => {
|
|
const store = useChatStore()
|
|
store.sessions = [makeSession('session-1')]
|
|
await store.switchSession('session-1')
|
|
store.activeSession!.messages = [{
|
|
id: 'a1',
|
|
role: 'assistant',
|
|
content: 'final answer',
|
|
timestamp: Date.now(),
|
|
isStreaming: true,
|
|
} as any]
|
|
|
|
handlers.onRunCompleted({ event: 'run.completed', parsed_content: '', output: '', run_id: 'run-1' })
|
|
await nextTick()
|
|
|
|
const assistant = store.activeSession?.messages.find((message: Message) => message.id === 'a1')
|
|
expect(assistant?.content).toBe('final answer')
|
|
expect(assistant?.isStreaming).toBe(false)
|
|
expect(store.activeSession?.messages.some(
|
|
(message: Message) => message.role === 'system' && message.content.includes('Agent returned no output'),
|
|
)).toBe(false)
|
|
})
|
|
|
|
it('renders parsed_content-only completion as a new assistant message and auto-plays it', async () => {
|
|
vi.useFakeTimers()
|
|
const dispatchSpy = vi.spyOn(window, 'dispatchEvent')
|
|
const store = useChatStore()
|
|
store.sessions = [makeSession('session-1')]
|
|
await store.switchSession('session-1')
|
|
store.setAutoPlaySpeech(true)
|
|
|
|
handlers.onRunCompleted({
|
|
event: 'run.completed',
|
|
parsed_content: 'final answer',
|
|
output: '',
|
|
run_id: 'run-parsed-only',
|
|
})
|
|
await nextTick()
|
|
await vi.advanceTimersByTimeAsync(300)
|
|
|
|
const assistantMessages = store.activeSession?.messages.filter((message: Message) => message.role === 'assistant') ?? []
|
|
expect(assistantMessages).toHaveLength(1)
|
|
expect(assistantMessages[0]?.content).toBe('final answer')
|
|
expect(store.activeSession?.messages.some(
|
|
(message: Message) => message.role === 'system' && message.content.includes('Agent returned no output'),
|
|
)).toBe(false)
|
|
expect(dispatchSpy).toHaveBeenCalledTimes(1)
|
|
const autoPlayEvent = dispatchSpy.mock.calls[0][0] as CustomEvent<{ messageId: string; content: string }>
|
|
expect(autoPlayEvent.type).toBe('auto-play-speech')
|
|
expect(autoPlayEvent.detail.content).toBe('final answer')
|
|
expect(autoPlayEvent.detail.messageId).toBe(assistantMessages[0]?.id)
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
it('does not treat an older assistant message as output for a blank run.completed event', async () => {
|
|
const dispatchSpy = vi.spyOn(window, 'dispatchEvent')
|
|
const store = useChatStore()
|
|
store.sessions = [makeSession('session-1')]
|
|
await store.switchSession('session-1')
|
|
store.setAutoPlaySpeech(true)
|
|
store.activeSession!.messages = [{
|
|
id: 'old-a1',
|
|
role: 'assistant',
|
|
content: 'previous answer',
|
|
timestamp: Date.now(),
|
|
isStreaming: false,
|
|
} as any]
|
|
|
|
handlers.onRunCompleted({ event: 'run.completed', parsed_content: '', output: '', run_id: 'run-2' })
|
|
await nextTick()
|
|
|
|
expect(store.activeSession?.messages.find((message: Message) => message.id === 'old-a1')?.content).toBe('previous answer')
|
|
expect(store.activeSession?.messages.some(
|
|
(message: Message) => message.role === 'system' && message.content.includes('Agent returned no output'),
|
|
)).toBe(true)
|
|
expect(dispatchSpy).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('renders parsed_content-only completion as a new assistant message after reconnect resume and auto-plays it', async () => {
|
|
vi.useFakeTimers()
|
|
const dispatchSpy = vi.spyOn(window, 'dispatchEvent')
|
|
const store = useChatStore()
|
|
const session = makeSession('session-1')
|
|
store.sessions = [session]
|
|
store.activeSessionId = 'session-1'
|
|
store.activeSession = session
|
|
store.setAutoPlaySpeech(true)
|
|
|
|
await store.sendMessage('resume run')
|
|
|
|
const onEvent = chatApi.startRunViaSocket.mock.calls[0][1] as (event: any) => void
|
|
const reconnectOptions = chatApi.startRunViaSocket.mock.calls[0][5] as { onReconnectResume: (data: any) => void }
|
|
|
|
reconnectOptions.onReconnectResume({
|
|
session_id: 'session-1',
|
|
isWorking: true,
|
|
messages: [],
|
|
events: [],
|
|
})
|
|
|
|
onEvent({
|
|
event: 'run.completed',
|
|
session_id: 'session-1',
|
|
parsed_content: 'final answer',
|
|
output: '',
|
|
run_id: 'run-reconnect-parsed-only',
|
|
})
|
|
await nextTick()
|
|
await vi.advanceTimersByTimeAsync(300)
|
|
|
|
const assistantMessages = store.activeSession?.messages.filter((message: Message) => message.role === 'assistant') ?? []
|
|
expect(assistantMessages).toHaveLength(1)
|
|
expect(assistantMessages[0]?.content).toBe('final answer')
|
|
expect(store.activeSession?.messages.some(
|
|
(message: Message) => message.role === 'system' && message.content.includes('Agent returned no output'),
|
|
)).toBe(false)
|
|
expect(dispatchSpy).toHaveBeenCalledTimes(1)
|
|
const autoPlayEvent = dispatchSpy.mock.calls[0][0] as CustomEvent<{ messageId: string; content: string }>
|
|
expect(autoPlayEvent.type).toBe('auto-play-speech')
|
|
expect(autoPlayEvent.detail.content).toBe('final answer')
|
|
expect(autoPlayEvent.detail.messageId).toBe(assistantMessages[0]?.id)
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
it('renders object-shaped run failure errors with their message text', async () => {
|
|
const store = useChatStore()
|
|
const session = makeSession('session-1')
|
|
store.sessions = [session]
|
|
store.activeSessionId = 'session-1'
|
|
store.activeSession = session
|
|
|
|
await store.sendMessage('run claude')
|
|
|
|
const onEvent = chatApi.startRunViaSocket.mock.calls[0][1] as (event: any) => void
|
|
onEvent({
|
|
event: 'run.failed',
|
|
session_id: 'session-1',
|
|
error: {
|
|
message: 'spawn claude ENOENT',
|
|
code: 'ENOENT',
|
|
},
|
|
})
|
|
|
|
const errorMessage = store.activeSession?.messages.find(
|
|
(message: Message) => message.role === 'assistant' && message.systemType === 'error',
|
|
)
|
|
expect(errorMessage?.content).toBe('Error: spawn claude ENOENT')
|
|
expect(errorMessage?.content).not.toContain('[object Object]')
|
|
})
|
|
|
|
it('appends post-reconnect deltas to a genuine resumed in-flight assistant instead of duplicating it', async () => {
|
|
const store = useChatStore()
|
|
const session = makeSession('session-1')
|
|
store.sessions = [session]
|
|
store.activeSessionId = 'session-1'
|
|
store.activeSession = session
|
|
|
|
await store.sendMessage('resume run')
|
|
|
|
const onEvent = chatApi.startRunViaSocket.mock.calls[0][1] as (event: any) => void
|
|
const reconnectOptions = chatApi.startRunViaSocket.mock.calls[0][5] as { onReconnectResume: (data: any) => void }
|
|
|
|
reconnectOptions.onReconnectResume({
|
|
session_id: 'session-1',
|
|
isWorking: true,
|
|
messages: [{
|
|
id: 'current-a1',
|
|
role: 'assistant',
|
|
content: 'partial answer',
|
|
timestamp: Date.now() / 1000,
|
|
finish_reason: null,
|
|
runMarker: 'cli_run_current',
|
|
}],
|
|
events: [],
|
|
})
|
|
|
|
onEvent({
|
|
event: 'message.delta',
|
|
session_id: 'session-1',
|
|
delta: ' continued',
|
|
run_id: 'run-reconnect-streaming',
|
|
})
|
|
await nextTick()
|
|
|
|
const assistantMessages = store.activeSession?.messages.filter((message: Message) => message.role === 'assistant') ?? []
|
|
expect(assistantMessages).toHaveLength(1)
|
|
expect(assistantMessages[0]).toEqual(expect.objectContaining({
|
|
id: 'current-a1',
|
|
content: 'partial answer continued',
|
|
isStreaming: true,
|
|
}))
|
|
})
|
|
|
|
it('closes a genuine resumed in-flight assistant on blank completion without swallowed-error or replay', async () => {
|
|
vi.useFakeTimers()
|
|
const dispatchSpy = vi.spyOn(window, 'dispatchEvent')
|
|
const store = useChatStore()
|
|
const session = makeSession('session-1')
|
|
store.sessions = [session]
|
|
store.activeSessionId = 'session-1'
|
|
store.activeSession = session
|
|
store.setAutoPlaySpeech(true)
|
|
|
|
await store.sendMessage('resume run')
|
|
|
|
const onEvent = chatApi.startRunViaSocket.mock.calls[0][1] as (event: any) => void
|
|
const reconnectOptions = chatApi.startRunViaSocket.mock.calls[0][5] as { onReconnectResume: (data: any) => void }
|
|
|
|
reconnectOptions.onReconnectResume({
|
|
session_id: 'session-1',
|
|
isWorking: true,
|
|
messages: [{
|
|
id: 'current-a1',
|
|
role: 'assistant',
|
|
content: 'partial answer',
|
|
timestamp: Date.now() / 1000,
|
|
finish_reason: null,
|
|
runMarker: 'cli_run_current',
|
|
}],
|
|
events: [],
|
|
})
|
|
|
|
onEvent({
|
|
event: 'run.completed',
|
|
session_id: 'session-1',
|
|
parsed_content: '',
|
|
output: '',
|
|
run_id: 'run-reconnect-blank-resumed-current',
|
|
})
|
|
await nextTick()
|
|
await vi.advanceTimersByTimeAsync(300)
|
|
|
|
const assistantMessages = store.activeSession?.messages.filter((message: Message) => message.role === 'assistant') ?? []
|
|
expect(assistantMessages).toHaveLength(1)
|
|
expect(assistantMessages[0]).toEqual(expect.objectContaining({
|
|
id: 'current-a1',
|
|
content: 'partial answer',
|
|
isStreaming: false,
|
|
}))
|
|
expect(store.activeSession?.messages.some(
|
|
(message: Message) => message.role === 'system' && message.content.includes('Agent returned no output'),
|
|
)).toBe(false)
|
|
expect(dispatchSpy).not.toHaveBeenCalled()
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
it('finalizes a genuine resumed in-flight assistant with parsed_content instead of appending a second row', async () => {
|
|
vi.useFakeTimers()
|
|
const dispatchSpy = vi.spyOn(window, 'dispatchEvent')
|
|
const store = useChatStore()
|
|
const session = makeSession('session-1')
|
|
store.sessions = [session]
|
|
store.activeSessionId = 'session-1'
|
|
store.activeSession = session
|
|
store.setAutoPlaySpeech(true)
|
|
|
|
await store.sendMessage('resume run')
|
|
|
|
const onEvent = chatApi.startRunViaSocket.mock.calls[0][1] as (event: any) => void
|
|
const reconnectOptions = chatApi.startRunViaSocket.mock.calls[0][5] as { onReconnectResume: (data: any) => void }
|
|
|
|
reconnectOptions.onReconnectResume({
|
|
session_id: 'session-1',
|
|
isWorking: true,
|
|
messages: [{
|
|
id: 'current-a1',
|
|
role: 'assistant',
|
|
content: 'partial answer',
|
|
timestamp: Date.now() / 1000,
|
|
finish_reason: null,
|
|
runMarker: 'cli_run_current',
|
|
}],
|
|
events: [],
|
|
})
|
|
|
|
onEvent({
|
|
event: 'run.completed',
|
|
session_id: 'session-1',
|
|
parsed_content: 'final answer',
|
|
output: '',
|
|
run_id: 'run-reconnect-current-parsed-content',
|
|
})
|
|
await nextTick()
|
|
await vi.advanceTimersByTimeAsync(300)
|
|
|
|
const assistantMessages = store.activeSession?.messages.filter((message: Message) => message.role === 'assistant') ?? []
|
|
expect(assistantMessages).toHaveLength(1)
|
|
expect(assistantMessages[0]).toEqual(expect.objectContaining({
|
|
id: 'current-a1',
|
|
content: 'final answer',
|
|
isStreaming: false,
|
|
}))
|
|
expect(dispatchSpy).toHaveBeenCalledTimes(1)
|
|
const autoPlayEvent = dispatchSpy.mock.calls[0][0] as CustomEvent<{ messageId: string; content: string }>
|
|
expect(autoPlayEvent.type).toBe('auto-play-speech')
|
|
expect(autoPlayEvent.detail.content).toBe('final answer')
|
|
expect(autoPlayEvent.detail.messageId).toBe('current-a1')
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
it('does not auto-play resumed assistant history again on blank completion after reconnect', async () => {
|
|
vi.useFakeTimers()
|
|
const dispatchSpy = vi.spyOn(window, 'dispatchEvent')
|
|
const store = useChatStore()
|
|
const session = makeSession('session-1')
|
|
session.messages = [{
|
|
id: 'old-a1',
|
|
role: 'assistant',
|
|
content: 'previous answer',
|
|
timestamp: Date.now(),
|
|
isStreaming: false,
|
|
} as any]
|
|
store.sessions = [session]
|
|
store.activeSessionId = 'session-1'
|
|
store.activeSession = session
|
|
store.setAutoPlaySpeech(true)
|
|
|
|
await store.sendMessage('resume run')
|
|
|
|
const onEvent = chatApi.startRunViaSocket.mock.calls[0][1] as (event: any) => void
|
|
const reconnectOptions = chatApi.startRunViaSocket.mock.calls[0][5] as { onReconnectResume: (data: any) => void }
|
|
|
|
reconnectOptions.onReconnectResume({
|
|
session_id: 'session-1',
|
|
isWorking: true,
|
|
messages: [{
|
|
id: 'old-a1',
|
|
role: 'assistant',
|
|
content: 'previous answer',
|
|
timestamp: Date.now() / 1000,
|
|
finish_reason: 'stop',
|
|
}],
|
|
events: [],
|
|
})
|
|
|
|
onEvent({
|
|
event: 'run.completed',
|
|
session_id: 'session-1',
|
|
parsed_content: '',
|
|
output: '',
|
|
run_id: 'run-reconnect-blank',
|
|
})
|
|
await nextTick()
|
|
await vi.advanceTimersByTimeAsync(300)
|
|
|
|
expect(store.activeSession?.messages.find((message: Message) => message.id === 'old-a1')?.content).toBe('previous answer')
|
|
expect(dispatchSpy).not.toHaveBeenCalled()
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
it('preserves resumed assistant history when parsed_content arrives after reconnect and auto-plays the new row', async () => {
|
|
vi.useFakeTimers()
|
|
const dispatchSpy = vi.spyOn(window, 'dispatchEvent')
|
|
const store = useChatStore()
|
|
const session = makeSession('session-1')
|
|
session.messages = [{
|
|
id: 'old-a1',
|
|
role: 'assistant',
|
|
content: 'previous answer',
|
|
timestamp: Date.now(),
|
|
isStreaming: false,
|
|
} as any]
|
|
store.sessions = [session]
|
|
store.activeSessionId = 'session-1'
|
|
store.activeSession = session
|
|
store.setAutoPlaySpeech(true)
|
|
|
|
await store.sendMessage('resume run')
|
|
|
|
const onEvent = chatApi.startRunViaSocket.mock.calls[0][1] as (event: any) => void
|
|
const reconnectOptions = chatApi.startRunViaSocket.mock.calls[0][5] as { onReconnectResume: (data: any) => void }
|
|
|
|
reconnectOptions.onReconnectResume({
|
|
session_id: 'session-1',
|
|
isWorking: true,
|
|
messages: [{
|
|
id: 'old-a1',
|
|
role: 'assistant',
|
|
content: 'previous answer',
|
|
timestamp: Date.now() / 1000,
|
|
}],
|
|
events: [],
|
|
})
|
|
|
|
onEvent({
|
|
event: 'run.completed',
|
|
session_id: 'session-1',
|
|
parsed_content: 'final answer',
|
|
output: '',
|
|
run_id: 'run-reconnect-historical-parsed-content',
|
|
})
|
|
await nextTick()
|
|
await vi.advanceTimersByTimeAsync(300)
|
|
|
|
const assistantMessages = store.activeSession?.messages.filter((message: Message) => message.role === 'assistant') ?? []
|
|
expect(assistantMessages).toHaveLength(2)
|
|
expect(assistantMessages[0]).toEqual(expect.objectContaining({
|
|
id: 'old-a1',
|
|
content: 'previous answer',
|
|
}))
|
|
expect(assistantMessages[1]).toEqual(expect.objectContaining({
|
|
content: 'final answer',
|
|
}))
|
|
expect(dispatchSpy).toHaveBeenCalledTimes(1)
|
|
const autoPlayEvent = dispatchSpy.mock.calls[0][0] as CustomEvent<{ messageId: string; content: string }>
|
|
expect(autoPlayEvent.type).toBe('auto-play-speech')
|
|
expect(autoPlayEvent.detail.content).toBe('final answer')
|
|
expect(autoPlayEvent.detail.messageId).toBe(assistantMessages[1]?.id)
|
|
expect(autoPlayEvent.detail.messageId).not.toBe('old-a1')
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
it('still auto-plays genuinely new assistant text after reconnect resume', async () => {
|
|
vi.useFakeTimers()
|
|
const dispatchSpy = vi.spyOn(window, 'dispatchEvent')
|
|
const store = useChatStore()
|
|
const session = makeSession('session-1')
|
|
session.messages = [{
|
|
id: 'old-a1',
|
|
role: 'assistant',
|
|
content: 'previous answer',
|
|
timestamp: Date.now(),
|
|
isStreaming: false,
|
|
} as any]
|
|
store.sessions = [session]
|
|
store.activeSessionId = 'session-1'
|
|
store.activeSession = session
|
|
store.setAutoPlaySpeech(true)
|
|
|
|
await store.sendMessage('resume run')
|
|
|
|
const onEvent = chatApi.startRunViaSocket.mock.calls[0][1] as (event: any) => void
|
|
const reconnectOptions = chatApi.startRunViaSocket.mock.calls[0][5] as { onReconnectResume: (data: any) => void }
|
|
|
|
reconnectOptions.onReconnectResume({
|
|
session_id: 'session-1',
|
|
isWorking: true,
|
|
messages: [{
|
|
id: 'old-a1',
|
|
role: 'assistant',
|
|
content: 'previous answer',
|
|
timestamp: Date.now() / 1000,
|
|
finish_reason: 'stop',
|
|
}],
|
|
events: [],
|
|
})
|
|
|
|
onEvent({
|
|
event: 'run.completed',
|
|
session_id: 'session-1',
|
|
output: 'new answer',
|
|
run_id: 'run-reconnect-new-output',
|
|
})
|
|
await nextTick()
|
|
await vi.advanceTimersByTimeAsync(300)
|
|
|
|
expect(dispatchSpy).toHaveBeenCalledTimes(1)
|
|
const autoPlayEvent = dispatchSpy.mock.calls[0][0] as CustomEvent<{ messageId: string; content: string }>
|
|
expect(autoPlayEvent.type).toBe('auto-play-speech')
|
|
expect(autoPlayEvent.detail.content).toBe('new answer')
|
|
expect(autoPlayEvent.detail.messageId).not.toBe('old-a1')
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
it('does not auto-play an older assistant message when a run only emits tool output', async () => {
|
|
vi.useFakeTimers()
|
|
const dispatchSpy = vi.spyOn(window, 'dispatchEvent')
|
|
const store = useChatStore()
|
|
store.sessions = [makeSession('session-1')]
|
|
await store.switchSession('session-1')
|
|
store.setAutoPlaySpeech(true)
|
|
store.activeSession!.messages = [{
|
|
id: 'old-a1',
|
|
role: 'assistant',
|
|
content: 'previous answer',
|
|
timestamp: Date.now(),
|
|
isStreaming: false,
|
|
} as any]
|
|
|
|
handlers.onToolStarted({ event: 'tool.started', tool: 'search', tool_call_id: 'tool-1', run_id: 'run-3' })
|
|
handlers.onToolCompleted({ event: 'tool.completed', tool_call_id: 'tool-1', output: { ok: true }, run_id: 'run-3' })
|
|
handlers.onRunCompleted({ event: 'run.completed', parsed_content: '', output: '', run_id: 'run-3' })
|
|
await nextTick()
|
|
await vi.advanceTimersByTimeAsync(300)
|
|
|
|
expect(dispatchSpy).not.toHaveBeenCalled()
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
it('auto-plays only the new assistant message when the current run produces one', async () => {
|
|
vi.useFakeTimers()
|
|
const dispatchSpy = vi.spyOn(window, 'dispatchEvent')
|
|
const store = useChatStore()
|
|
store.sessions = [makeSession('session-1')]
|
|
await store.switchSession('session-1')
|
|
store.setAutoPlaySpeech(true)
|
|
store.activeSession!.messages = [{
|
|
id: 'old-a1',
|
|
role: 'assistant',
|
|
content: 'previous answer',
|
|
timestamp: Date.now(),
|
|
isStreaming: false,
|
|
} as any]
|
|
|
|
handlers.onMessageDelta({ event: 'message.delta', delta: 'new answer', run_id: 'run-4' })
|
|
handlers.onRunCompleted({ event: 'run.completed', parsed_content: 'new answer', output: '', run_id: 'run-4' })
|
|
await nextTick()
|
|
await vi.advanceTimersByTimeAsync(300)
|
|
|
|
expect(dispatchSpy).toHaveBeenCalledTimes(1)
|
|
const autoPlayEvent = dispatchSpy.mock.calls[0][0] as CustomEvent<{ messageId: string; content: string }>
|
|
expect(autoPlayEvent.type).toBe('auto-play-speech')
|
|
expect(autoPlayEvent.detail.content).toBe('new answer')
|
|
expect(autoPlayEvent.detail.messageId).not.toBe('old-a1')
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
it('preserves non-string tool.completed outputs in live session handlers', async () => {
|
|
const store = useChatStore()
|
|
store.sessions = [makeSession('session-1')]
|
|
await store.switchSession('session-1')
|
|
store.activeSession!.messages = [{
|
|
id: 'tool-1',
|
|
role: 'tool',
|
|
content: '',
|
|
timestamp: Date.now(),
|
|
toolCallId: 'call-1',
|
|
toolStatus: 'running',
|
|
} as any]
|
|
|
|
handlers.onToolCompleted({ event: 'tool.completed', tool_call_id: 'call-1', output: { ok: true }, run_id: 'run-1' })
|
|
await nextTick()
|
|
|
|
const tool = store.activeSession?.messages.find((message: Message) => message.id === 'tool-1')
|
|
expect(tool?.toolStatus).toBe('done')
|
|
expect(tool?.toolResult).toEqual({ ok: true })
|
|
})
|
|
})
|