[codex] Fix profile-aware session deep links (#962)
* feat: add session deep links for chats * feat: add deep links for history and group chat * Fix profile-aware session deep links --------- Co-authored-by: Maxim Kirilyuk <werserk@inbox.ru>
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
import { expect, test, type Page } from '@playwright/test'
|
||||
import { authenticate, mockChatSocket, mockHermesApi, TEST_ACCESS_KEY } from './fixtures'
|
||||
|
||||
const inputPlaceholder = 'Type a message... (Enter to send, Shift+Enter for new line)'
|
||||
|
||||
type SessionSeed = {
|
||||
id: string
|
||||
title: string
|
||||
lastActive: number
|
||||
}
|
||||
|
||||
function sessionSummary({ id, title, lastActive }: SessionSeed) {
|
||||
return {
|
||||
id,
|
||||
profile: 'research',
|
||||
source: 'cli',
|
||||
model: 'test-model',
|
||||
provider: 'test-provider',
|
||||
title,
|
||||
preview: title,
|
||||
started_at: lastActive - 10,
|
||||
ended_at: null,
|
||||
last_active: lastActive,
|
||||
message_count: 1,
|
||||
tool_call_count: 0,
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
cache_read_tokens: 0,
|
||||
cache_write_tokens: 0,
|
||||
reasoning_tokens: 0,
|
||||
billing_provider: null,
|
||||
estimated_cost_usd: 0,
|
||||
actual_cost_usd: null,
|
||||
cost_status: 'estimated',
|
||||
}
|
||||
}
|
||||
|
||||
function resumePayload(sessionId: string, content: string) {
|
||||
return {
|
||||
session_id: sessionId,
|
||||
messages: [
|
||||
{
|
||||
id: 1,
|
||||
session_id: sessionId,
|
||||
role: 'user',
|
||||
content,
|
||||
timestamp: Date.now() / 1000,
|
||||
tool_call_id: null,
|
||||
tool_calls: null,
|
||||
tool_name: null,
|
||||
token_count: null,
|
||||
finish_reason: null,
|
||||
reasoning: null,
|
||||
},
|
||||
],
|
||||
isWorking: false,
|
||||
events: [],
|
||||
}
|
||||
}
|
||||
|
||||
const sessions = [
|
||||
sessionSummary({ id: 'session-a', title: 'Alpha chat', lastActive: 100 }),
|
||||
sessionSummary({ id: 'session-b', title: 'Beta chat', lastActive: 200 }),
|
||||
]
|
||||
|
||||
const resumes = {
|
||||
'session-a': resumePayload('session-a', 'Alpha route content'),
|
||||
'session-b': resumePayload('session-b', 'Beta route content'),
|
||||
}
|
||||
|
||||
async function setupChatPage(page: Page) {
|
||||
await authenticate(page, TEST_ACCESS_KEY, 'research')
|
||||
await page.addInitScript((payload) => {
|
||||
;(window as any).__PW_CHAT_SOCKET_RESUMES__ = payload
|
||||
window.localStorage.setItem('hermes_active_session_research', 'session-b')
|
||||
}, resumes)
|
||||
const api = await mockHermesApi(page, { sessions })
|
||||
await mockChatSocket(page)
|
||||
return api
|
||||
}
|
||||
|
||||
async function sendChatMessage(page: Page, message: string) {
|
||||
const input = page.getByPlaceholder(inputPlaceholder)
|
||||
await expect(input).toBeVisible()
|
||||
await input.fill(message)
|
||||
await page.getByRole('button', { name: 'Send' }).click()
|
||||
}
|
||||
|
||||
async function waitForRun(page: Page, index = 0) {
|
||||
const handle = await page.waitForFunction((runIndex) => {
|
||||
const state = (window as any).__PW_CHAT_SOCKET__
|
||||
const runs = state?.emitted?.filter((item: any) => item.event === 'run') || []
|
||||
const run = runs[runIndex]
|
||||
return run ? run.payload : null
|
||||
}, index)
|
||||
return handle.jsonValue() as Promise<any>
|
||||
}
|
||||
|
||||
test('route session id wins over shared active-session localStorage', async ({ page }) => {
|
||||
const api = await setupChatPage(page)
|
||||
|
||||
await page.goto('/#/hermes/session/session-a')
|
||||
|
||||
await expect(page.getByText('Alpha route content')).toBeVisible()
|
||||
await expect(page.getByText('Beta route content')).toHaveCount(0)
|
||||
await expect(page).toHaveURL(/#\/hermes\/session\/session-a$/)
|
||||
expect(api.unexpectedRequests).toEqual([])
|
||||
})
|
||||
|
||||
test('two tabs can show different sessions and keep them after reload', async ({ context }) => {
|
||||
const pageA = await context.newPage()
|
||||
const pageB = await context.newPage()
|
||||
const apiA = await setupChatPage(pageA)
|
||||
const apiB = await setupChatPage(pageB)
|
||||
|
||||
await pageA.goto('/#/hermes/session/session-a')
|
||||
await pageB.goto('/#/hermes/session/session-b')
|
||||
|
||||
await expect(pageA.getByText('Alpha route content')).toBeVisible()
|
||||
await expect(pageB.getByText('Beta route content')).toBeVisible()
|
||||
|
||||
await pageA.reload()
|
||||
await pageB.reload()
|
||||
|
||||
await expect(pageA.getByText('Alpha route content')).toBeVisible()
|
||||
await expect(pageB.getByText('Beta route content')).toBeVisible()
|
||||
await expect(pageA).toHaveURL(/#\/hermes\/session\/session-a$/)
|
||||
await expect(pageB).toHaveURL(/#\/hermes\/session\/session-b$/)
|
||||
expect(apiA.unexpectedRequests).toEqual([])
|
||||
expect(apiB.unexpectedRequests).toEqual([])
|
||||
})
|
||||
|
||||
test('parallel tabs send runs and render progress only for their own session', async ({ context }) => {
|
||||
const pageA = await context.newPage()
|
||||
const pageB = await context.newPage()
|
||||
const apiA = await setupChatPage(pageA)
|
||||
const apiB = await setupChatPage(pageB)
|
||||
|
||||
await pageA.goto('/#/hermes/session/session-a')
|
||||
await pageB.goto('/#/hermes/session/session-b')
|
||||
await expect(pageA.getByText('Alpha route content')).toBeVisible()
|
||||
await expect(pageB.getByText('Beta route content')).toBeVisible()
|
||||
|
||||
await sendChatMessage(pageA, 'Question for Alpha')
|
||||
await sendChatMessage(pageB, 'Question for Beta')
|
||||
|
||||
const runA = await waitForRun(pageA)
|
||||
const runB = await waitForRun(pageB)
|
||||
expect(runA.session_id).toBe('session-a')
|
||||
expect(runB.session_id).toBe('session-b')
|
||||
|
||||
await pageA.evaluate((sid) => {
|
||||
const socket = (window as any).__PW_CHAT_SOCKET__.latest
|
||||
socket.__trigger('run.started', { event: 'run.started', session_id: sid, run_id: 'run-a' })
|
||||
socket.__trigger('message.delta', { event: 'message.delta', session_id: sid, run_id: 'run-a', delta: 'Alpha progress' })
|
||||
}, runA.session_id)
|
||||
await pageB.evaluate((sid) => {
|
||||
const socket = (window as any).__PW_CHAT_SOCKET__.latest
|
||||
socket.__trigger('run.started', { event: 'run.started', session_id: sid, run_id: 'run-b' })
|
||||
socket.__trigger('message.delta', { event: 'message.delta', session_id: sid, run_id: 'run-b', delta: 'Beta progress' })
|
||||
}, runB.session_id)
|
||||
|
||||
await expect(pageA.getByText('Alpha progress')).toBeVisible()
|
||||
await expect(pageA.getByText('Beta progress')).toHaveCount(0)
|
||||
await expect(pageB.getByText('Beta progress')).toBeVisible()
|
||||
await expect(pageB.getByText('Alpha progress')).toHaveCount(0)
|
||||
expect(apiA.unexpectedRequests).toEqual([])
|
||||
expect(apiB.unexpectedRequests).toEqual([])
|
||||
})
|
||||
@@ -0,0 +1,153 @@
|
||||
import { expect, test, type Page, type Route } from '@playwright/test'
|
||||
import { authenticate } from './fixtures'
|
||||
|
||||
const rooms = [
|
||||
{ id: 'room-alpha', name: 'Alpha Room', inviteCode: 'ALPHA1', triggerTokens: 100000, maxHistoryTokens: 32000, tailMessageCount: 10, totalTokens: 123 },
|
||||
{ id: 'room-beta', name: 'Beta Room', inviteCode: 'BETA22', triggerTokens: 100000, maxHistoryTokens: 32000, tailMessageCount: 10, totalTokens: 456 },
|
||||
]
|
||||
|
||||
const messagesByRoom: Record<string, unknown[]> = {
|
||||
'room-alpha': [
|
||||
{ id: 'alpha-msg', roomId: 'room-alpha', senderId: 'user-1', senderName: 'Alice', content: 'Alpha room message', timestamp: 1_790_000_000, role: 'user' },
|
||||
],
|
||||
'room-beta': [
|
||||
{ id: 'beta-msg', roomId: 'room-beta', senderId: 'user-1', senderName: 'Bob', content: 'Beta room message', timestamp: 1_790_000_100, role: 'user' },
|
||||
],
|
||||
}
|
||||
|
||||
async function mockGroupChatApi(page: Page) {
|
||||
await page.route('**/*', async (route: Route) => {
|
||||
const request = route.request()
|
||||
const url = new URL(request.url())
|
||||
const { pathname } = url
|
||||
|
||||
if (!(pathname === '/health' || pathname.startsWith('/api/'))) {
|
||||
await route.continue()
|
||||
return
|
||||
}
|
||||
|
||||
const json = (body: unknown, status = 200) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) })
|
||||
|
||||
if (pathname === '/health') return json({ status: 'ok' })
|
||||
if (pathname === '/api/auth/status') return json({ hasPasswordLogin: false, username: null })
|
||||
if (pathname === '/api/hermes/profiles') return json({ profiles: [{ name: 'default', active: true, model: 'test-model', gateway: 'test' }] })
|
||||
if (pathname === '/api/hermes/group-chat/rooms') return json({ rooms })
|
||||
|
||||
const detailMatch = pathname.match(/^\/api\/hermes\/group-chat\/rooms\/([^/]+)$/)
|
||||
if (detailMatch) {
|
||||
const roomId = decodeURIComponent(detailMatch[1])
|
||||
const room = rooms.find(r => r.id === roomId)
|
||||
return room
|
||||
? json({ room, messages: messagesByRoom[roomId] || [], agents: [], members: [{ id: 'member-1', userId: 'user-1', name: 'User One', description: '', joinedAt: 1_790_000_000 }] })
|
||||
: json({ error: 'Room not found' }, 404)
|
||||
}
|
||||
|
||||
return json({ error: `Unexpected mocked route: ${request.method()} ${pathname}` }, 404)
|
||||
})
|
||||
}
|
||||
|
||||
async function mockGroupChatSocket(page: Page) {
|
||||
await page.route('**/node_modules/.vite/deps/socket__io-client.js*', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/javascript',
|
||||
body: `
|
||||
const state = window.__PW_GROUP_SOCKET__ || (window.__PW_GROUP_SOCKET__ = { sockets: [], emitted: [] })
|
||||
const roomMessages = ${JSON.stringify(messagesByRoom)}
|
||||
function makeSocket(url, options) {
|
||||
const listeners = new Map()
|
||||
const socket = {
|
||||
connected: true,
|
||||
url,
|
||||
options,
|
||||
on(event, handler) {
|
||||
const handlers = listeners.get(event) || []
|
||||
handlers.push(handler)
|
||||
listeners.set(event, handlers)
|
||||
return this
|
||||
},
|
||||
emit(event, payload, ack) {
|
||||
state.emitted.push({ event, payload })
|
||||
if (event === 'join' && typeof ack === 'function') {
|
||||
const roomId = payload && payload.roomId
|
||||
setTimeout(() => ack({ roomId, roomName: roomId, members: [], messages: roomMessages[roomId] || [], agents: [], rooms: [], typingUsers: [], contextStatuses: [] }), 0)
|
||||
}
|
||||
if (event === 'message' && typeof ack === 'function') {
|
||||
setTimeout(() => ack({ id: payload && payload.id }), 0)
|
||||
}
|
||||
return this
|
||||
},
|
||||
removeAllListeners() {
|
||||
listeners.clear()
|
||||
return this
|
||||
},
|
||||
disconnect() {
|
||||
this.connected = false
|
||||
return this
|
||||
},
|
||||
__trigger(event, payload) {
|
||||
for (const handler of listeners.get(event) || []) handler(payload)
|
||||
},
|
||||
}
|
||||
state.sockets.push(socket)
|
||||
state.latest = socket
|
||||
return socket
|
||||
}
|
||||
export function io(url, options) {
|
||||
return makeSocket(url, options)
|
||||
}
|
||||
export default { io }
|
||||
`,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function setup(page: Page, path: string) {
|
||||
await authenticate(page)
|
||||
await mockGroupChatSocket(page)
|
||||
await mockGroupChatApi(page)
|
||||
await page.goto(path)
|
||||
}
|
||||
|
||||
test.describe('group chat room deep links', () => {
|
||||
test('route room id opens selected room', async ({ page }) => {
|
||||
await setup(page, '/#/hermes/group-chat/room/room-beta')
|
||||
|
||||
await expect(page.locator('.room-title-text', { hasText: 'Beta Room' })).toBeVisible()
|
||||
await expect(page.getByText('Beta room message')).toBeVisible()
|
||||
await expect(page).toHaveURL(/#\/hermes\/group-chat\/room\/room-beta$/)
|
||||
})
|
||||
|
||||
test('clicking another room updates URL and reload preserves it', async ({ page }) => {
|
||||
await setup(page, '/#/hermes/group-chat/room/room-alpha')
|
||||
await expect(page.getByText('Alpha room message')).toBeVisible()
|
||||
|
||||
await page.getByText('Beta Room').click()
|
||||
await expect(page).toHaveURL(/#\/hermes\/group-chat\/room\/room-beta$/)
|
||||
await expect(page.getByText('Beta room message')).toBeVisible()
|
||||
|
||||
await page.reload()
|
||||
await expect(page).toHaveURL(/#\/hermes\/group-chat\/room\/room-beta$/)
|
||||
await expect(page.getByText('Beta room message')).toBeVisible()
|
||||
})
|
||||
|
||||
test('two tabs can show different rooms', async ({ context }) => {
|
||||
const first = await context.newPage()
|
||||
const second = await context.newPage()
|
||||
|
||||
await setup(first, '/#/hermes/group-chat/room/room-alpha')
|
||||
await setup(second, '/#/hermes/group-chat/room/room-beta')
|
||||
|
||||
await expect(first.getByText('Alpha room message')).toBeVisible()
|
||||
await expect(first.getByText('Beta room message')).toHaveCount(0)
|
||||
await expect(second.getByText('Beta room message')).toBeVisible()
|
||||
await expect(second.getByText('Alpha room message')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('unknown route room id falls back to base group chat route', async ({ page }) => {
|
||||
await setup(page, '/#/hermes/group-chat/room/missing-room')
|
||||
|
||||
await expect(page).toHaveURL(/#\/hermes\/group-chat$/)
|
||||
await expect(page.getByText('Alpha Room')).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,153 @@
|
||||
import { expect, test, type Page, type Route } from '@playwright/test'
|
||||
import { authenticate } from './fixtures'
|
||||
|
||||
const historySessions = [
|
||||
{
|
||||
id: 'hist-alpha',
|
||||
profile: 'default',
|
||||
source: 'cli',
|
||||
model: 'test-model',
|
||||
provider: 'test-provider',
|
||||
title: 'Alpha History Session',
|
||||
preview: 'Alpha preview',
|
||||
started_at: 1_790_000_000,
|
||||
ended_at: null,
|
||||
last_active: 1_790_000_100,
|
||||
message_count: 2,
|
||||
tool_call_count: 0,
|
||||
input_tokens: 10,
|
||||
output_tokens: 20,
|
||||
cache_read_tokens: 0,
|
||||
cache_write_tokens: 0,
|
||||
reasoning_tokens: 0,
|
||||
billing_provider: null,
|
||||
estimated_cost_usd: 0,
|
||||
actual_cost_usd: null,
|
||||
cost_status: '',
|
||||
workspace: null,
|
||||
},
|
||||
{
|
||||
id: 'hist-beta',
|
||||
profile: 'default',
|
||||
source: 'cli',
|
||||
model: 'test-model',
|
||||
provider: 'test-provider',
|
||||
title: 'Beta History Session',
|
||||
preview: 'Beta preview',
|
||||
started_at: 1_790_000_200,
|
||||
ended_at: null,
|
||||
last_active: 1_790_000_300,
|
||||
message_count: 2,
|
||||
tool_call_count: 0,
|
||||
input_tokens: 30,
|
||||
output_tokens: 40,
|
||||
cache_read_tokens: 0,
|
||||
cache_write_tokens: 0,
|
||||
reasoning_tokens: 0,
|
||||
billing_provider: null,
|
||||
estimated_cost_usd: 0,
|
||||
actual_cost_usd: null,
|
||||
cost_status: '',
|
||||
workspace: null,
|
||||
},
|
||||
]
|
||||
|
||||
function detailFor(id: string) {
|
||||
const session = historySessions.find(s => s.id === id)
|
||||
if (!session) return null
|
||||
return {
|
||||
...session,
|
||||
messages: [
|
||||
{
|
||||
id: 1,
|
||||
session_id: id,
|
||||
role: 'user',
|
||||
content: `Question for ${session.title}`,
|
||||
tool_call_id: null,
|
||||
tool_calls: null,
|
||||
tool_name: null,
|
||||
timestamp: session.started_at,
|
||||
token_count: null,
|
||||
finish_reason: null,
|
||||
reasoning: null,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
session_id: id,
|
||||
role: 'assistant',
|
||||
content: `Answer from ${session.title}`,
|
||||
tool_call_id: null,
|
||||
tool_calls: null,
|
||||
tool_name: null,
|
||||
timestamp: session.started_at + 1,
|
||||
token_count: null,
|
||||
finish_reason: null,
|
||||
reasoning: null,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
async function mockHistoryApi(page: Page) {
|
||||
await page.route('**/*', async (route: Route) => {
|
||||
const request = route.request()
|
||||
const url = new URL(request.url())
|
||||
const { pathname } = url
|
||||
|
||||
if (!(pathname === '/health' || pathname.startsWith('/api/'))) {
|
||||
await route.continue()
|
||||
return
|
||||
}
|
||||
|
||||
const json = (body: unknown, status = 200) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) })
|
||||
|
||||
if (pathname === '/health') return json({ status: 'ok' })
|
||||
if (pathname === '/api/auth/status') return json({ hasPasswordLogin: false, username: null })
|
||||
if (pathname === '/api/hermes/available-models') return json({ default: 'test-model', default_provider: 'test-provider', groups: [], allProviders: [], model_aliases: {}, model_visibility: {} })
|
||||
if (pathname === '/api/hermes/profiles') return json({ profiles: [{ name: 'default', active: true, model: 'test-model', gateway: 'test' }] })
|
||||
if (pathname === '/api/hermes/sessions/hermes') return json({ sessions: historySessions })
|
||||
|
||||
const detailMatch = pathname.match(/^\/api\/hermes\/sessions\/hermes\/([^/]+)$/)
|
||||
if (detailMatch) {
|
||||
const detail = detailFor(decodeURIComponent(detailMatch[1]))
|
||||
return detail ? json({ session: detail }) : json({ error: 'Session not found' }, 404)
|
||||
}
|
||||
|
||||
return json({ error: `Unexpected mocked route: ${request.method()} ${pathname}` }, 404)
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('history session deep links', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await authenticate(page)
|
||||
await mockHistoryApi(page)
|
||||
})
|
||||
|
||||
test('route session id opens selected history session', async ({ page }) => {
|
||||
await page.goto('/#/hermes/history/session/hist-beta')
|
||||
|
||||
await expect(page.getByText('Beta History Session').first()).toBeVisible()
|
||||
await expect(page.getByText('Answer from Beta History Session')).toBeVisible()
|
||||
await expect(page).toHaveURL(/#\/hermes\/history\/session\/hist-beta$/)
|
||||
})
|
||||
|
||||
test('clicking another history session updates URL and reload preserves it', async ({ page }) => {
|
||||
await page.goto('/#/hermes/history/session/hist-alpha')
|
||||
await expect(page.getByText('Answer from Alpha History Session')).toBeVisible()
|
||||
|
||||
await page.getByText('Beta History Session').first().click()
|
||||
await expect(page).toHaveURL(/#\/hermes\/history\/session\/hist-beta\?profile=default$/)
|
||||
await expect(page.getByText('Answer from Beta History Session')).toBeVisible()
|
||||
|
||||
await page.reload()
|
||||
await expect(page).toHaveURL(/#\/hermes\/history\/session\/hist-beta\?profile=default$/)
|
||||
await expect(page.getByText('Answer from Beta History Session')).toBeVisible()
|
||||
})
|
||||
|
||||
test('unknown route session id falls back to base history route', async ({ page }) => {
|
||||
await page.goto('/#/hermes/history/session/missing-session')
|
||||
|
||||
await expect(page).toHaveURL(/#\/hermes\/history$/)
|
||||
await expect(page.getByText('Alpha History Session').first()).toBeVisible()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user