import { describe, test, expect, beforeEach, mock } from 'bun:test' // Mock config const mockConfig = { port: 3000, host: '0.0.0.0', apiKeys: ['test-api-key'], baseUrl: 'http://localhost:3000', pollTimeout: 1, heartbeatInterval: 20, jwtExpiresIn: 3600, disconnectTimeout: 300, webCorsOrigins: [], wsIdleTimeout: 30, wsKeepaliveInterval: 20, } mock.module('../config', () => ({ config: mockConfig, getBaseUrl: () => 'http://localhost:3000', })) import { Hono } from 'hono' import { storeReset, storeCreateSession, storeCreateEnvironment, storeBindSession, } from '../store' import { removeEventBus, getAllEventBuses, getEventBus, } from '../transport/event-bus' import { issueToken } from '../auth/token' import { publishSessionEvent } from '../services/transport' import { encodeWebSocketAuthProtocol } from '../auth/middleware' // Import route modules import v1Sessions from '../routes/v1/sessions' import v1Environments from '../routes/v1/environments' import v1EnvironmentsWork from '../routes/v1/environments.work' import v1SessionIngress, { decodeSessionIngressWsMessage, handleSessionIngressWsPayload, websocket as sessionIngressWebsocket, } from '../routes/v1/session-ingress' import { decodeAcpWsMessageData, hasAcpRelayAuth, handleAcpWsPayload, } from '../routes/acp' import acpRoutes from '../routes/acp' import v2CodeSessions from '../routes/v2/code-sessions' import v2Worker from '../routes/v2/worker' import v2WorkerEventsStream from '../routes/v2/worker-events-stream' import v2WorkerEvents from '../routes/v2/worker-events' import webAuth from '../routes/web/auth' import webSessions from '../routes/web/sessions' import webControl from '../routes/web/control' import webEnvironments from '../routes/web/environments' function createApp() { const app = new Hono() app.route('/v1/sessions', v1Sessions) app.route('/v1/environments', v1Environments) app.route('/v1/environments', v1EnvironmentsWork) app.route('/v2/session_ingress', v1SessionIngress) app.route('/v1/code/sessions', v2CodeSessions) app.route('/v1/code/sessions', v2Worker) app.route('/v1/code/sessions', v2WorkerEventsStream) app.route('/v1/code/sessions', v2WorkerEvents) app.route('/web', webAuth) app.route('/web', webSessions) app.route('/web', webControl) app.route('/web', webEnvironments) app.route('/acp', acpRoutes) return app } const AUTH_HEADERS = { Authorization: 'Bearer test-api-key', 'X-Username': 'testuser', } function toWebSessionId(sessionId: string): string { if (!sessionId.startsWith('cse_')) return sessionId return `session_${sessionId.slice('cse_'.length)}` } describe('V1 Session Routes', () => { let app: Hono beforeEach(() => { storeReset() for (const [key] of getAllEventBuses()) { removeEventBus(key) } app = createApp() }) test('POST /v1/sessions — creates a session', async () => { const res = await app.request('/v1/sessions', { method: 'POST', headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' }, body: JSON.stringify({ title: 'Test Session' }), }) expect(res.status).toBe(200) const body = await res.json() expect(body.id).toMatch(/^session_/) expect(body.title).toBe('Test Session') expect(body.status).toBe('idle') }) test('POST /v1/sessions — requires auth', async () => { const res = await app.request('/v1/sessions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}), }) expect(res.status).toBe(401) }) test('GET /v1/sessions/:id — returns created session', async () => { const createRes = await app.request('/v1/sessions', { method: 'POST', headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' }, body: JSON.stringify({}), }) const { id } = await createRes.json() const getRes = await app.request(`/v1/sessions/${id}`, { headers: AUTH_HEADERS, }) expect(getRes.status).toBe(200) const body = await getRes.json() expect(body.id).toBe(id) }) test('GET /v1/sessions/:id — 404 for unknown session', async () => { const res = await app.request('/v1/sessions/nope', { headers: AUTH_HEADERS, }) expect(res.status).toBe(404) }) test('GET /v1/sessions/:id — resolves compat code session IDs', async () => { const createRes = await app.request('/v1/code/sessions', { method: 'POST', headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' }, body: JSON.stringify({}), }) const { session: { id }, } = await createRes.json() const getRes = await app.request(`/v1/sessions/${toWebSessionId(id)}`, { headers: AUTH_HEADERS, }) expect(getRes.status).toBe(200) const body = await getRes.json() expect(body.id).toBe(id) }) test('PATCH /v1/sessions/:id — updates title', async () => { const createRes = await app.request('/v1/sessions', { method: 'POST', headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' }, body: JSON.stringify({}), }) const { id } = await createRes.json() const patchRes = await app.request(`/v1/sessions/${id}`, { method: 'PATCH', headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' }, body: JSON.stringify({ title: 'Updated Title' }), }) expect(patchRes.status).toBe(200) const body = await patchRes.json() expect(body.title).toBe('Updated Title') }) test('POST /v1/sessions/:id/archive — archives session', async () => { const createRes = await app.request('/v1/sessions', { method: 'POST', headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' }, body: JSON.stringify({}), }) const { id } = await createRes.json() const archiveRes = await app.request(`/v1/sessions/${id}/archive`, { method: 'POST', headers: AUTH_HEADERS, }) expect(archiveRes.status).toBe(200) }) test('POST /v1/sessions/:id/archive — archives compat code session IDs', async () => { const createRes = await app.request('/v1/code/sessions', { method: 'POST', headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' }, body: JSON.stringify({}), }) const { session: { id }, } = await createRes.json() const compatId = toWebSessionId(id) const archiveRes = await app.request(`/v1/sessions/${compatId}/archive`, { method: 'POST', headers: AUTH_HEADERS, }) expect(archiveRes.status).toBe(200) const getRes = await app.request(`/v1/sessions/${compatId}`, { headers: AUTH_HEADERS, }) expect(getRes.status).toBe(200) const body = await getRes.json() expect(body.id).toBe(id) expect(body.status).toBe('archived') }) test('POST /v1/sessions/:id/events — publishes events', async () => { const createRes = await app.request('/v1/sessions', { method: 'POST', headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' }, body: JSON.stringify({}), }) const { id } = await createRes.json() const eventsRes = await app.request(`/v1/sessions/${id}/events`, { method: 'POST', headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' }, body: JSON.stringify({ events: [{ type: 'user', content: 'hello' }] }), }) expect(eventsRes.status).toBe(200) const body = await eventsRes.json() expect(body.events).toBe(1) }) test('POST /v1/sessions/:id/events — resolves compat code session IDs', async () => { const createRes = await app.request('/v1/code/sessions', { method: 'POST', headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' }, body: JSON.stringify({}), }) const { session: { id }, } = await createRes.json() const compatId = toWebSessionId(id) const eventsRes = await app.request(`/v1/sessions/${compatId}/events`, { method: 'POST', headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' }, body: JSON.stringify({ events: [{ type: 'user', content: 'hello from compat' }], }), }) expect(eventsRes.status).toBe(200) const events = getEventBus(id).getEventsSince(0) expect(events).toHaveLength(1) expect(events[0]?.type).toBe('user') expect((events[0]?.payload as { content?: string }).content).toBe( 'hello from compat', ) }) test('POST /v1/sessions with environment_id creates work item', async () => { // First register an environment const envRes = await app.request('/v1/environments/bridge', { method: 'POST', headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' }, body: JSON.stringify({ machine_name: 'test' }), }) const { environment_id } = await envRes.json() const sessRes = await app.request('/v1/sessions', { method: 'POST', headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' }, body: JSON.stringify({ environment_id }), }) expect(sessRes.status).toBe(200) const body = await sessRes.json() expect(body.environment_id).toBe(environment_id) }) test('POST /v1/sessions with invalid environment_id — session created, work item fails silently', async () => { const sessRes = await app.request('/v1/sessions', { method: 'POST', headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' }, body: JSON.stringify({ environment_id: 'env_nonexistent' }), }) expect(sessRes.status).toBe(200) const body = await sessRes.json() expect(body.id).toMatch(/^session_/) }) test('POST /v1/sessions with events — publishes initial events', async () => { const sessRes = await app.request('/v1/sessions', { method: 'POST', headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' }, body: JSON.stringify({ events: [{ type: 'init', data: 'starting' }] }), }) expect(sessRes.status).toBe(200) }) }) describe('V1 Environment Routes', () => { let app: Hono beforeEach(() => { storeReset() app = createApp() }) test('POST /v1/environments/bridge — registers environment', async () => { const res = await app.request('/v1/environments/bridge', { method: 'POST', headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' }, body: JSON.stringify({ machine_name: 'mac1', directory: '/home' }), }) expect(res.status).toBe(200) const body = await res.json() expect(body.environment_id).toMatch(/^env_/) expect(body.status).toBe('active') }) test('DELETE /v1/environments/bridge/:id — deregisters environment', async () => { const envRes = await app.request('/v1/environments/bridge', { method: 'POST', headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' }, body: JSON.stringify({}), }) const { environment_id } = await envRes.json() const delRes = await app.request( `/v1/environments/bridge/${environment_id}`, { method: 'DELETE', headers: AUTH_HEADERS, }, ) expect(delRes.status).toBe(200) }) test('POST /v1/environments/:id/bridge/reconnect — reconnects environment', async () => { const envRes = await app.request('/v1/environments/bridge', { method: 'POST', headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' }, body: JSON.stringify({}), }) const { environment_id } = await envRes.json() const reconnectRes = await app.request( `/v1/environments/${environment_id}/bridge/reconnect`, { method: 'POST', headers: AUTH_HEADERS, }, ) expect(reconnectRes.status).toBe(200) }) }) describe('V1 Work Routes', () => { let app: Hono let envId: string beforeEach(async () => { storeReset() app = createApp() const envRes = await app.request('/v1/environments/bridge', { method: 'POST', headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' }, body: JSON.stringify({}), }) envId = (await envRes.json()).environment_id }) test('GET /v1/environments/:id/work/poll — returns 204 when no work', async () => { const res = await app.request(`/v1/environments/${envId}/work/poll`, { headers: AUTH_HEADERS, }) expect(res.status).toBe(204) }) test('work lifecycle: create → poll → ack → stop', async () => { // Create session with environment (creates work item) const sessRes = await app.request('/v1/sessions', { method: 'POST', headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' }, body: JSON.stringify({ environment_id: envId }), }) const sessionId = (await sessRes.json()).id // Poll for work const pollRes = await app.request(`/v1/environments/${envId}/work/poll`, { headers: AUTH_HEADERS, }) expect(pollRes.status).toBe(200) const work = await pollRes.json() expect(work.id).toMatch(/^work_/) expect(work.data.id).toBe(sessionId) // Ack work const ackRes = await app.request( `/v1/environments/${envId}/work/${work.id}/ack`, { method: 'POST', headers: AUTH_HEADERS, }, ) expect(ackRes.status).toBe(200) // Stop work const stopRes = await app.request( `/v1/environments/${envId}/work/${work.id}/stop`, { method: 'POST', headers: AUTH_HEADERS, }, ) expect(stopRes.status).toBe(200) }) test('POST work heartbeat', async () => { // Create session + work await app.request('/v1/sessions', { method: 'POST', headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' }, body: JSON.stringify({ environment_id: envId }), }) const pollRes = await app.request(`/v1/environments/${envId}/work/poll`, { headers: AUTH_HEADERS, }) const work = await pollRes.json() const hbRes = await app.request( `/v1/environments/${envId}/work/${work.id}/heartbeat`, { method: 'POST', headers: AUTH_HEADERS, }, ) expect(hbRes.status).toBe(200) const body = await hbRes.json() expect(body.lease_extended).toBe(true) }) }) describe('V2 Code Session Routes', () => { let app: Hono beforeEach(() => { storeReset() process.env.RCS_API_KEYS = 'test-api-key' app = createApp() }) test('POST /v1/code/sessions — creates code session', async () => { const res = await app.request('/v1/code/sessions', { method: 'POST', headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' }, body: JSON.stringify({ title: 'Code Session' }), }) expect(res.status).toBe(200) const body = await res.json() expect(body.session.id).toMatch(/^cse_/) expect(body.session.title).toBe('Code Session') }) test('POST /v1/code/sessions/:id/bridge — returns bridge info with JWT', async () => { // Create code session const createRes = await app.request('/v1/code/sessions', { method: 'POST', headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' }, body: JSON.stringify({}), }) const { id } = (await createRes.json()).session const bridgeRes = await app.request(`/v1/code/sessions/${id}/bridge`, { method: 'POST', headers: AUTH_HEADERS, }) expect(bridgeRes.status).toBe(200) const body = await bridgeRes.json() expect(body.api_base_url).toBe('http://localhost:3000') expect(body.worker_epoch).toBe(1) expect(body.worker_jwt).toBeTruthy() expect(body.expires_in).toBe(3600) }) test('POST /v1/code/sessions/:id/bridge — 404 for unknown session', async () => { const res = await app.request('/v1/code/sessions/nope/bridge', { method: 'POST', headers: AUTH_HEADERS, }) expect(res.status).toBe(404) }) }) describe('V2 Worker Routes', () => { let app: Hono beforeEach(() => { storeReset() process.env.RCS_API_KEYS = 'test-api-key' app = createApp() }) test('POST /v1/code/sessions/:id/worker/register — increments epoch', async () => { // Create session const createRes = await app.request('/v1/sessions', { method: 'POST', headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' }, body: JSON.stringify({}), }) const { id } = await createRes.json() const regRes = await app.request( `/v1/code/sessions/${id}/worker/register`, { method: 'POST', headers: AUTH_HEADERS, }, ) expect(regRes.status).toBe(200) const body = await regRes.json() expect(body.worker_epoch).toBe(1) }) test('POST /v1/code/sessions/:id/worker/register — 404 for unknown', async () => { const res = await app.request('/v1/code/sessions/nope/worker/register', { method: 'POST', headers: AUTH_HEADERS, }) expect(res.status).toBe(404) }) }) describe('Web Auth Routes', () => { let app: Hono beforeEach(() => { storeReset() app = createApp() }) test('POST /web/bind — binds session to UUID', async () => { // Create session first const sessRes = await app.request('/v1/sessions', { method: 'POST', headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' }, body: JSON.stringify({}), }) const { id } = await sessRes.json() const bindRes = await app.request('/web/bind?uuid=test-uuid', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId: id }), }) expect(bindRes.status).toBe(200) const body = await bindRes.json() expect(body.ok).toBe(true) }) test('POST /web/bind — binds compat code session ID to UUID', async () => { const sessRes = await app.request('/v1/code/sessions', { method: 'POST', headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' }, body: JSON.stringify({}), }) const body = await sessRes.json() const compatId = toWebSessionId(body.session.id) const bindRes = await app.request('/web/bind?uuid=test-uuid', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId: compatId }), }) expect(bindRes.status).toBe(200) const bindBody = await bindRes.json() expect(bindBody.ok).toBe(true) expect(bindBody.sessionId).toBe(compatId) }) test('POST /web/bind — 404 for unknown session', async () => { const res = await app.request('/web/bind?uuid=test-uuid', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId: 'nope' }), }) expect(res.status).toBe(404) }) test('POST /web/bind — 400 when missing params', async () => { const res = await app.request('/web/bind', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}), }) expect(res.status).toBe(400) }) }) describe('Web Session Routes', () => { let app: Hono beforeEach(() => { storeReset() for (const [key] of getAllEventBuses()) { removeEventBus(key) } app = createApp() }) test('POST /web/sessions — creates and auto-binds session', async () => { const res = await app.request('/web/sessions?uuid=user-1', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: 'Web Session' }), }) expect(res.status).toBe(200) const body = await res.json() expect(body.id).toMatch(/^session_/) expect(body.source).toBe('web') }) test('GET /web/sessions — returns sessions owned by UUID', async () => { // Create and bind const createRes = await app.request('/web/sessions?uuid=user-1', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}), }) const { id } = await createRes.json() const listRes = await app.request('/web/sessions?uuid=user-1') expect(listRes.status).toBe(200) const sessions = await listRes.json() expect(sessions).toHaveLength(1) expect(sessions[0].id).toBe(id) }) test('GET /web/sessions and /all — serialize owned code sessions as compat IDs', async () => { const codeSession = storeCreateSession({ idPrefix: 'cse_' }) storeBindSession(codeSession.id, 'user-1') const compatId = toWebSessionId(codeSession.id) const listRes = await app.request('/web/sessions?uuid=user-1') expect(listRes.status).toBe(200) const sessions = await listRes.json() expect(sessions).toHaveLength(1) expect(sessions[0].id).toBe(compatId) const allRes = await app.request('/web/sessions/all?uuid=user-1') expect(allRes.status).toBe(200) const summaries = await allRes.json() expect(summaries).toHaveLength(1) expect(summaries[0].id).toBe(compatId) }) test('GET /web/sessions — requires UUID', async () => { const res = await app.request('/web/sessions') expect(res.status).toBe(401) }) test('GET /web/sessions/all — lists only sessions owned by requesting UUID', async () => { // Create 2 sessions via different users await app.request('/web/sessions?uuid=user-1', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}), }) await app.request('/web/sessions?uuid=user-2', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}), }) const allRes = await app.request('/web/sessions/all?uuid=user-1') expect(allRes.status).toBe(200) const sessions = await allRes.json() expect(sessions).toHaveLength(1) // only user-1's session, not user-2's }) test('GET /web/sessions and /all — hides archived and inactive sessions', async () => { const archived = storeCreateSession({}) const inactive = storeCreateSession({}) const open = storeCreateSession({}) storeBindSession(archived.id, 'user-1') storeBindSession(inactive.id, 'user-1') storeBindSession(open.id, 'user-1') await app.request(`/v1/sessions/${archived.id}/archive`, { method: 'POST', headers: AUTH_HEADERS, }) const { storeUpdateSession } = await import('../store') storeUpdateSession(inactive.id, { status: 'inactive' }) const listRes = await app.request('/web/sessions?uuid=user-1') expect(listRes.status).toBe(200) const sessions = await listRes.json() expect(sessions.map((session: { id: string }) => session.id)).toEqual([ open.id, ]) const allRes = await app.request('/web/sessions/all?uuid=user-1') expect(allRes.status).toBe(200) const summaries = await allRes.json() expect(summaries.map((session: { id: string }) => session.id)).toEqual([ open.id, ]) }) test('GET /web/sessions/:id — returns owned session', async () => { const createRes = await app.request('/web/sessions?uuid=user-1', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}), }) const { id } = await createRes.json() const getRes = await app.request(`/web/sessions/${id}?uuid=user-1`) expect(getRes.status).toBe(200) }) test('GET /web/sessions/:id — includes automation_state snapshot when worker metadata has it', async () => { const createRes = await app.request('/v1/code/sessions', { method: 'POST', headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' }, body: JSON.stringify({}), }) const { session: { id }, } = await createRes.json() storeBindSession(id, 'user-1') await app.request(`/v1/code/sessions/${id}/worker`, { method: 'PUT', headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' }, body: JSON.stringify({ worker_epoch: 1, external_metadata: { automation_state: { enabled: true, phase: 'standby', next_tick_at: 123456, sleep_until: null, }, }, }), }) const getRes = await app.request( `/web/sessions/${toWebSessionId(id)}?uuid=user-1`, ) expect(getRes.status).toBe(200) const body = await getRes.json() expect(body.automation_state).toEqual({ enabled: true, phase: 'standby', next_tick_at: 123456, sleep_until: null, }) }) test('GET /web/sessions/:id — 403 for non-owner', async () => { const createRes = await app.request('/web/sessions?uuid=user-1', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}), }) const { id } = await createRes.json() const getRes = await app.request(`/web/sessions/${id}?uuid=user-2`) expect(getRes.status).toBe(403) }) test('GET /web/sessions/:id/history — returns events', async () => { const createRes = await app.request('/web/sessions?uuid=user-1', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}), }) const { id } = await createRes.json() const histRes = await app.request(`/web/sessions/${id}/history?uuid=user-1`) expect(histRes.status).toBe(200) const body = await histRes.json() expect(body.events).toEqual([]) }) test('GET /web/sessions/:id/history — returns task_state snapshots', async () => { const createRes = await app.request('/web/sessions?uuid=user-1', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}), }) const { id } = await createRes.json() publishSessionEvent( id, 'task_state', { task_list_id: 'team-alpha', tasks: [{ id: '1', subject: 'Investigate', status: 'pending' }], }, 'inbound', ) const histRes = await app.request(`/web/sessions/${id}/history?uuid=user-1`) expect(histRes.status).toBe(200) const body = await histRes.json() expect(body.events).toHaveLength(1) expect(body.events[0]?.type).toBe('task_state') expect(body.events[0]?.payload.task_list_id).toBe('team-alpha') expect(body.events[0]?.payload.tasks).toEqual([ { id: '1', subject: 'Investigate', status: 'pending' }, ]) }) test('GET /web/sessions/:id and history — supports compat code session IDs', async () => { const codeSession = storeCreateSession({ idPrefix: 'cse_' }) storeBindSession(codeSession.id, 'user-1') const compatId = toWebSessionId(codeSession.id) const getRes = await app.request(`/web/sessions/${compatId}?uuid=user-1`) expect(getRes.status).toBe(200) const session = await getRes.json() expect(session.id).toBe(compatId) const histRes = await app.request( `/web/sessions/${compatId}/history?uuid=user-1`, ) expect(histRes.status).toBe(200) const history = await histRes.json() expect(history.events).toEqual([]) }) test('GET /web/sessions/:id/history — 403 for non-owner', async () => { const createRes = await app.request('/web/sessions?uuid=user-1', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}), }) const { id } = await createRes.json() const histRes = await app.request(`/web/sessions/${id}/history?uuid=user-2`) expect(histRes.status).toBe(403) }) test('GET /web/sessions/:id — 404 after session deleted', async () => { const createRes = await app.request('/web/sessions?uuid=user-1', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}), }) const { id } = await createRes.json() // Archive/delete the session via v1 await app.request(`/v1/sessions/${id}/archive`, { method: 'POST', headers: AUTH_HEADERS, }) // Session still exists (archived), so we can still get it const getRes = await app.request(`/web/sessions/${id}?uuid=user-1`) // After archive, session status is "archived" but still exists expect(getRes.status).toBe(200) }) test('GET /web/sessions/:id/history — 404 for non-existent session', async () => { // Bind to a non-existent session won't work, but if ownership was set // and session deleted, we need to test the 404 path const createRes = await app.request('/web/sessions?uuid=user-1', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}), }) const { id } = await createRes.json() // Delete the session from store directly const { storeDeleteSession } = await import('../store') storeDeleteSession(id) const histRes = await app.request(`/web/sessions/${id}/history?uuid=user-1`) expect(histRes.status).toBe(404) }) test('POST /web/sessions with invalid environment_id — handles work item error', async () => { const res = await app.request('/web/sessions?uuid=user-1', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ environment_id: 'env_nonexistent' }), }) // Session is still created even if work item fails expect(res.status).toBe(200) const body = await res.json() expect(body.id).toMatch(/^session_/) }) test('GET /web/sessions/:id/events — returns SSE stream', async () => { const createRes = await app.request('/web/sessions?uuid=user-1', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}), }) const { id } = await createRes.json() const eventsRes = await app.request( `/web/sessions/${id}/events?uuid=user-1`, ) expect(eventsRes.status).toBe(200) expect(eventsRes.headers.get('Content-Type')).toBe('text/event-stream') // Read initial keepalive and cancel const reader = eventsRes.body?.getReader() if (reader) { const { value } = await reader.read() const text = new TextDecoder().decode(value!) expect(text).toContain(': keepalive') reader.cancel() } }) test('GET /web/sessions/:id/events — supports compat code session IDs', async () => { const codeSession = storeCreateSession({ idPrefix: 'cse_' }) storeBindSession(codeSession.id, 'user-1') const compatId = toWebSessionId(codeSession.id) const eventsRes = await app.request( `/web/sessions/${compatId}/events?uuid=user-1`, ) expect(eventsRes.status).toBe(200) expect(eventsRes.headers.get('Content-Type')).toBe('text/event-stream') const reader = eventsRes.body?.getReader() if (reader) { const { value } = await reader.read() const text = new TextDecoder().decode(value!) expect(text).toContain(': keepalive') reader.cancel() } }) test('GET /web/sessions/:id/events — 403 for non-owner', async () => { const createRes = await app.request('/web/sessions?uuid=user-1', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}), }) const { id } = await createRes.json() const eventsRes = await app.request( `/web/sessions/${id}/events?uuid=user-2`, ) expect(eventsRes.status).toBe(403) }) test('GET /web/sessions/:id/events — 409 for archived session', async () => { const createRes = await app.request('/web/sessions?uuid=user-1', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}), }) const { id } = await createRes.json() await app.request(`/v1/sessions/${id}/archive`, { method: 'POST', headers: AUTH_HEADERS, }) const res = await app.request(`/web/sessions/${id}/events?uuid=user-1`) expect(res.status).toBe(409) const body = await res.json() expect(body.error.type).toBe('session_closed') }) }) describe('Web Control Routes', () => { let app: Hono let sessionId: string beforeEach(async () => { storeReset() for (const [key] of getAllEventBuses()) { removeEventBus(key) } app = createApp() // Create and bind session const createRes = await app.request('/web/sessions?uuid=user-1', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}), }) sessionId = (await createRes.json()).id }) test('POST /web/sessions/:id/events — sends user message', async () => { const res = await app.request( `/web/sessions/${sessionId}/events?uuid=user-1`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: 'user', content: 'hello' }), }, ) expect(res.status).toBe(200) const body = await res.json() expect(body.status).toBe('ok') expect(body.event).toBeTruthy() }) test('POST /web/sessions/:id/events/control/interrupt — supports compat code session IDs', async () => { const rawSessionId = storeCreateSession({ idPrefix: 'cse_' }).id storeBindSession(rawSessionId, 'user-1') const compatId = toWebSessionId(rawSessionId) const eventsRes = await app.request( `/web/sessions/${compatId}/events?uuid=user-1`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: 'user', content: 'hello' }), }, ) expect(eventsRes.status).toBe(200) const controlRes = await app.request( `/web/sessions/${compatId}/control?uuid=user-1`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: 'permission_response', approved: true, request_id: 'r1', }), }, ) expect(controlRes.status).toBe(200) const interruptRes = await app.request( `/web/sessions/${compatId}/interrupt?uuid=user-1`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, }, ) expect(interruptRes.status).toBe(200) }) test('POST /web/sessions/:id/events — 403 for non-owner', async () => { const res = await app.request( `/web/sessions/${sessionId}/events?uuid=user-2`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: 'user', content: 'hello' }), }, ) expect(res.status).toBe(403) }) test('POST /web/sessions/:id/control — sends control request', async () => { const res = await app.request( `/web/sessions/${sessionId}/control?uuid=user-1`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: 'permission_response', approved: true, request_id: 'r1', }), }, ) expect(res.status).toBe(200) }) test('POST /web/sessions/:id/interrupt — interrupts session', async () => { const res = await app.request( `/web/sessions/${sessionId}/interrupt?uuid=user-1`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, }, ) expect(res.status).toBe(200) }) test('POST /web/sessions/:id/interrupt — 403 for non-owner', async () => { const res = await app.request( `/web/sessions/${sessionId}/interrupt?uuid=user-2`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, }, ) expect(res.status).toBe(403) }) test('POST /web/sessions/:id/control — 403 for non-owner', async () => { const res = await app.request( `/web/sessions/${sessionId}/control?uuid=user-2`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: 'permission_response', approved: true }), }, ) expect(res.status).toBe(403) }) test('POST /web/sessions/:id/events — 403 for non-existent session with no ownership', async () => { const res = await app.request( '/web/sessions/nonexistent/events?uuid=user-1', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: 'user', content: 'hello' }), }, ) expect(res.status).toBe(403) }) test('POST /web/sessions/:id/events/control/interrupt — 409 for archived session', async () => { await app.request(`/v1/sessions/${sessionId}/archive`, { method: 'POST', headers: AUTH_HEADERS, }) const eventsRes = await app.request( `/web/sessions/${sessionId}/events?uuid=user-1`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: 'user', content: 'hello' }), }, ) expect(eventsRes.status).toBe(409) const controlRes = await app.request( `/web/sessions/${sessionId}/control?uuid=user-1`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: 'permission_response', approved: true, request_id: 'r1', }), }, ) expect(controlRes.status).toBe(409) const interruptRes = await app.request( `/web/sessions/${sessionId}/interrupt?uuid=user-1`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, }, ) expect(interruptRes.status).toBe(409) }) }) describe('Web Environment Routes', () => { let app: Hono beforeEach(() => { storeReset() app = createApp() }) test('GET /web/environments — lists active environments', async () => { // Register an env via v1 await app.request('/v1/environments/bridge', { method: 'POST', headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' }, body: JSON.stringify({ machine_name: 'mac1' }), }) const res = await app.request('/web/environments?uuid=user-1') expect(res.status).toBe(200) const envs = await res.json() expect(envs).toHaveLength(1) expect(envs[0].machine_name).toBe('mac1') }) test('GET /web/environments — requires UUID', async () => { const res = await app.request('/web/environments') expect(res.status).toBe(401) }) }) describe('V1 Session Ingress Routes (HTTP)', () => { let app: Hono beforeEach(() => { storeReset() for (const [key] of getAllEventBuses()) { removeEventBus(key) } process.env.RCS_API_KEYS = 'test-api-key' app = createApp() }) test('POST /v2/session_ingress/session/:sessionId/events — ingests events with API key', async () => { // Create session first const sessRes = await app.request('/v1/sessions', { method: 'POST', headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' }, body: JSON.stringify({}), }) const { id } = await sessRes.json() const res = await app.request(`/v2/session_ingress/session/${id}/events`, { method: 'POST', headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' }, body: JSON.stringify({ events: [{ type: 'assistant', content: 'response' }], }), }) expect(res.status).toBe(200) const body = await res.json() expect(body.status).toBe('ok') }) test('POST /v2/session_ingress/session/:sessionId/events — rejects without auth', async () => { const res = await app.request('/v2/session_ingress/session/nope/events', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ events: [] }), }) expect(res.status).toBe(401) }) test('POST /v2/session_ingress/session/:sessionId/events — 404 for unknown session', async () => { const res = await app.request('/v2/session_ingress/session/nope/events', { method: 'POST', headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' }, body: JSON.stringify({ events: [{ type: 'user', content: 'hi' }] }), }) expect(res.status).toBe(404) }) test('POST /v2/session_ingress/session/:sessionId/events — resolves compat code session IDs', async () => { const sessRes = await app.request('/v1/code/sessions', { method: 'POST', headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' }, body: JSON.stringify({}), }) const { session: { id }, } = await sessRes.json() const compatId = toWebSessionId(id) const res = await app.request( `/v2/session_ingress/session/${compatId}/events`, { method: 'POST', headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' }, body: JSON.stringify({ events: [ { type: 'assistant', message: { role: 'assistant', content: 'compat ok' }, }, ], }), }, ) expect(res.status).toBe(200) const events = getEventBus(id).getEventsSince(0) expect(events).toHaveLength(1) expect(events[0]?.type).toBe('assistant') }) test('GET /v2/session_ingress/ws/:sessionId — accepts small payload into handler', async () => { const sessRes = await app.request('/v1/sessions', { method: 'POST', headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' }, body: JSON.stringify({}), }) const { id } = await sessRes.json() const server = Bun.serve({ port: 0, fetch: app.fetch, websocket: { ...sessionIngressWebsocket, idleTimeout: 30, }, }) try { const event = await new Promise((resolve, reject) => { let ws: WebSocket | undefined const timeout = setTimeout(() => { ws?.close() reject(new Error('Timed out waiting for inbound WebSocket payload')) }, 2000) const bus = getEventBus(id) const unsub = bus.subscribe(sessionEvent => { if ( sessionEvent.direction === 'inbound' && sessionEvent.type === 'user' ) { clearTimeout(timeout) unsub() ws?.close() resolve(sessionEvent) } }) ws = new WebSocket( `ws://127.0.0.1:${server.port}/v2/session_ingress/ws/${id}`, [encodeWebSocketAuthProtocol('test-api-key')], ) ws.onopen = () => { ws.send( JSON.stringify({ type: 'user', message: { role: 'user', content: 'hello' }, }) + '\n', ) } ws.onerror = () => { clearTimeout(timeout) unsub() reject(new Error('Session ingress WebSocket connection failed')) } }) expect((event as { type?: string }).type).toBe('user') } finally { await server.stop(true) } }) test('GET /v2/session_ingress/ws/:sessionId — closes 11MB payload with 1009', () => { const close = mock(() => {}) const handled = handleSessionIngressWsPayload( { close } as any, 'session_large', 'x'.repeat(11 * 1024 * 1024), ) expect(handled).toBe(false) expect(close).toHaveBeenCalledWith(1009, 'message too large') }) test('session ingress decode rejects unsupported payload types', () => { const close = mock(() => {}) const handled = handleSessionIngressWsPayload( { close } as any, 'session_bad', { data: 'bad' }, ) expect(decodeSessionIngressWsMessage({ data: 'bad' }).ok).toBe(false) expect(handled).toBe(false) expect(close).toHaveBeenCalledWith(1003, 'unsupported message payload') }) test('GET /v2/session_ingress/ws/:sessionId — resolves compat code session IDs', async () => { const sessRes = await app.request('/v1/code/sessions', { method: 'POST', headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' }, body: JSON.stringify({}), }) const { session: { id }, } = await sessRes.json() const compatId = toWebSessionId(id) publishSessionEvent(id, 'user', { content: 'compat ws replay' }, 'outbound') const server = Bun.serve({ port: 0, fetch: app.fetch, websocket: { ...sessionIngressWebsocket, idleTimeout: 30, }, }) try { const message = await new Promise((resolve, reject) => { const ws = new WebSocket( `ws://127.0.0.1:${server.port}/v2/session_ingress/ws/${compatId}`, [encodeWebSocketAuthProtocol('test-api-key')], ) const timeout = setTimeout(() => { ws.close() reject(new Error('Timed out waiting for compat WebSocket replay')) }, 2000) ws.onmessage = event => { const data = typeof event.data === 'string' ? event.data : String(event.data) if (data.includes('"type":"user"')) { clearTimeout(timeout) ws.close() resolve(data) } } ws.onerror = () => { clearTimeout(timeout) reject(new Error('Compat WebSocket connection failed')) } }) expect(message).toContain('"type":"user"') expect(message).toContain(`"session_id":"${id}"`) expect(message).toContain('compat ws replay') } finally { await server.stop(true) } }) }) describe('ACP Routes', () => { let app: Hono function createRelayAuthApp() { const authApp = new Hono() authApp.get('/relay-auth', c => c.json({ ok: hasAcpRelayAuth(c) })) return authApp } beforeEach(() => { storeReset() for (const [key] of getAllEventBuses()) { removeEventBus(key) } app = createApp() }) test('GET /acp/agents requires auth', async () => { const res = await app.request('/acp/agents') expect(res.status).toBe(401) }) test('GET /acp/agents rejects UUID-only auth', async () => { const res = await app.request('/acp/agents?uuid=user-1') expect(res.status).toBe(401) }) test('GET /acp/agents accepts API key header', async () => { storeCreateEnvironment({ secret: 'secret', machineName: 'agent-one', workerType: 'acp', bridgeId: 'group-one', }) const res = await app.request('/acp/agents', { headers: AUTH_HEADERS, }) expect(res.status).toBe(200) const body = await res.json() expect(body).toHaveLength(1) expect(body[0].agent_name).toBe('agent-one') }) test('GET /acp/channel-groups requires auth', async () => { const res = await app.request('/acp/channel-groups') expect(res.status).toBe(401) }) test('GET /acp/channel-groups rejects UUID-only auth', async () => { const res = await app.request('/acp/channel-groups?uuid=user-1') expect(res.status).toBe(401) }) test('GET /acp/channel-groups accepts API key header', async () => { storeCreateEnvironment({ secret: 'secret', machineName: 'agent-one', workerType: 'acp', bridgeId: 'group-one', }) const res = await app.request('/acp/channel-groups', { headers: AUTH_HEADERS, }) expect(res.status).toBe(200) const body = await res.json() expect(body).toHaveLength(1) expect(body[0].channel_group_id).toBe('group-one') }) test('GET /acp/channel-groups/:id requires auth', async () => { storeCreateEnvironment({ secret: 'secret', machineName: 'agent-one', workerType: 'acp', bridgeId: 'group-one', }) const res = await app.request('/acp/channel-groups/group-one') expect(res.status).toBe(401) }) test('GET /acp/channel-groups/:id rejects query token auth', async () => { storeCreateEnvironment({ secret: 'secret', machineName: 'agent-one', workerType: 'acp', bridgeId: 'group-one', }) const res = await app.request( '/acp/channel-groups/group-one?token=test-api-key', ) expect(res.status).toBe(401) }) test('GET /acp/channel-groups/:id rejects UUID-only auth', async () => { storeCreateEnvironment({ secret: 'secret', machineName: 'agent-one', workerType: 'acp', bridgeId: 'group-one', }) const res = await app.request('/acp/channel-groups/group-one?uuid=user-1') expect(res.status).toBe(401) }) test('GET /acp/channel-groups/:id returns group with API key auth', async () => { storeCreateEnvironment({ secret: 'secret', machineName: 'agent-one', workerType: 'acp', bridgeId: 'group-one', }) const res = await app.request('/acp/channel-groups/group-one', { headers: AUTH_HEADERS, }) expect(res.status).toBe(200) const body = await res.json() expect(body.channel_group_id).toBe('group-one') expect(body.member_count).toBe(1) }) test('GET /acp/channel-groups/:id/events requires auth', async () => { const res = await app.request('/acp/channel-groups/group-one/events') expect(res.status).toBe(401) }) test('GET /acp/channel-groups/:id/events rejects UUID-only auth', async () => { const res = await app.request( '/acp/channel-groups/group-one/events?uuid=user-1', ) expect(res.status).toBe(401) }) test('GET /acp/channel-groups/:id/events accepts API key header', async () => { const res = await app.request('/acp/channel-groups/group-one/events', { headers: AUTH_HEADERS, }) expect(res.status).toBe(200) expect(res.headers.get('Content-Type')).toBe('text/event-stream') await res.body?.cancel() }) test('ACP relay auth rejects UUID-only auth', async () => { const res = await createRelayAuthApp().request('/relay-auth?uuid=user-1') expect(await res.json()).toEqual({ ok: false }) }) test('ACP relay auth accepts API key header', async () => { const res = await createRelayAuthApp().request('/relay-auth', { headers: AUTH_HEADERS, }) expect(await res.json()).toEqual({ ok: true }) }) test('ACP relay auth accepts WebSocket protocol auth', async () => { const res = await createRelayAuthApp().request('/relay-auth', { headers: { 'Sec-WebSocket-Protocol': encodeWebSocketAuthProtocol('test-api-key'), }, }) expect(await res.json()).toEqual({ ok: true }) }) test('ACP WebSocket rejects legacy query-token auth on the real upgrade path', async () => { const server = Bun.serve({ port: 0, fetch: app.fetch, websocket: { ...sessionIngressWebsocket, idleTimeout: 30, }, }) try { const close = await new Promise((resolve, reject) => { const ws = new WebSocket( `ws://127.0.0.1:${server.port}/acp/ws?token=test-api-key`, ) const timeout = setTimeout(() => { ws.close() reject( new Error('Timed out waiting for ACP WebSocket auth rejection'), ) }, 2000) ws.onclose = event => { clearTimeout(timeout) resolve(event) } ws.onerror = () => { clearTimeout(timeout) reject( new Error('ACP WebSocket query-token test failed before close'), ) } }) expect(close.code).toBe(4003) expect(close.reason).toBe('unauthorized') } finally { server.stop(true) } }) test('ACP WebSocket accepts subprotocol auth on the real upgrade path', async () => { const server = Bun.serve({ port: 0, fetch: app.fetch, websocket: { ...sessionIngressWebsocket, idleTimeout: 30, }, }) try { const message = await new Promise((resolve, reject) => { const ws = new WebSocket(`ws://127.0.0.1:${server.port}/acp/ws`, [ encodeWebSocketAuthProtocol('test-api-key'), ]) const timeout = setTimeout(() => { ws.close() reject(new Error('Timed out waiting for ACP WebSocket registration')) }, 2000) ws.onopen = () => { ws.send( JSON.stringify({ type: 'register', agent_name: 'agent-one' }) + '\n', ) } ws.onmessage = event => { const data = typeof event.data === 'string' ? event.data : String(event.data) if (data.includes('"type":"registered"')) { clearTimeout(timeout) ws.close() resolve(data) } } ws.onerror = () => { clearTimeout(timeout) reject(new Error('ACP WebSocket subprotocol auth failed')) } }) expect(message).toContain('"agent_id"') } finally { await server.stop(true) } }) test('ACP relay WebSocket rejects legacy query-token auth on the real upgrade path', async () => { const server = Bun.serve({ port: 0, fetch: app.fetch, websocket: { ...sessionIngressWebsocket, idleTimeout: 30, }, }) try { const close = await new Promise((resolve, reject) => { const ws = new WebSocket( `ws://127.0.0.1:${server.port}/acp/relay/agent_123?token=test-api-key`, ) const timeout = setTimeout(() => { ws.close() reject( new Error('Timed out waiting for ACP relay query-token rejection'), ) }, 2000) ws.onclose = event => { clearTimeout(timeout) resolve(event) } ws.onerror = () => { clearTimeout(timeout) reject(new Error('ACP relay query-token test failed before close')) } }) expect(close.code).toBe(4003) expect(close.reason).toBe('unauthorized') } finally { server.stop(true) } }) test('ACP relay WebSocket accepts subprotocol auth on the real upgrade path', async () => { const server = Bun.serve({ port: 0, fetch: app.fetch, websocket: { ...sessionIngressWebsocket, idleTimeout: 30, }, }) try { const close = await new Promise((resolve, reject) => { const ws = new WebSocket( `ws://127.0.0.1:${server.port}/acp/relay/agent_123`, [encodeWebSocketAuthProtocol('test-api-key')], ) const timeout = setTimeout(() => { ws.close() reject( new Error('Timed out waiting for ACP relay authenticated close'), ) }, 2000) ws.onclose = event => { clearTimeout(timeout) resolve(event) } ws.onerror = () => { clearTimeout(timeout) reject(new Error('ACP relay subprotocol auth failed before close')) } }) expect(close.code).toBe(4004) expect(close.reason).toBe('agent not found') } finally { server.stop(true) } }) }) describe('ACP WebSocket payload guards', () => { test('rejects oversized multibyte text by byte size', () => { const close = mock(() => {}) const handleMessage = mock(() => {}) const payload = '你'.repeat(4 * 1024 * 1024) const decoded = decodeAcpWsMessageData(payload) const handled = handleAcpWsPayload( { close } as any, '[ACP-WS]', 'wsId=multibyte', payload, handleMessage, ) expect(decoded.ok && decoded.size).toBeGreaterThan(10 * 1024 * 1024) expect(handled).toBe(false) expect(handleMessage).not.toHaveBeenCalled() expect(close).toHaveBeenCalledWith(1009, 'message too large') }) test('rejects oversized binary payload by byte size', () => { const close = mock(() => {}) const handleMessage = mock(() => {}) const payload = new Uint8Array(11 * 1024 * 1024) const decoded = decodeAcpWsMessageData(payload) const handled = handleAcpWsPayload( { close } as any, '[ACP-Relay]', 'relayWsId=binary', payload, handleMessage, ) expect(decoded).toEqual({ ok: false, reason: 'message too large', size: 11 * 1024 * 1024, }) expect(handled).toBe(false) expect(handleMessage).not.toHaveBeenCalled() expect(close).toHaveBeenCalledWith(1009, 'message too large') }) test('accepts small payload into ACP handler', () => { const close = mock(() => {}) const handleMessage = mock(() => {}) const handled = handleAcpWsPayload( { close } as any, '[ACP-WS]', 'wsId=small', '{"type":"keep_alive"}', handleMessage, ) expect(handled).toBe(true) expect(handleMessage).toHaveBeenCalledWith('{"type":"keep_alive"}') expect(close).not.toHaveBeenCalled() }) }) describe('V2 Worker Events Routes', () => { let app: Hono beforeEach(() => { storeReset() for (const [key] of getAllEventBuses()) { removeEventBus(key) } process.env.RCS_API_KEYS = 'test-api-key' app = createApp() }) test('POST /v1/code/sessions/:id/worker/events — publishes worker events', async () => { // Create session const sessRes = await app.request('/v1/sessions', { method: 'POST', headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' }, body: JSON.stringify({}), }) const { id } = await sessRes.json() const res = await app.request(`/v1/code/sessions/${id}/worker/events`, { method: 'POST', headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' }, body: JSON.stringify([{ type: 'assistant', content: 'response' }]), }) expect(res.status).toBe(200) const body = await res.json() expect(body.status).toBe('ok') expect(body.count).toBe(1) }) test('POST /v1/code/sessions/:id/worker/events — unwraps CCR batch payloads', async () => { const sessRes = await app.request('/v1/code/sessions', { method: 'POST', headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' }, body: JSON.stringify({}), }) const { session: { id }, } = await sessRes.json() const res = await app.request(`/v1/code/sessions/${id}/worker/events`, { method: 'POST', headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' }, body: JSON.stringify({ worker_epoch: 1, events: [{ payload: { type: 'assistant', content: 'response' } }], }), }) expect(res.status).toBe(200) const body = await res.json() expect(body.count).toBe(1) const events = getEventBus(id).getEventsSince(0) expect(events).toHaveLength(1) expect(events[0]?.type).toBe('assistant') expect((events[0]?.payload as { content?: string }).content).toBe( 'response', ) }) test('GET/PUT /v1/code/sessions/:id/worker — stores worker state', async () => { const sessRes = await app.request('/v1/code/sessions', { method: 'POST', headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' }, body: JSON.stringify({}), }) const { session: { id }, } = await sessRes.json() const putRes = await app.request(`/v1/code/sessions/${id}/worker`, { method: 'PUT', headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' }, body: JSON.stringify({ worker_epoch: 1, worker_status: 'running', external_metadata: { permission_mode: 'default', automation_state: { enabled: true, phase: 'sleeping', next_tick_at: null, sleep_until: 123456, }, }, }), }) expect(putRes.status).toBe(200) const getRes = await app.request(`/v1/code/sessions/${id}/worker`, { headers: AUTH_HEADERS, }) expect(getRes.status).toBe(200) const body = await getRes.json() expect(body.worker.worker_status).toBe('running') expect(body.worker.external_metadata.permission_mode).toBe('default') expect(body.worker.external_metadata.automation_state).toEqual({ enabled: true, phase: 'sleeping', next_tick_at: null, sleep_until: 123456, }) const events = getEventBus(id).getEventsSince(0) expect(events.some(event => event.type === 'automation_state')).toBe(true) expect(events.at(-1)?.payload).toEqual({ enabled: true, phase: 'sleeping', next_tick_at: null, sleep_until: 123456, }) }) test('POST /v1/code/sessions/:id/worker/heartbeat — updates heartbeat', async () => { const sessRes = await app.request('/v1/code/sessions', { method: 'POST', headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' }, body: JSON.stringify({}), }) const { session: { id }, } = await sessRes.json() const heartbeatRes = await app.request( `/v1/code/sessions/${id}/worker/heartbeat`, { method: 'POST', headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' }, body: JSON.stringify({ worker_epoch: 1 }), }, ) expect(heartbeatRes.status).toBe(200) const getRes = await app.request(`/v1/code/sessions/${id}/worker`, { headers: AUTH_HEADERS, }) const body = await getRes.json() expect(body.worker.last_heartbeat_at).toBeTruthy() }) test('GET /v1/code/sessions/:id/worker/events/stream — emits CCR client_event frames', async () => { const sessRes = await app.request('/v1/code/sessions', { method: 'POST', headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' }, body: JSON.stringify({}), }) const { session: { id }, } = await sessRes.json() const streamRes = await app.request( `/v1/code/sessions/${id}/worker/events/stream`, { headers: AUTH_HEADERS, }, ) expect(streamRes.status).toBe(200) const reader = streamRes.body?.getReader() expect(reader).toBeTruthy() if (!reader) return const firstChunk = await reader.read() const keepalive = new TextDecoder().decode(firstChunk.value!) expect(keepalive).toContain(': keepalive') publishSessionEvent( id, 'user', { type: 'user', content: 'hello' }, 'outbound', ) const secondChunk = await reader.read() const frame = new TextDecoder().decode(secondChunk.value!) expect(frame).toContain('event: client_event') expect(frame).toContain( '"payload":{"type":"user","content":"hello","message":{"content":"hello"}}', ) reader.cancel() }) test('GET /v1/code/sessions/:id/worker/events/stream — normalizes web permission approvals to control_response', async () => { const createRes = await app.request('/web/sessions?uuid=user-1', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}), }) const { id } = await createRes.json() const streamRes = await app.request( `/v1/code/sessions/${id}/worker/events/stream`, { headers: AUTH_HEADERS, }, ) expect(streamRes.status).toBe(200) const reader = streamRes.body?.getReader() expect(reader).toBeTruthy() if (!reader) return await reader.read() // initial keepalive const controlRes = await app.request( `/web/sessions/${id}/control?uuid=user-1`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: 'permission_response', approved: true, request_id: 'req-1', }), }, ) expect(controlRes.status).toBe(200) const chunk = await reader.read() const frame = new TextDecoder().decode(chunk.value!) expect(frame).toContain('event: client_event') expect(frame).toContain('"event_type":"permission_response"') expect(frame).toContain('"payload":{"type":"control_response"') expect(frame).toContain('"request_id":"req-1"') expect(frame).toContain('"behavior":"allow"') reader.cancel() }) test('GET /v1/code/sessions/:id/worker/events/stream — normalizes web plan rejection feedback to deny control_response', async () => { const createRes = await app.request('/web/sessions?uuid=user-1', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}), }) const { id } = await createRes.json() const streamRes = await app.request( `/v1/code/sessions/${id}/worker/events/stream`, { headers: AUTH_HEADERS, }, ) expect(streamRes.status).toBe(200) const reader = streamRes.body?.getReader() expect(reader).toBeTruthy() if (!reader) return await reader.read() // initial keepalive const controlRes = await app.request( `/web/sessions/${id}/control?uuid=user-1`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: 'permission_response', approved: false, request_id: 'req-2', message: 'Need more detail', }), }, ) expect(controlRes.status).toBe(200) const chunk = await reader.read() const frame = new TextDecoder().decode(chunk.value!) expect(frame).toContain('event: client_event') expect(frame).toContain('"event_type":"permission_response"') expect(frame).toContain('"payload":{"type":"control_response"') expect(frame).toContain('"request_id":"req-2"') expect(frame).toContain('"subtype":"error"') expect(frame).toContain('"behavior":"deny"') expect(frame).toContain('"message":"Need more detail"') reader.cancel() }) test('GET /v1/code/sessions/:id/worker/events/stream — normalizes web interrupts to control_request', async () => { const createRes = await app.request('/web/sessions?uuid=user-1', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}), }) const { id } = await createRes.json() const streamRes = await app.request( `/v1/code/sessions/${id}/worker/events/stream`, { headers: AUTH_HEADERS, }, ) expect(streamRes.status).toBe(200) const reader = streamRes.body?.getReader() expect(reader).toBeTruthy() if (!reader) return await reader.read() // initial keepalive const interruptRes = await app.request( `/web/sessions/${id}/interrupt?uuid=user-1`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, }, ) expect(interruptRes.status).toBe(200) const chunk = await reader.read() const frame = new TextDecoder().decode(chunk.value!) expect(frame).toContain('event: client_event') expect(frame).toContain('"event_type":"interrupt"') expect(frame).toContain('"payload":{"type":"control_request"') expect(frame).toContain('"subtype":"interrupt"') reader.cancel() }) test('PUT /v1/code/sessions/:id/worker/state — updates session status', async () => { const sessRes = await app.request('/v1/sessions', { method: 'POST', headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' }, body: JSON.stringify({}), }) const { id } = await sessRes.json() const res = await app.request(`/v1/code/sessions/${id}/worker/state`, { method: 'PUT', headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' }, body: JSON.stringify({ status: 'running' }), }) expect(res.status).toBe(200) }) test('PUT /v1/code/sessions/:id/worker/external_metadata — no-op', async () => { const sessRes = await app.request('/v1/sessions', { method: 'POST', headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' }, body: JSON.stringify({}), }) const { id } = await sessRes.json() const res = await app.request( `/v1/code/sessions/${id}/worker/external_metadata`, { method: 'PUT', headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' }, body: JSON.stringify({ meta: 'data' }), }, ) expect(res.status).toBe(200) }) test('POST /v1/code/sessions/:id/worker/events/:eventId/delivery — no-op', async () => { const sessRes = await app.request('/v1/sessions', { method: 'POST', headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' }, body: JSON.stringify({}), }) const { id } = await sessRes.json() const res = await app.request( `/v1/code/sessions/${id}/worker/events/evt123/delivery`, { method: 'POST', headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' }, body: JSON.stringify({ status: 'received' }), }, ) expect(res.status).toBe(200) }) test('POST /v1/code/sessions/:id/worker/events/delivery — batch no-op', async () => { const sessRes = await app.request('/v1/code/sessions', { method: 'POST', headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' }, body: JSON.stringify({}), }) const { session: { id }, } = await sessRes.json() const res = await app.request( `/v1/code/sessions/${id}/worker/events/delivery`, { method: 'POST', headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' }, body: JSON.stringify({ worker_epoch: 1, updates: [{ event_id: 'evt123', status: 'received' }], }), }, ) expect(res.status).toBe(200) }) })