diff --git a/docs/configuration.md b/docs/configuration.md index 66c68fb..4d6c039 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -159,3 +159,42 @@ entrypoint = "MemoryService" binding = "TAROTSCRIPT" service = "tarotscript-worker" ``` + +## Chat WebSocket Durable Object + +The `/chat/ws` route uses a Durable Object binding named `CHAT_SESSION` to keep terminal and browser chat sessions attached to one ordered stream. + +```toml +[[durable_objects.bindings]] +name = "CHAT_SESSION" +class_name = "ChatSession" + +[[migrations]] +tag = "v1-chat-session" +new_sqlite_classes = ["ChatSession"] +``` + +The route is protected by the same `AEGIS_TOKEN` as the HTTP API. Connect with `wss:///chat/ws?token=` and request the `aegis-chat` subprotocol. + +Client frames. `eventId` is optional but recommended for reconnect/replay deduplication: + +```json +{ "type": "message", "text": "What changed today?", "conversationId": "optional-uuid", "eventId": "optional-client-event-id" } +``` + +Server frames: + +```json +{ "type": "history", "conversationId": null, "messages": [] } +{ "type": "start", "conversationId": "uuid" } +{ "type": "delta", "text": "partial text" } +{ "type": "done", "conversationId": "uuid", "metadata": { "id": "message-id" } } +{ "type": "error", "error": "message" } +``` + +Fresh databases created from `schema.sql` include the required `conversations.user_id` column. Existing deployments should add it before enabling `/chat/ws`: + +```sql +ALTER TABLE conversations ADD COLUMN user_id TEXT NOT NULL DEFAULT 'operator'; +CREATE INDEX IF NOT EXISTS idx_conversations_user_updated ON conversations(user_id, updated_at); +``` diff --git a/docs/getting-started.md b/docs/getting-started.md index 2ffcb86..5341917 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -37,6 +37,8 @@ npx wrangler d1 create my-agent This prints a `database_id` — paste it into `wrangler.toml` under `[[d1_databases]]`. +The example Wrangler config also includes the `CHAT_SESSION` Durable Object binding and SQLite-backed migration required by `/chat/ws`. + Then run the schema migration: ```bash diff --git a/web/package.json b/web/package.json index 27e7ccc..deaa59b 100755 --- a/web/package.json +++ b/web/package.json @@ -51,6 +51,7 @@ "./routes/health": "./src/routes/health.ts", "./routes/sessions": "./src/routes/sessions.ts", "./routes/messages": "./src/routes/messages.ts", + "./routes/chat-ws": "./src/routes/chat-ws.ts", "./routes/conversations": "./src/routes/conversations.ts", "./routes/feedback": "./src/routes/feedback.ts", "./routes/observability": "./src/routes/observability.ts", @@ -58,6 +59,8 @@ "./routes/pages": "./src/routes/pages.ts", "./routes/dynamic-tools": "./src/routes/dynamic-tools.ts", "./adapters/voice": "./src/adapters/voice/cloudflare-agent.ts", + "./durable-objects/chat-session": "./src/durable-objects/chat-session.ts", + "./durable-objects/chat-session-auth": "./src/durable-objects/chat-session-auth.ts", "./schema-enums": "./src/schema-enums.ts", "./contracts/goal": "./src/contracts/goal.contract.ts", "./contracts/agenda-item": "./src/contracts/agenda-item.contract.ts", diff --git a/web/schema.sql b/web/schema.sql index a0ec217..4e94a8e 100755 --- a/web/schema.sql +++ b/web/schema.sql @@ -7,6 +7,7 @@ CREATE TABLE IF NOT EXISTS conversations ( id TEXT PRIMARY KEY, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')), + user_id TEXT NOT NULL DEFAULT 'operator', title TEXT ); @@ -200,6 +201,7 @@ CREATE TABLE IF NOT EXISTS operator_log ( CREATE INDEX IF NOT EXISTS idx_messages_conversation ON messages(conversation_id); CREATE INDEX IF NOT EXISTS idx_messages_created ON messages(created_at); +CREATE INDEX IF NOT EXISTS idx_conversations_user_updated ON conversations(user_id, updated_at); CREATE INDEX IF NOT EXISTS idx_memory_topic ON memory_entries(topic); CREATE INDEX IF NOT EXISTS idx_memory_dedup ON memory_entries(topic, fact_hash); CREATE INDEX IF NOT EXISTS idx_memory_expires ON memory_entries(expires_at); diff --git a/web/src/core.ts b/web/src/core.ts index f118d1a..4152c01 100644 --- a/web/src/core.ts +++ b/web/src/core.ts @@ -27,6 +27,7 @@ import { observability } from './routes/observability.js'; import { pages } from './routes/pages.js'; import { ccTasks } from './routes/cc-tasks.js'; import { messages } from './routes/messages.js'; +import { chatWs } from './routes/chat-ws.js'; import { dynamicToolsRoutes } from './routes/dynamic-tools.js'; // ─── Scheduled Task Plugin ────────────────────────────────── @@ -213,6 +214,7 @@ export function createAegisApp(config: AegisAppConfig): AegisApp { app.route('/', pages); app.route('/', ccTasks); app.route('/', messages); + app.route('/', chatWs); app.route('/', dynamicToolsRoutes); // ── Extension routes ── @@ -268,6 +270,7 @@ export function createAegisApp(config: AegisAppConfig): AegisApp { pages, ccTasks, messages, + chatWs, dynamicTools: dynamicToolsRoutes, }; @@ -288,6 +291,8 @@ export type { MessageMetadata, } from './types.js'; +export { ChatSession } from './durable-objects/chat-session.js'; + export type { EdgeEnv } from './kernel/dispatch.js'; export type { diff --git a/web/src/durable-objects/chat-session-auth.ts b/web/src/durable-objects/chat-session-auth.ts new file mode 100644 index 0000000..ae15a62 --- /dev/null +++ b/web/src/durable-objects/chat-session-auth.ts @@ -0,0 +1,20 @@ +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +export type ConversationOwnership = 'owned' | 'not_owned' | 'not_found'; + +export function isValidConversationId(id: string): boolean { + return UUID_RE.test(id); +} + +export async function verifyConversationOwnership( + db: D1Database, + conversationId: string, + userId: string, +): Promise { + const row = await db.prepare( + 'SELECT user_id FROM conversations WHERE id = ?', + ).bind(conversationId).first<{ user_id: string | null }>(); + + if (!row) return 'not_found'; + return (row.user_id ?? 'operator') === userId ? 'owned' : 'not_owned'; +} diff --git a/web/src/durable-objects/chat-session.ts b/web/src/durable-objects/chat-session.ts new file mode 100644 index 0000000..de74a12 --- /dev/null +++ b/web/src/durable-objects/chat-session.ts @@ -0,0 +1,239 @@ +import type { Env, MessageMetadata } from '../types.js'; +import { buildEdgeEnv } from '../edge-env.js'; +import { createIntent, dispatchStream } from '../kernel/dispatch.js'; +import type { DispatchResult } from '../kernel/types.js'; +import { isValidConversationId, verifyConversationOwnership } from './chat-session-auth.js'; +import { z } from 'zod'; + +const MessageFrameSchema = z.object({ + type: z.literal('message'), + text: z.string().trim().min(1), + conversationId: z.string().optional(), + eventId: z.string().trim().min(1).optional(), +}).passthrough(); + +type StoredMessage = { + id: string; + role: 'user' | 'assistant'; + content: string; + metadata: string | null; + created_at: string; +}; + +export class ChatSession implements DurableObject { + constructor( + private readonly state: DurableObjectState, + private readonly env: Env, + ) {} + + async fetch(request: Request): Promise { + if (request.headers.get('Upgrade')?.toLowerCase() !== 'websocket') { + return new Response('Expected WebSocket upgrade', { status: 426 }); + } + + const pair = new WebSocketPair(); + const client = pair[0]; + const server = pair[1]; + const url = new URL(request.url); + const userId = this.userId(); + const conversationId = url.searchParams.get('conversationId'); + + this.state.acceptWebSocket(server, ['aegis-chat']); + this.state.waitUntil(this.sendHistory(server, conversationId, userId)); + + const headers = new Headers(); + if (request.headers.get('Sec-WebSocket-Protocol')?.split(',').map((p) => p.trim()).includes('aegis-chat')) { + headers.set('Sec-WebSocket-Protocol', 'aegis-chat'); + } + + return new Response(null, { + status: 101, + webSocket: client, + headers, + }); + } + + async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise { + let payload: unknown; + try { + payload = JSON.parse(typeof message === 'string' ? message : new TextDecoder().decode(message)); + } catch { + this.send(ws, { type: 'error', error: 'Invalid JSON frame' }); + return; + } + + if (!isRecord(payload) || payload.type !== 'message') return; + + const parsed = MessageFrameSchema.safeParse(payload); + if (!parsed.success) { + this.send(ws, { type: 'error', error: 'Invalid message frame' }); + return; + } + + const eventId = parsed.data.eventId ?? crypto.randomUUID(); + const eventClaimed = await this.claimEvent(eventId); + if (!eventClaimed) { + this.send(ws, { type: 'error', error: 'duplicate event' }); + return; + } + + const text = parsed.data.text; + const userId = this.userId(); + const conversationId = await this.resolveConversationId(ws, parsed.data.conversationId, userId, text); + if (!conversationId) return; + + const userMessageId = crypto.randomUUID(); + await this.env.DB.prepare( + 'INSERT INTO messages (id, conversation_id, role, content) VALUES (?, ?, ?, ?)', + ).bind(userMessageId, conversationId, 'user', text).run(); + + this.send(ws, { type: 'start', conversationId }); + + try { + const edgeEnv = buildEdgeEnv(this.env); + const intent = createIntent(conversationId, text); + const result = await dispatchStream(intent, edgeEnv, (delta) => { + this.send(ws, { type: 'delta', text: delta }); + }); + + const assistantMessageId = crypto.randomUUID(); + const metadata = buildMessageMetadata(result); + + await this.env.DB.prepare( + 'INSERT INTO messages (id, conversation_id, role, content, metadata) VALUES (?, ?, ?, ?, ?)', + ).bind(assistantMessageId, conversationId, 'assistant', result.text, JSON.stringify(metadata)).run(); + + await this.env.DB.prepare( + "UPDATE conversations SET updated_at = datetime('now') WHERE id = ?", + ).bind(conversationId).run(); + + this.send(ws, { type: 'done', conversationId, metadata: { id: assistantMessageId, ...metadata } }); + } catch (err) { + const error = err instanceof Error ? err.message : String(err); + this.send(ws, { type: 'error', error }); + } + } + + private async resolveConversationId( + ws: WebSocket, + rawConversationId: unknown, + userId: string, + firstMessage: string, + ): Promise { + if (rawConversationId !== undefined && typeof rawConversationId !== 'string') { + this.send(ws, { type: 'error', error: 'conversationId must be a UUID string' }); + return null; + } + + const conversationId = rawConversationId ?? crypto.randomUUID(); + if (!isValidConversationId(conversationId)) { + this.send(ws, { type: 'error', error: 'conversationId must be a UUID string' }); + return null; + } + + const ownership = await verifyConversationOwnership(this.env.DB, conversationId, userId); + if (ownership === 'not_owned') { + this.send(ws, { type: 'error', error: 'conversation not found' }); + return null; + } + if (ownership === 'owned') return conversationId; + + await this.env.DB.prepare( + 'INSERT INTO conversations (id, title, user_id) VALUES (?, ?, ?)', + ).bind(conversationId, firstMessage.slice(0, 100), userId).run(); + return conversationId; + } + + private async claimEvent(eventId: string): Promise { + const existing = await this.env.DB.prepare( + 'SELECT event_id FROM web_events WHERE event_id = ?', + ).bind(eventId).first(); + if (existing) return false; + + await this.env.DB.prepare( + 'INSERT INTO web_events (event_id) VALUES (?)', + ).bind(eventId).run(); + return true; + } + + private async sendHistory(ws: WebSocket, conversationId: string | null, userId: string): Promise { + if (!conversationId) { + this.send(ws, { type: 'history', conversationId: null, messages: [] }); + return; + } + if (!isValidConversationId(conversationId)) { + this.send(ws, { type: 'error', error: 'conversationId must be a UUID string' }); + return; + } + + const ownership = await verifyConversationOwnership(this.env.DB, conversationId, userId); + if (ownership === 'not_owned') { + this.send(ws, { type: 'error', error: 'conversation not found' }); + return; + } + if (ownership === 'not_found') { + this.send(ws, { type: 'history', conversationId, messages: [] }); + return; + } + + const rows = await this.env.DB.prepare( + `SELECT m.id, m.role, m.content, m.metadata, m.created_at + FROM messages m + JOIN conversations c ON c.id = m.conversation_id + WHERE m.conversation_id = ? AND c.user_id = ? + ORDER BY m.created_at ASC`, + ).bind(conversationId, userId).all(); + + this.send(ws, { + type: 'history', + conversationId, + messages: rows.results.map((message) => ({ + ...message, + metadata: parseMetadata(message.metadata), + })), + }); + } + + private userId(): string { + return this.state.id.name ?? 'operator'; + } + + private send(ws: WebSocket, frame: Record): void { + try { + ws.send(JSON.stringify(frame)); + } catch { + // Ignore writes after the client disconnects. + } + } +} + +function buildMessageMetadata(result: DispatchResult): MessageMetadata { + return { + classification: result.classification, + executor: result.executor, + procHit: result.procedureHit, + latencyMs: result.latency_ms, + cost: result.cost, + confidence: result.confidence, + reclassified: result.reclassified, + probeResult: result.probeResult, + grounded: result.grounded, + sources: result.sources, + unknowns: result.unknowns, + searched: result.searched, + unverifiedClaims: result.unverified_claims, + }; +} + +function parseMetadata(value: string | null): MessageMetadata | null { + if (!value) return null; + try { + return JSON.parse(value) as MessageMetadata; + } catch { + return null; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} diff --git a/web/src/index.ts b/web/src/index.ts index eaa61f8..e7e3425 100755 --- a/web/src/index.ts +++ b/web/src/index.ts @@ -24,6 +24,7 @@ import { observability } from './routes/observability.js'; import { pages } from './routes/pages.js'; import { ccTasks } from './routes/cc-tasks.js'; import { messages } from './routes/messages.js'; +import { chatWs } from './routes/chat-ws.js'; import { content } from './routes/content.js'; import { codebeast } from './routes/codebeast.js'; import { bluesky } from './routes/bluesky.js'; @@ -44,6 +45,7 @@ app.route('/', observability); app.route('/', pages); app.route('/', ccTasks); app.route('/', messages); +app.route('/', chatWs); app.route('/', content); app.route('/', codebeast); app.route('/', bluesky); @@ -87,3 +89,5 @@ export default { ctx.waitUntil(runScheduledTasks(buildEdgeEnv(env))); }, }; + +export { ChatSession } from './durable-objects/chat-session.js'; diff --git a/web/src/routes/chat-ws.ts b/web/src/routes/chat-ws.ts new file mode 100644 index 0000000..d3664ef --- /dev/null +++ b/web/src/routes/chat-ws.ts @@ -0,0 +1,17 @@ +import { Hono } from 'hono'; +import type { Env } from '../types.js'; + +export const chatWs = new Hono<{ Bindings: Env }>(); + +chatWs.get('/chat/ws', async (c) => { + if (c.req.header('Upgrade')?.toLowerCase() !== 'websocket') { + return c.text('Expected WebSocket upgrade', 426); + } + if (!c.env.CHAT_SESSION) { + return c.text('CHAT_SESSION Durable Object binding is not configured', 503); + } + + const id = c.env.CHAT_SESSION.idFromName('operator'); + const stub = c.env.CHAT_SESSION.get(id); + return stub.fetch(c.req.raw); +}); diff --git a/web/src/types.ts b/web/src/types.ts index a377dc5..25641b0 100755 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -4,6 +4,7 @@ export interface Env { DB: D1Database; AI: Ai; AEGIS_TOKEN: string; + CHAT_SESSION?: DurableObjectNamespace; // OAuth 2.1 (injected by OAuthProvider wrapper at runtime) OAUTH_PROVIDER: OAuthHelpers; @@ -190,5 +191,10 @@ export interface MessageMetadata { confidence?: number; reclassified?: boolean; probeResult?: string; + grounded?: boolean; + sources?: string[]; + unknowns?: string[]; + searched?: string[]; + unverifiedClaims?: string[]; error?: boolean; } diff --git a/web/tests/chat-session-auth.test.ts b/web/tests/chat-session-auth.test.ts new file mode 100644 index 0000000..a63a4c7 --- /dev/null +++ b/web/tests/chat-session-auth.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, vi } from 'vitest'; +import { isValidConversationId, verifyConversationOwnership } from '../src/durable-objects/chat-session-auth.js'; + +function makeDb(row: { user_id: string | null } | null) { + return { + prepare: vi.fn(() => ({ + bind: vi.fn(() => ({ + first: vi.fn().mockResolvedValue(row), + })), + })), + } as unknown as D1Database; +} + +describe('chat-session auth helpers', () => { + it('accepts only UUID conversation ids', () => { + expect(isValidConversationId('018f9e54-7f61-4e01-8a04-7b54c23b2e10')).toBe(true); + expect(isValidConversationId('conv-1')).toBe(false); + expect(isValidConversationId('../other')).toBe(false); + }); + + it('reports owned conversations', async () => { + await expect( + verifyConversationOwnership(makeDb({ user_id: 'operator' }), '018f9e54-7f61-4e01-8a04-7b54c23b2e10', 'operator'), + ).resolves.toBe('owned'); + }); + + it('treats legacy null user_id rows as operator-owned', async () => { + await expect( + verifyConversationOwnership(makeDb({ user_id: null }), '018f9e54-7f61-4e01-8a04-7b54c23b2e10', 'operator'), + ).resolves.toBe('owned'); + }); + + it('distinguishes missing and foreign conversations', async () => { + await expect( + verifyConversationOwnership(makeDb(null), '018f9e54-7f61-4e01-8a04-7b54c23b2e10', 'operator'), + ).resolves.toBe('not_found'); + + await expect( + verifyConversationOwnership(makeDb({ user_id: 'other-user' }), '018f9e54-7f61-4e01-8a04-7b54c23b2e10', 'operator'), + ).resolves.toBe('not_owned'); + }); +}); diff --git a/web/tests/chat-session.test.ts b/web/tests/chat-session.test.ts new file mode 100644 index 0000000..d002162 --- /dev/null +++ b/web/tests/chat-session.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; + +vi.mock('../src/kernel/dispatch.js', () => ({ + createIntent: vi.fn((conversationId: string, text: string) => ({ conversationId, text })), + dispatchStream: vi.fn(), +})); + +vi.mock('../src/edge-env.js', () => ({ + buildEdgeEnv: vi.fn((env: unknown) => ({ env })), +})); + +import { ChatSession } from '../src/durable-objects/chat-session.js'; +import { dispatchStream } from '../src/kernel/dispatch.js'; +import type { Env } from '../src/types.js'; + +const CONVERSATION_ID = '018f9e54-7f61-4e01-8a04-7b54c23b2e10'; + +function makeDb(owner: string | null = null, duplicateEvent = false) { + const queries: { sql: string; bindings: unknown[] }[] = []; + return { + prepare(sql: string) { + const entry = { sql, bindings: [] as unknown[] }; + queries.push(entry); + return { + bind(...bindings: unknown[]) { + entry.bindings = bindings; + return this; + }, + async first() { + if (sql.includes('SELECT event_id FROM web_events')) { + return duplicateEvent ? { event_id: 'evt-1' } : null; + } + if (sql.includes('SELECT user_id FROM conversations')) { + return owner ? { user_id: owner } : null; + } + return null; + }, + async all() { + return { results: [] }; + }, + async run() { + return { meta: { changes: 1 } }; + }, + }; + }, + _queries: queries, + } as unknown as D1Database & { _queries: { sql: string; bindings: unknown[] }[] }; +} + +function makeSession(db: D1Database) { + const state = { + id: { name: 'operator' }, + acceptWebSocket: vi.fn(), + waitUntil: vi.fn(), + } as unknown as DurableObjectState; + const env = { DB: db } as Env; + return new ChatSession(state, env); +} + +function sentFrames(ws: { send: ReturnType }) { + return ws.send.mock.calls.map(([frame]) => JSON.parse(String(frame))); +} + +describe('ChatSession Durable Object', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('persists a new conversation and streams assistant frames', async () => { + vi.mocked(dispatchStream).mockImplementation(async (_intent, _env, onDelta) => { + onDelta('partial'); + return { + text: 'complete', + classification: 'general_knowledge', + executor: 'gpt_oss', + latency_ms: 12, + cost: 0.001, + grounded: true, + sources: ['wiki:demo'], + } as any; + }); + + const db = makeDb(); + const session = makeSession(db); + const ws = { send: vi.fn() }; + + await session.webSocketMessage(ws as unknown as WebSocket, JSON.stringify({ + type: 'message', + text: 'What changed?', + conversationId: CONVERSATION_ID, + eventId: 'evt-1', + })); + + const frames = sentFrames(ws); + expect(frames.map((frame) => frame.type)).toEqual(['start', 'delta', 'done']); + expect(frames[0].conversationId).toBe(CONVERSATION_ID); + expect(frames[1].text).toBe('partial'); + expect(frames[2].metadata.executor).toBe('gpt_oss'); + expect(frames[2].metadata.grounded).toBe(true); + + expect(db._queries.some((query) => + query.sql.includes('INSERT INTO conversations') && + query.bindings.includes(CONVERSATION_ID) && + query.bindings.includes('operator'), + )).toBe(true); + expect(dispatchStream).toHaveBeenCalledTimes(1); + }); + + it('rejects foreign conversations before dispatching', async () => { + const db = makeDb('other-user'); + const session = makeSession(db); + const ws = { send: vi.fn() }; + + await session.webSocketMessage(ws as unknown as WebSocket, JSON.stringify({ + type: 'message', + text: 'Resume this', + conversationId: CONVERSATION_ID, + eventId: 'evt-1', + })); + + const frames = sentFrames(ws); + expect(frames).toEqual([{ type: 'error', error: 'conversation not found' }]); + expect(dispatchStream).not.toHaveBeenCalled(); + }); + + it('deduplicates replayed client events before dispatching', async () => { + const db = makeDb(null, true); + const session = makeSession(db); + const ws = { send: vi.fn() }; + + await session.webSocketMessage(ws as unknown as WebSocket, JSON.stringify({ + type: 'message', + text: 'Replay this', + conversationId: CONVERSATION_ID, + eventId: 'evt-1', + })); + + const frames = sentFrames(ws); + expect(frames).toEqual([{ type: 'error', error: 'duplicate event' }]); + expect(dispatchStream).not.toHaveBeenCalled(); + }); +}); diff --git a/web/tests/chat-ws.test.ts b/web/tests/chat-ws.test.ts new file mode 100644 index 0000000..6437200 --- /dev/null +++ b/web/tests/chat-ws.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it, vi } from 'vitest'; +import { Hono } from 'hono'; +import { chatWs } from '../src/routes/chat-ws.js'; +import type { Env } from '../src/types.js'; + +function makeApp(env: Partial) { + const app = new Hono<{ Bindings: Env }>(); + app.route('/', chatWs); + return { + request: (path: string, init?: RequestInit) => app.request(path, init, env as Env), + }; +} + +describe('chatWs route', () => { + it('rejects non-websocket requests', async () => { + const app = makeApp({}); + const res = await app.request('/chat/ws'); + + expect(res.status).toBe(426); + await expect(res.text()).resolves.toContain('Expected WebSocket upgrade'); + }); + + it('fails closed when CHAT_SESSION is missing', async () => { + const app = makeApp({}); + const res = await app.request('/chat/ws', { + headers: { Upgrade: 'websocket' }, + }); + + expect(res.status).toBe(503); + await expect(res.text()).resolves.toContain('CHAT_SESSION'); + }); + + it('forwards websocket upgrades to the operator ChatSession object', async () => { + const forwarded = new Response('forwarded', { status: 209 }); + const stub = { fetch: vi.fn().mockResolvedValue(forwarded) }; + const namespace = { + idFromName: vi.fn((name: string) => ({ name })), + get: vi.fn(() => stub), + }; + const app = makeApp({ CHAT_SESSION: namespace as unknown as DurableObjectNamespace }); + + const res = await app.request('/chat/ws?token=test-token', { + headers: { Upgrade: 'websocket' }, + }); + + expect(res.status).toBe(209); + expect(namespace.idFromName).toHaveBeenCalledWith('operator'); + expect(namespace.get).toHaveBeenCalledWith({ name: 'operator' }); + expect(stub.fetch).toHaveBeenCalledTimes(1); + }); +}); diff --git a/web/wrangler.toml.example b/web/wrangler.toml.example index 9caed6f..e0b1ddd 100644 --- a/web/wrangler.toml.example +++ b/web/wrangler.toml.example @@ -13,6 +13,14 @@ database_name = "my-agent" [ai] binding = "AI" +[[durable_objects.bindings]] +name = "CHAT_SESSION" +class_name = "ChatSession" + +[[migrations]] +tag = "v1-chat-session" +new_sqlite_classes = ["ChatSession"] + # Triggers — hourly cron for scheduled tasks [triggers] crons = ["0 * * * *"]