Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion hub/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ Namespace: `/cli`
- `message` - Send message to session.
- `update-metadata` - Update session metadata.
- `update-state` - Update agent state.
- `session-alive` - Keep session active.
- `session-alive` - Keep session active. Payloads are validated against the shared protocol schemas; unknown `permissionMode`, `modelMode`, or `collaborationMode` values and out-of-range `sid`/`time` values cause the heartbeat to be dropped. Forked or older CLIs emitting non-standard values must upgrade.
- `session-end` - Mark session ended.
- `machine-alive` - Keep machine online.
- `rpc-register` - Register RPC handler.
Expand Down
26 changes: 21 additions & 5 deletions hub/src/socket/handlers/cli/sessionHandlers.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { ClientToServerEvents } from '@hapi/protocol'
import { CodexCollaborationModeSchema, ModelModeSchema, PermissionModeSchema } from '@hapi/protocol/schemas'
import { z } from 'zod'
import { randomUUID } from 'node:crypto'
import type { CodexCollaborationMode, PermissionMode } from '@hapi/protocol/types'
import type { CodexCollaborationMode, ModelMode, PermissionMode } from '@hapi/protocol/types'
import type { Store, StoredSession } from '../../../store'
import type { SyncEvent } from '../../../sync/syncEngine'
import { extractTodoWriteTodosFromMessageContent } from '../../../sync/todos'
Expand All @@ -18,6 +19,7 @@ type SessionAlivePayload = {
model?: string | null
effort?: string | null
collaborationMode?: CodexCollaborationMode
modelMode?: ModelMode
}

type SessionEndPayload = {
Expand Down Expand Up @@ -50,6 +52,18 @@ const updateStateSchema = z.object({
agentState: z.unknown().nullable()
})

const sessionAliveSchema = z.object({
sid: z.string().min(1).max(128),
time: z.number().finite().nonnegative(),
thinking: z.boolean().optional(),
mode: z.enum(['local', 'remote']).optional(),
permissionMode: PermissionModeSchema.optional(),
model: z.string().max(256).nullable().optional(),
effort: z.string().max(64).nullable().optional(),
collaborationMode: CodexCollaborationModeSchema.optional(),
modelMode: ModelModeSchema.optional()
})

export type SessionHandlersDeps = {
store: Store
resolveSessionAccess: ResolveSessionAccess
Expand Down Expand Up @@ -235,15 +249,17 @@ export function registerSessionHandlers(socket: CliSocketWithData, deps: Session
socket.on('update-state', handleUpdateState)

socket.on('session-alive', (data: SessionAlivePayload) => {
if (!data || typeof data.sid !== 'string' || typeof data.time !== 'number') {
const parsed = sessionAliveSchema.safeParse(data)
if (!parsed.success) {
return
}
const sessionAccess = resolveSessionAccess(data.sid)
const payload = parsed.data
const sessionAccess = resolveSessionAccess(payload.sid)
if (!sessionAccess.ok) {
emitAccessError('session', data.sid, sessionAccess.reason)
emitAccessError('session', payload.sid, sessionAccess.reason)
return
}
onSessionAlive?.(data)
onSessionAlive?.(payload)
})

socket.on('session-end', (data: SessionEndPayload) => {
Expand Down
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@
"test:hub": "cd hub && bun run test",
"test:web": "cd web && bun run test",
"test:e2e:web": "cd web && bun run test:e2e",
"test:e2e:web:session-sort": "cd web && bun run test:e2e:session-sort",
"clean-session": "bun run hub/scripts/cleanup-sessions.ts",
"release-all": "cd cli && bun run release-all"
},
Expand Down
7 changes: 5 additions & 2 deletions web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,9 +142,12 @@ The built assets land in `web/dist` and are served by hapi-hub. The single execu

## E2E (Playwright)

Session-sort backend persistence has Playwright coverage in:
Playwright covers:

- `web/e2e/session-sort.backend.e2e.spec.ts`
- `web/e2e/session-sort.backend.e2e.spec.ts` — session-sort backend persistence.
- `web/e2e/session-metadata.ui.e2e.spec.ts` — session list and header metadata chip rendering.

Shared env knobs: `HAPI_E2E_BASE_URL` (default `http://127.0.0.1:3906`), `HAPI_E2E_CLI_TOKEN` (default `pw-test-token`), and `HAPI_E2E_RUN_ID` (defaults to a random suffix) namespace test sessions across runs.

Install browser once:

Expand Down
83 changes: 83 additions & 0 deletions web/e2e/session-metadata.ui.e2e.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { expect, test, type Page } from '@playwright/test'

const BASE_URL = process.env.HAPI_E2E_BASE_URL ?? 'http://127.0.0.1:3906'
const BASE_TOKEN = process.env.HAPI_E2E_CLI_TOKEN ?? 'pw-test-token'
const RUN_ID = process.env.HAPI_E2E_RUN_ID ?? `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`

function token(namespaceSuffix: string): string {
return `${BASE_TOKEN}:session-metadata-${RUN_ID}-${namespaceSuffix}`
}

async function login(page: Page, accessToken: string): Promise<void> {
await page.goto(BASE_URL, { waitUntil: 'networkidle' })
await page.getByPlaceholder('Access token').fill(accessToken)
await page.getByRole('button', { name: 'Sign In' }).click()
await expect(page.getByPlaceholder('Access token')).toHaveCount(0, { timeout: 15_000 })
await expect(page.locator('.session-list-item').first()).toBeVisible({ timeout: 15_000 })
}

async function createCliSession(
accessToken: string,
tag: string,
name: string,
path: string,
machineId: string
): Promise<string> {
const response = await fetch(`${BASE_URL}/cli/sessions`, {
method: 'POST',
headers: {
authorization: `Bearer ${accessToken}`,
'content-type': 'application/json'
},
body: JSON.stringify({
tag,
metadata: {
name,
path,
host: 'pw-host',
machineId,
flavor: 'codex',
worktree: {
basePath: '/work/repo',
branch: 'feature/chips',
name: 'feature-chips'
}
},
agentState: null,
model: 'gpt-5.4',
effort: 'very-high'
})
})
expect(response.status).toBe(200)
const json = await response.json() as { session: { id: string } }
return json.session.id
}

test('session metadata chips render in list and header', async ({ page }) => {
const accessToken = token('chips')

const activeSessionId = await createCliSession(accessToken, 's-active', 'Active Session', '/work/repo/project-a', 'm1')
await createCliSession(accessToken, 's-inactive', 'Inactive Session', '/work/repo/project-a', 'm1')

await login(page, accessToken)

await page.getByRole('button', {
name: /work\/repo/i
}).first().click()

const activeRow = page.locator('.session-list-item', { hasText: 'Active Session' }).first()
await expect(activeRow).toContainText('codex')
await expect(activeRow).toContainText('gpt-5.4')
await expect(activeRow).toContainText('feature/chips')

await page.goto(`${BASE_URL}/sessions/${activeSessionId}`, { waitUntil: 'domcontentloaded' })

const headerTitle = page.locator('div.truncate.font-semibold').first()
await expect(headerTitle).toHaveText('Active Session')

const headerMeta = headerTitle.locator('xpath=following-sibling::div[1]')
await expect(headerMeta).toContainText('codex')
await expect(headerMeta).toContainText('gpt-5.4')
await expect(headerMeta).toContainText('Very High')
await expect(headerMeta).toContainText('feature/chips')
})
1 change: 0 additions & 1 deletion web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
"preview": "vite preview",
"test": "vitest run",
"test:e2e": "BUN_BIN=${BUN_BIN:-$(command -v bun || echo $HOME/.bun/bin/bun)} playwright test --config=playwright.config.ts",
"test:e2e:session-sort": "BUN_BIN=${BUN_BIN:-$(command -v bun || echo $HOME/.bun/bin/bun)} playwright test --config=playwright.config.ts --grep \"session sort\"",
"test:e2e:install": "playwright install chromium"
},
"dependencies": {
Expand Down
21 changes: 19 additions & 2 deletions web/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,27 @@ import { defineConfig, devices } from '@playwright/test'

const port = Number(process.env.HAPI_E2E_PORT ?? '3906')
const baseUrl = process.env.HAPI_E2E_BASE_URL ?? `http://127.0.0.1:${port}`
const hapiHome = process.env.HAPI_E2E_HAPI_HOME ?? `/tmp/hapi-playwright-session-sort-${port}`
const hapiHome = process.env.HAPI_E2E_HAPI_HOME ?? `/tmp/hapi-playwright-${port}`
const cliApiToken = process.env.HAPI_E2E_CLI_TOKEN ?? 'pw-test-token'
const bunBin = process.env.BUN_BIN ?? 'bun'

function shellQuote(value: string): string {
return `'${value.replace(/'/g, `'\"'\"'`)}'`
}

const buildCommand = [
`${shellQuote(bunBin)} run build`,
`rm -rf ${shellQuote(hapiHome)}`,
`mkdir -p ${shellQuote(hapiHome)}`,
[
`CLI_API_TOKEN=${shellQuote(cliApiToken)}`,
`HAPI_HOME=${shellQuote(hapiHome)}`,
'HAPI_LISTEN_HOST=127.0.0.1',
`HAPI_LISTEN_PORT=${port}`,
`${shellQuote(bunBin)} run --cwd ../hub src/index.ts`
].join(' ')
].join(' && ')

export default defineConfig({
testDir: './e2e',
timeout: 180_000,
Expand All @@ -29,7 +46,7 @@ export default defineConfig({
}
],
webServer: {
command: `${bunBin} run build && rm -rf "${hapiHome}" && mkdir -p "${hapiHome}" && CLI_API_TOKEN=${cliApiToken} HAPI_HOME="${hapiHome}" HAPI_LISTEN_HOST=127.0.0.1 HAPI_LISTEN_PORT=${port} ${bunBin} run --cwd ../hub src/index.ts`,
command: buildCommand,
url: `${baseUrl}/health`,
timeout: 120_000,
reuseExistingServer: false
Expand Down
2 changes: 1 addition & 1 deletion web/src/components/NewSession/ActionButtons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export function ActionButtons(props: {
const { t } = useTranslation()

return (
<div className="flex gap-2 px-3 pt-3 pb-[calc(env(safe-area-inset-bottom)+1rem)]">
<div className="sticky bottom-0 z-10 flex gap-2 border-t border-[var(--app-divider)] bg-[var(--app-bg)] px-3 pt-3 pb-[calc(env(safe-area-inset-bottom)+1rem)] shadow-[0_-8px_24px_rgba(0,0,0,0.06)]">
<Button
variant="secondary"
onClick={props.onCancel}
Expand Down
147 changes: 147 additions & 0 deletions web/src/components/SessionHeader.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { renderToStaticMarkup } from 'react-dom/server'
import { I18nProvider } from '@/lib/i18n-context'
import { SessionHeader } from './SessionHeader'

vi.mock('@/hooks/useTelegram', () => ({
isTelegramApp: vi.fn(() => false)
}))

vi.mock('@/hooks/mutations/useSessionActions', () => ({
useSessionActions: () => ({
archiveSession: vi.fn(),
renameSession: vi.fn(),
deleteSession: vi.fn(),
isPending: false
})
}))

vi.mock('@/components/SessionActionMenu', () => ({
SessionActionMenu: () => null
}))

vi.mock('@/components/RenameSessionDialog', () => ({
RenameSessionDialog: () => null
}))

vi.mock('@/components/ui/ConfirmDialog', () => ({
ConfirmDialog: () => null
}))

function renderHeader(sessionOverrides: Record<string, unknown> = {}) {
const session = {
id: '1234567890abcdef',
active: true,
createdAt: 0,
updatedAt: 0,
activeAt: 0,
seq: 0,
namespace: 'default',
metadataVersion: 0,
agentStateVersion: 0,
thinking: false,
thinkingAt: 0,
metadata: {
name: 'Named Session',
path: '/workspace/project-name',
flavor: 'codex',
worktree: { branch: 'feature/chips', basePath: '/workspace/project-name' }
},
model: 'gpt-5.4',
effort: 'very-high',
permissionMode: 'yolo',
collaborationMode: 'default',
agentState: null,
...sessionOverrides
}

return renderToStaticMarkup(
<I18nProvider>
<SessionHeader
session={session as never}
onBack={vi.fn()}
onViewFiles={vi.fn()}
api={null}
/>
</I18nProvider>
)
}

describe('SessionHeader', () => {
beforeEach(() => {
vi.clearAllMocks()
const localStorageMock = {
getItem: vi.fn(() => 'en'),
setItem: vi.fn(),
removeItem: vi.fn()
}
Object.defineProperty(window, 'localStorage', { value: localStorageMock, configurable: true })
})

it('renders full metadata chips', () => {
const markup = renderHeader()
expect(markup).toContain('Named Session')
expect(markup).toContain('codex')
expect(markup).toContain('gpt-5.4')
expect(markup).toContain('Very High')
expect(markup).toContain('Yolo')
expect(markup).toContain('feature/chips')
expect(markup).toContain('text-[var(--app-flavor-codex-text)] font-medium')
expect(markup).toContain('text-[var(--app-hint)] opacity-40')
})

it('hides effort when absent', () => {
const markup = renderHeader({ effort: null })
expect(markup).not.toContain('Very High')
expect(markup).toContain('Named Session')
expect(markup).toContain('gpt-5.4')
})

it('hides permission mode when default', () => {
const markup = renderHeader({ permissionMode: 'default' })
expect(markup).not.toContain('Yolo')
expect(markup).toContain('Named Session')
expect(markup).toContain('codex')
})

it('hides worktree when absent', () => {
const markup = renderHeader({
metadata: {
name: 'Named Session',
path: '/workspace/project-name',
flavor: 'codex'
}
})
expect(markup).not.toContain('feature/chips')
})

it('falls back to the path basename for the title', () => {
const markup = renderHeader({
metadata: {
path: '/workspace/path-title',
flavor: 'codex'
}
})
expect(markup).toContain('path-title')
})

it('falls back to the short id when no path or summary exists', () => {
const markup = renderHeader({
id: 'abcdef1234567890',
metadata: null
})
expect(markup).toContain('abcdef12')
})

it('uses hint styling for unknown flavors', () => {
const markup = renderHeader({
metadata: {
name: 'Mystery Session',
path: '/workspace/mystery',
flavor: 'mystery'
}
})
expect(markup).toContain('mystery')
expect(markup).toContain('text-[var(--app-hint)] font-medium')
})
})
Loading