diff --git a/packages/client/src/components/layout/ProfileSelector.vue b/packages/client/src/components/layout/ProfileSelector.vue
index fcf1e57..929d3da 100644
--- a/packages/client/src/components/layout/ProfileSelector.vue
+++ b/packages/client/src/components/layout/ProfileSelector.vue
@@ -41,6 +41,7 @@ onMounted(() => {
{{ t('sidebar.profiles') }}
{
+ const state = (window as any).__PW_CHAT_SOCKET__
+ const runs = state?.emitted?.filter((item: any) => item.event === 'run') || []
+ const run = runs[runIndex]
+ return run
+ ? {
+ socket: {
+ url: state.latest.url,
+ options: state.latest.options,
+ },
+ run: run.payload,
+ runCount: runs.length,
+ socketCount: state.sockets.length,
+ }
+ : null
+ }, index)
+ return handle.jsonValue() as Promise
+}
+
test('sends a chat run and renders streamed Socket.IO response events', async ({ page }) => {
await authenticate(page, TEST_ACCESS_KEY, 'research')
const api = await mockHermesApi(page)
@@ -8,40 +37,23 @@ test('sends a chat run and renders streamed Socket.IO response events', async ({
await page.goto('/#/hermes/chat')
- const input = page.getByPlaceholder('Type a message... (Enter to send, Shift+Enter for new line)')
- await expect(input).toBeVisible()
- await input.fill('Summarize the queue')
- await page.getByRole('button', { name: 'Send' }).click()
+ await sendChatMessage(page, 'Summarize the queue')
await expect(page.locator('p').filter({ hasText: /^Summarize the queue$/ })).toBeVisible()
- const socketState = await page.waitForFunction(() => {
- const state = (window as any).__PW_CHAT_SOCKET__
- return state?.emitted?.some((item: any) => item.event === 'run')
- ? {
- socket: {
- url: state.latest.url,
- options: state.latest.options,
- },
- emitted: state.emitted,
- }
- : null
- })
- const { socket, emitted } = await socketState.jsonValue() as any
- const run = emitted.find((item: any) => item.event === 'run')
+ const { socket, run } = await waitForRun(page)
expect(socket.url).toBe('/chat-run')
expect(socket.options.auth).toEqual({ token: TEST_ACCESS_KEY })
expect(socket.options.query).toEqual({ profile: 'research' })
- expect(run.payload).toMatchObject({
+ expect(run).toMatchObject({
input: 'Summarize the queue',
queue_id: expect.any(String),
session_id: expect.any(String),
source: 'api_server',
})
- expect(run.payload.model).toBe('test-model')
+ expect(run.model).toBe('test-model')
- const sessionId = run.payload.session_id
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-1' })
@@ -55,9 +67,135 @@ test('sends a chat run and renders streamed Socket.IO response events', async ({
inputTokens: 11,
outputTokens: 7,
})
- }, sessionId)
+ }, run.session_id)
await expect(page.getByText('Streaming answer from Hermes')).toBeVisible()
- await expect(page.getByRole('button', { name: 'Send' })).toBeVisible()
+ await expect(page.getByRole('button', { name: 'Stop' })).toHaveCount(0)
+ expect(api.unexpectedRequests).toEqual([])
+})
+
+test('uses the newly selected profile for the next chat-run socket after profile switch reload', async ({ page }) => {
+ await authenticate(page, TEST_ACCESS_KEY, 'default')
+ const api = await mockHermesApi(page, { initialProfileName: 'default' })
+ await mockChatSocket(page)
+
+ await page.goto('/#/hermes/chat')
+ await expect(page.getByTestId('profile-selector-select').filter({ hasText: 'default' })).toBeVisible()
+
+ await sendChatMessage(page, 'Warm up default socket')
+ const defaultRun = await waitForRun(page)
+ expect(defaultRun.socket.options.query).toEqual({ profile: 'default' })
+ 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-default' })
+ socket.__trigger('message.delta', { event: 'message.delta', session_id: sid, run_id: 'run-default', delta: 'Default profile reply' })
+ socket.__trigger('run.completed', {
+ event: 'run.completed',
+ session_id: sid,
+ run_id: 'run-default',
+ output: 'Default profile reply',
+ })
+ }, defaultRun.run.session_id)
+ await expect(page.getByRole('button', { name: 'Stop' })).toHaveCount(0)
+
+ await page.locator('[data-testid="profile-selector-select"] .n-base-selection').click()
+ const reloadPromise = page.waitForEvent('framenavigated', frame => frame === page.mainFrame())
+ await page.locator('.n-base-select-option', { hasText: /^research$/ }).click()
+ await reloadPromise
+ await page.waitForLoadState('domcontentloaded')
+ await expect(page.getByTestId('profile-selector-select').filter({ hasText: 'research' })).toBeVisible()
+
+ await sendChatMessage(page, 'Use the active research profile')
+ const { socket, run } = await waitForRun(page)
+
+ expect(socket.url).toBe('/chat-run')
+ expect(socket.options.auth).toEqual({ token: TEST_ACCESS_KEY })
+ expect(socket.options.query).toEqual({ profile: 'research' })
+ expect(run.input).toBe('Use the active research profile')
+ expect(await page.evaluate(() => window.localStorage.getItem('hermes_active_profile_name'))).toBe('research')
+
+ const switchRequest = api.requests.find((request) => request.pathname === '/api/hermes/profiles/active')
+ expect(switchRequest?.method).toBe('PUT')
+ expect(switchRequest?.postData).toBe(JSON.stringify({ name: 'research' }))
+ expect(api.unexpectedRequests).toEqual([])
+})
+
+test('keeps queued runs on one socket and does not duplicate streamed handlers', 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 queued contract')
+ 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-1', queue_length: 1 })
+ socket.__trigger('message.delta', { event: 'message.delta', session_id: sid, run_id: 'run-1', delta: 'First answer' })
+ }, first.run.session_id)
+ await expect(page.getByRole('button', { name: 'Stop' })).toBeVisible()
+
+ await sendChatMessage(page, 'Second queued contract')
+ const second = await waitForRun(page, 1)
+
+ expect(second.socketCount).toBe(1)
+ expect(second.runCount).toBe(2)
+ expect(second.run.session_id).toBe(first.run.session_id)
+ expect(second.run.input).toBe('Second queued contract')
+
+ await page.evaluate((sid) => {
+ const socket = (window as any).__PW_CHAT_SOCKET__.latest
+ socket.__trigger('run.completed', {
+ event: 'run.completed',
+ session_id: sid,
+ run_id: 'run-1',
+ output: 'First answer',
+ queue_remaining: 1,
+ })
+ socket.__trigger('run.started', { event: 'run.started', session_id: sid, run_id: 'run-2', queue_length: 0 })
+ socket.__trigger('message.delta', { event: 'message.delta', session_id: sid, run_id: 'run-2', delta: 'Second answer' })
+ socket.__trigger('run.completed', {
+ event: 'run.completed',
+ session_id: sid,
+ run_id: 'run-2',
+ output: 'Second answer',
+ queue_remaining: 0,
+ })
+ }, first.run.session_id)
+
+ await expect(page.locator('p').filter({ hasText: /^First answer$/ })).toHaveCount(1)
+ await expect(page.locator('p').filter({ hasText: /^Second queued contract$/ })).toHaveCount(1)
+ await expect(page.locator('p').filter({ hasText: /^Second answer$/ })).toHaveCount(1)
+ await expect(page.getByRole('button', { name: 'Stop' })).toHaveCount(0)
+ expect(api.unexpectedRequests).toEqual([])
+})
+
+test('surfaces an empty completed run as an error instead of leaving chat stalled', 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, 'Call a broken provider')
+ 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-empty' })
+ socket.__trigger('run.completed', {
+ event: 'run.completed',
+ session_id: sid,
+ run_id: 'run-empty',
+ output: '',
+ inputTokens: 0,
+ outputTokens: 0,
+ })
+ }, run.session_id)
+
+ await expect(page.getByText(/Agent returned no output/)).toBeVisible()
+ await expect(page.getByRole('button', { name: 'Send' })).toBeVisible()
+ await expect(page.getByRole('button', { name: 'Stop' })).toHaveCount(0)
expect(api.unexpectedRequests).toEqual([])
})
diff --git a/tests/e2e/fixtures.ts b/tests/e2e/fixtures.ts
index 2310f8a..0b28929 100644
--- a/tests/e2e/fixtures.ts
+++ b/tests/e2e/fixtures.ts
@@ -12,6 +12,7 @@ export interface MockedRequest {
interface MockHermesApiOptions {
tokenValidationStatus?: number
+ initialProfileName?: 'default' | 'research'
}
const sampleModelGroup = {
@@ -76,6 +77,7 @@ export async function mockHermesApi(page: Page, options: MockHermesApiOptions =
const requests: MockedRequest[] = []
const unexpectedRequests: MockedRequest[] = []
const tokenValidationStatus = options.tokenValidationStatus ?? 200
+ let activeProfileName = options.initialProfileName ?? 'research'
await page.route('**/*', async (route: Route) => {
const request = route.request()
@@ -144,13 +146,37 @@ export async function mockHermesApi(page: Page, options: MockHermesApiOptions =
if (pathname === '/api/hermes/profiles') {
await route.fulfill(jsonResponse({
profiles: [
- { name: 'default', active: false, model: 'test-model', gateway: 'test', alias: 'Default' },
- { name: 'research', active: true, model: 'test-model', gateway: 'test', alias: 'Research' },
+ { name: 'default', active: activeProfileName === 'default', model: 'test-model', gateway: 'test', alias: 'Default' },
+ { name: 'research', active: activeProfileName === 'research', model: 'test-model', gateway: 'test', alias: 'Research' },
],
}))
return
}
+ if (pathname === '/api/hermes/profiles/active') {
+ if (request.method() !== 'PUT') {
+ await route.fulfill(jsonResponse({ error: 'Method not allowed' }, 405))
+ return
+ }
+
+ let body: { name?: unknown }
+ try {
+ body = JSON.parse(request.postData() || '{}')
+ } catch {
+ await route.fulfill(jsonResponse({ error: 'Invalid JSON body' }, 400))
+ return
+ }
+
+ if (body.name !== 'default' && body.name !== 'research') {
+ await route.fulfill(jsonResponse({ error: 'Unknown profile' }, 400))
+ return
+ }
+
+ activeProfileName = body.name
+ await route.fulfill(jsonResponse({ success: true, active: activeProfileName }))
+ return
+ }
+
if (pathname === '/api/hermes/config') {
await route.fulfill(jsonResponse({
display: { streaming: true, show_reasoning: true, show_cost: true },
@@ -248,3 +274,82 @@ export default { io }
})
})
}
+
+export async function mockTerminalWebSocket(page: Page) {
+ await page.addInitScript(() => {
+ const state = (window as any).__PW_TERMINAL_WS__ = {
+ sockets: [] as any[],
+ sent: [] as any[],
+ createdCount: 0,
+ latest: null as any,
+ }
+ const RealEvent = window.Event
+ const RealMessageEvent = window.MessageEvent
+
+ class MockTerminalWebSocket extends EventTarget {
+ static CONNECTING = 0
+ static OPEN = 1
+ static CLOSING = 2
+ static CLOSED = 3
+
+ readonly CONNECTING = 0
+ readonly OPEN = 1
+ readonly CLOSING = 2
+ readonly CLOSED = 3
+ binaryType: BinaryType = 'blob'
+ bufferedAmount = 0
+ extensions = ''
+ protocol = ''
+ readyState = MockTerminalWebSocket.CONNECTING
+ onopen: ((event: Event) => void) | null = null
+ onmessage: ((event: MessageEvent) => void) | null = null
+ onerror: ((event: Event) => void) | null = null
+ onclose: ((event: CloseEvent) => void) | null = null
+
+ constructor(readonly url: string | URL) {
+ super()
+ state.sockets.push(this)
+ state.latest = this
+ setTimeout(() => {
+ this.readyState = MockTerminalWebSocket.OPEN
+ const openEvent = new RealEvent('open')
+ this.onopen?.(openEvent)
+ this.dispatchEvent(openEvent)
+ this.__createSession('term-1', 'zsh', 101)
+ }, 0)
+ }
+
+ send(data: string | ArrayBufferLike | Blob | ArrayBufferView) {
+ const normalized = typeof data === 'string' ? data : String(data)
+ state.sent.push({ socket: this.url.toString(), data: normalized })
+ if (normalized.charCodeAt(0) !== 0x7B) return
+ try {
+ const message = JSON.parse(normalized)
+ if (message.type === 'create') {
+ this.__createSession(`term-${state.createdCount + 1}`, 'bash', 200 + state.createdCount)
+ }
+ if (message.type === 'switch') {
+ this.__emitMessage(JSON.stringify({ type: 'switched', id: message.sessionId }))
+ }
+ } catch {}
+ }
+
+ close() {
+ this.readyState = MockTerminalWebSocket.CLOSED
+ }
+
+ __createSession(id: string, shell: string, pid: number) {
+ state.createdCount += 1
+ this.__emitMessage(JSON.stringify({ type: 'created', id, shell, pid }))
+ }
+
+ __emitMessage(data: string) {
+ const event = new RealMessageEvent('message', { data })
+ this.onmessage?.(event)
+ this.dispatchEvent(event)
+ }
+ }
+
+ ;(window as any).WebSocket = MockTerminalWebSocket
+ })
+}
diff --git a/tests/e2e/terminal.spec.ts b/tests/e2e/terminal.spec.ts
new file mode 100644
index 0000000..2ad21c4
--- /dev/null
+++ b/tests/e2e/terminal.spec.ts
@@ -0,0 +1,60 @@
+import { expect, test } from '@playwright/test'
+import { authenticate, mockHermesApi, mockTerminalWebSocket, TEST_ACCESS_KEY } from './fixtures'
+
+test('opens terminal websocket session and forwards user input', async ({ page }) => {
+ await authenticate(page, TEST_ACCESS_KEY, 'research')
+ const api = await mockHermesApi(page)
+ await mockTerminalWebSocket(page)
+
+ await page.goto('/#/hermes/terminal')
+
+ await expect(page.getByText('Sessions')).toBeVisible()
+ await expect(page.locator('.session-item-title', { hasText: 'zsh #1' })).toBeVisible()
+
+ const terminalState = await page.waitForFunction(() => {
+ const state = (window as any).__PW_TERMINAL_WS__
+ return state?.sockets?.length
+ ? {
+ url: state.latest.url,
+ sent: state.sent,
+ }
+ : null
+ })
+ const initialState = await terminalState.jsonValue() as any
+ const terminalUrl = new URL(initialState.url)
+ expect(terminalUrl.pathname).toBe('/api/hermes/terminal')
+ expect(terminalUrl.searchParams.get('token')).toBe(TEST_ACCESS_KEY)
+
+ await page.locator('.terminal-header .header-actions button').last().click()
+ await expect(page.locator('.session-item-title', { hasText: 'bash #2' })).toBeVisible()
+
+ await page.locator('.terminal-xterm').click()
+ await page.keyboard.type('pwd')
+ await page.keyboard.press('Enter')
+
+ await expect.poll(async () => page.evaluate(() => {
+ const state = (window as any).__PW_TERMINAL_WS__
+ return state.sent
+ .map((item: any) => item.data)
+ .filter((data: string) => !data.startsWith('{'))
+ .join('')
+ })).toContain('pwd')
+
+ await expect.poll(async () => page.evaluate(() => {
+ const state = (window as any).__PW_TERMINAL_WS__
+ return state.sent
+ .map((item: any) => item.data)
+ .filter((data: string) => data.startsWith('{'))
+ .map((data: string) => JSON.parse(data))
+ .some((message: any) => message.type === 'resize' && message.cols > 0 && message.rows > 0)
+ })).toBe(true)
+
+ const finalState = await page.evaluate(() => (window as any).__PW_TERMINAL_WS__)
+ const controlMessages = finalState.sent
+ .map((item: any) => item.data)
+ .filter((data: string) => data.startsWith('{'))
+ .map((data: string) => JSON.parse(data))
+
+ expect(controlMessages.some((message: any) => message.type === 'create')).toBe(true)
+ expect(api.unexpectedRequests).toEqual([])
+})