diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts new file mode 100644 index 0000000..46c2bfc --- /dev/null +++ b/app/api/chat/route.ts @@ -0,0 +1,30 @@ +import { streamText } from 'ai'; +import { google } from '@ai-sdk/google'; +import { getCurrentUser } from '@/lib/auth/session'; +import { cookies } from 'next/headers'; + +export const runtime = 'edge'; + +export async function POST(req: Request) { + // Check authentication + const cookieStore = await cookies(); + const authCookie = cookieStore.get('ponderants-auth'); + + if (!authCookie) { + return new Response('Unauthorized', { status: 401 }); + } + + const { messages } = await req.json(); + + // Use Google's Gemini model for chat + const result = streamText({ + model: google('gemini-1.5-flash'), + messages, + system: `You are a thoughtful interviewer helping the user explore and capture their ideas. + Ask insightful questions to help them develop their thoughts. + Be concise but encouraging. When the user expresses a complete thought, + acknowledge it and help them refine it into a clear, structured idea.`, + }); + + return result.toDataStreamResponse(); +} diff --git a/app/chat/page.tsx b/app/chat/page.tsx index 4e0a14c..1de13bf 100644 --- a/app/chat/page.tsx +++ b/app/chat/page.tsx @@ -1,6 +1,6 @@ import { redirect } from 'next/navigation'; import { getCurrentUser } from '@/lib/auth/session'; -import { Center, Paper, Stack, Title, Text } from '@mantine/core'; +import { ChatInterface } from '@/components/ChatInterface'; export default async function ChatPage() { const user = await getCurrentUser(); @@ -10,21 +10,5 @@ export default async function ChatPage() { redirect('/login'); } - return ( -
- - - - Welcome to Ponderants - - - Logged in as: {user.handle} - - - DID: {user.did} - - - -
- ); + return ; } diff --git a/components/ChatInterface.tsx b/components/ChatInterface.tsx new file mode 100644 index 0000000..fe49125 --- /dev/null +++ b/components/ChatInterface.tsx @@ -0,0 +1,108 @@ +'use client'; + +import { useChat } from 'ai'; +import { Container, ScrollArea, Paper, Group, TextInput, Button, Stack, Text, Box } from '@mantine/core'; +import { useEffect, useRef } from 'react'; +import { MicrophoneRecorder } from './MicrophoneRecorder'; + +export function ChatInterface() { + const viewport = useRef(null); + + const { + messages, + input, + handleInputChange, + handleSubmit, + setInput, + isLoading, + } = useChat({ + api: '/api/chat', + }); + + // Auto-scroll to bottom when new messages arrive + useEffect(() => { + if (viewport.current) { + viewport.current.scrollTo({ + top: viewport.current.scrollHeight, + behavior: 'smooth', + }); + } + }, [messages]); + + return ( + + + {/* Chat messages area */} + + + {messages.length === 0 && ( + + Start a conversation by typing or speaking... + + )} + {messages.map((message) => ( + + + {message.content} + + + ))} + + + + {/* Input area */} +
+ + + + + {/* Microphone Recorder */} + { + // Update the input field in real-time + setInput(transcript); + }} + onTranscriptFinalized={(transcript) => { + // Set the input and submit + setInput(transcript); + // Trigger form submission + setTimeout(() => { + const form = document.querySelector('form'); + if (form) { + form.requestSubmit(); + } + }, 100); + }} + /> + + + + +
+
+
+ ); +} diff --git a/components/MicrophoneRecorder.tsx b/components/MicrophoneRecorder.tsx new file mode 100644 index 0000000..f1052d0 --- /dev/null +++ b/components/MicrophoneRecorder.tsx @@ -0,0 +1,154 @@ +'use client'; + +import { ActionIcon, Tooltip } from '@mantine/core'; +import { IconMicrophone, IconMicrophoneOff } from '@tabler/icons-react'; +import { useState, useRef } from 'react'; + +// Define the shape of the Deepgram transcript +interface DeepgramTranscript { + channel: { + alternatives: Array<{ + transcript: string; + }>; + }; + is_final: boolean; + speech_final: boolean; +} + +type Props = { + /** + * Callback function to update the chat input with the new transcript. + * @param transcript - The full, combined transcript + */ + onTranscriptUpdate: (transcript: string) => void; + /** + * Callback function to signal the final transcript for this "thought". + * @param transcript - The final, punctuated transcript + */ + onTranscriptFinalized: (transcript: string) => void; +}; + +export function MicrophoneRecorder({ onTranscriptUpdate, onTranscriptFinalized }: Props) { + const [isRecording, setIsRecording] = useState(false); + const mediaRecorderRef = useRef(null); + const socketRef = useRef(null); + + // Store the combined transcript for the current utterance + const transcriptRef = useRef(''); + + const stopRecording = () => { + if (mediaRecorderRef.current) { + mediaRecorderRef.current.stop(); + mediaRecorderRef.current = null; + } + if (socketRef.current) { + socketRef.current.close(); + socketRef.current = null; + } + setIsRecording(false); + + // Finalize the transcript + if (transcriptRef.current) { + onTranscriptFinalized(transcriptRef.current); + } + transcriptRef.current = ''; + }; + + const startRecording = async () => { + transcriptRef.current = ''; // Reset transcript + try { + // 1. Get the temporary Deepgram key + const response = await fetch('/api/voice-token', { method: 'POST' }); + const data = await response.json(); + + if (data.error) { + throw new Error(data.error); + } + + const { key } = data; + + // 2. Access the microphone + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + + // 3. Open direct WebSocket to Deepgram + const socket = new WebSocket( + 'wss://api.deepgram.com/v1/listen?interim_results=true&punctuate=true', + ['token', key] + ); + socketRef.current = socket; + + socket.onopen = () => { + // 4. Create MediaRecorder + const mediaRecorder = new MediaRecorder(stream, { + mimeType: 'audio/webm', + }); + mediaRecorderRef.current = mediaRecorder; + + // 5. Send audio chunks on data available + mediaRecorder.ondataavailable = (event) => { + if (event.data.size > 0 && socket.readyState === WebSocket.OPEN) { + socket.send(event.data); + } + }; + + // Start recording and chunking audio every 250ms + mediaRecorder.start(250); + setIsRecording(true); + }; + + // 6. Receive transcripts + socket.onmessage = (event) => { + const data = JSON.parse(event.data) as DeepgramTranscript; + const transcript = data.channel.alternatives[0]?.transcript || ''; + + if (transcript) { + transcriptRef.current = transcript; + onTranscriptUpdate(transcript); + } + + // If it's a "speech final" event, this utterance is done. + if (data.speech_final) { + stopRecording(); + } + }; + + socket.onclose = () => { + // Clean up stream + stream.getTracks().forEach((track) => track.stop()); + if (isRecording) { + stopRecording(); // Ensure cleanup + } + }; + + socket.onerror = (err) => { + console.error('WebSocket error:', err); + stopRecording(); + }; + } catch (error) { + console.error('Error starting recording:', error); + setIsRecording(false); + } + }; + + const handleToggleRecord = () => { + if (isRecording) { + stopRecording(); + } else { + startRecording(); + } + }; + + return ( + + + {isRecording ? : } + + + ); +} diff --git a/docs/steps/step-07.md b/docs/steps/step-07.md index 8e7d279..791c5c1 100644 --- a/docs/steps/step-07.md +++ b/docs/steps/step-07.md @@ -1,202 +1,286 @@ -# **File: COMMIT\_06\_WRITE\_FLOW.md** +# **File: COMMIT\_07\_CHAT.md** -## **Commit 6: Core Write-Through Cache API** +## **Commit 7: AI Interviewer: UI & Backend** ### **Objective** -Implement the POST /api/nodes route. This is the core "write-through cache" logic, which is the architectural foundation of the application. It must: +Build the conversational chat interface using the Vercel AI SDK. This includes: -1. Authenticate the user via their SurrealDB JWT. -2. Retrieve their ATproto access token (from the encrypted cookie). -3. **Step 1 (Truth):** Publish the new node to their PDS using the com.ponderants.node lexicon. -4. **Step 2 (Cache):** Generate a gemini-embedding-001 vector from the node's body. -5. **Step 3 (Cache):** Write the node, its atp\_uri, and its embedding to our SurrealDB cache. +1. A chat UI (client) that passes a persona in the request body. +2. An API route (server) that receives the persona and injects it into the system prompt. +3. **Crucially:** Using the AI SDK's tool or schema feature to force the AI to return a specific JSON object when it detects a "complete thought".18 This is far more reliable than prompt-based JSON instructions. ### **Implementation Specification** -**1\. Create lib/db.ts** +**1\. Define Structured Output Schema (lib/ai-schemas.ts)** -Create a helper file at /lib/db.ts for connecting to SurrealDB: +Create a file at /lib/ai-schemas.ts to define the Zod schema for the AI's structured output: TypeScript -import { Surreal } from 'surrealdb.js'; - -const db \= new Surreal(); +import { z } from 'zod'; /\*\* - \* Connects to the SurrealDB instance. - \* @param {string} token \- The user's app-specific (SurrealDB) JWT. + \* This Zod schema defines the \*only\* structured output + \* we want the AI to be able to generate. We will pass + \* this to the Vercel AI SDK to guarantee the AI's output + \* conforms to this shape. \[18\] \*/ -export async function connectToDB(token: string) { - if (\!db.connected) { - await db.connect(process.env.SURREALDB\_URL\!); - } - - // Authenticate as the user for this request. - // This enforces the row-level security (PERMISSIONS) - // defined in the schema for all subsequent queries. - await db.authenticate(token); - - return db; -} - -**2\. Create lib/ai.ts** - -Create a helper file at /lib/ai.ts for AI operations: - -TypeScript - -import { GoogleGenerativeAI } from '@google/generative-ai'; - -const genAI \= new GoogleGenerativeAI(process.env.GOOGLE\_API\_KEY\!); - -const embeddingModel \= genAI.getGenerativeModel({ - model: 'gemini-embedding-001', +export const NodeSuggestionSchema \= z.object({ + action: z.literal('suggest\_node'), + title: z + .string() + .describe('A concise, descriptive title for the thought node.'), + body: z + .string() + .describe('The full, well-structured content of the thought node.'), }); -/\*\* - \* Generates a vector embedding for a given text. - \* @param text The text to embed. - \* @returns A 1536-dimension vector (Array\). - \*/ -export async function generateEmbedding(text: string): Promise\ { - try { - const result \= await embeddingModel.embedContent(text); - return result.embedding.values; - } catch (error) { - console.error('Error generating embedding:', error); - throw new Error('Failed to generate AI embedding.'); - } -} +export type NodeSuggestion \= z.infer\; -**3\. Create Write API Route (app/api/nodes/route.ts)** +**2\. Create Chat API Route (app/api/chat/route.ts)** -Create the main API file at /app/api/nodes/route.ts: +Create the file at /app/api/chat/route.ts: TypeScript -import { NextRequest, NextResponse } from 'next/server'; -import { cookies } from 'next/headers'; -import { AtpAgent, RichText } from '@atproto/api'; -import { connectToDB } from '@/lib/db'; -import { generateEmbedding } from '@/lib/ai'; +import { + streamText, + StreamTextResult, + createStreamableValue, +} from '@ai-sdk/react'; +import { google } from '@ai-sdk/google'; +import { createOpenAI } from '@ai-sdk/openai'; +import { NextRequest } from 'next/server'; +import { NodeSuggestionSchema } from '@/lib/ai-schemas'; +import { z } from 'zod'; -export async function POST(request: NextRequest) { - const surrealJwt \= cookies().get('ponderants-auth')?.value; - const atpAccessToken \= cookies().get('atproto\_access\_token')?.value; +// Note: Ensure GOOGLE\_API\_KEY is set in your.env.local +const googleAi \= google('gemini-1.5-flash'); - if (\!surrealJwt ||\!atpAccessToken) { - return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); - } +export async function POST(req: NextRequest) { + const { messages, data } \= await req.json(); - let userDid: string; - try { - // Decode the JWT to get the DID for the SurrealDB query - // In a real app, we'd verify it, but for now we just - // pass it to connectToDB which authenticates with it. - const { payload } \= jwt.decode(surrealJwt, { complete: true })\!; - userDid \= (payload as { did: string }).did; - } catch (e) { - return NextResponse.json({ error: 'Invalid auth token' }, { status: 401 }); - } + // Get the 'persona' from the custom 'data' (or 'body') object + const { persona } \= z + .object({ + persona: z.string().optional().default('Socratic'), + }) + .parse(data); - const { title, body, links } \= (await request.json()) as { - title: string; - body: string; - links: string; // Array of at-uri strings - }; + // Dynamically create the system prompt + const systemPrompt \= \`You are a ${persona} thought partner. +Your goal is to interview the user to help them explore and structure their ideas. +When you identify a complete, self-contained idea, you MUST use the 'suggest\_node' tool +to propose it as a new "thought node". Do not suggest a node until the +idea is fully formed. +For all other conversation, just respond as a helpful AI.\`; - if (\!title ||\!body) { - return NextResponse.json({ error: 'Title and body are required' }, { status: 400 }); - } - - const createdAt \= new Date().toISOString(); - - // \--- Step 1: Write to Source of Truth (ATproto) \--- - let atp\_uri: string; - let atp\_cid: string; - - try { - const agent \= new AtpAgent({ service: 'https://bsky.social' }); // The service URL may need to be dynamic - await agent.resumeSession({ accessJwt: atpAccessToken, did: userDid, handle: '' }); // Simplified resume - - // Format the body as RichText - const rt \= new RichText({ text: body }); - await rt.detectFacets(agent); // Detect links, mentions - - const response \= await agent.post({ - $type: 'com.ponderants.node', - repo: userDid, - collection: 'com.ponderants.node', - record: { - title, - body: rt.text, - facets: rt.facets, // Include facets for rich text - links: links?.map(uri \=\> ({ $link: uri })) ||, // Convert URIs to strong refs - createdAt, - }, - }); + // Use the Vercel AI SDK's streamText function + const result: StreamTextResult \= await streamText({ + model: googleAi, + system: systemPrompt, + messages: messages, - atp\_uri \= response.uri; - atp\_cid \= response.cid; + // This is the critical part: + // We provide the schema as a 'tool' to the model. \[20\] + tools: { + suggest\_node: { + description: 'Suggest a new thought node when an idea is complete.', + schema: NodeSuggestionSchema, + }, + }, + }); + + // Return the streaming response + return result.toAIStreamResponse(); +} - } catch (error) { - console.error('ATproto write error:', error); - return NextResponse.json({ error: 'Failed to publish to PDS' }, { status: 500 }); - } +**3\. Create Chat UI (app/chat/page.tsx)** - // \--- Step 2: Generate AI Embedding (Cache) \--- - let embedding: number; - try { - embedding \= await generateEmbedding(title \+ '\\n' \+ body); - } catch (error) { - console.error('Embedding error:', error); - return NextResponse.json({ error: 'Failed to generate embedding' }, { status: 500 }); - } +Create the file at /app/chat/page.tsx: - // \--- Step 3: Write to App View Cache (SurrealDB) \--- - try { - const db \= await connectToDB(surrealJwt); +TypeScript - // Create the node record in our cache. - // The \`user\_did\` field is set, satisfying the 'PERMISSIONS' - // clause defined in the schema. - const newNode \= await db.create('node', { - user\_did: userDid, - atp\_uri: atp\_uri, - title: title, - body: body, // Store the raw text body - embedding: embedding, - // coords\_3d will be calculated later - }); +'use client'; - // Handle linking - if (links && links.length \> 0) { - // Find the corresponding cache nodes for the AT-URIs - const targetNodes: { id: string } \= await db.query( - 'SELECT id FROM node WHERE user\_did \= $did AND atp\_uri IN $links', - { did: userDid, links: links } - ); - - // Create graph relations - for (const targetNode of targetNodes) { - await db.query('RELATE $from-\>links\_to-\>$to', { - from: (newNode as any).id, - to: targetNode.id, +import { useChat } from '@ai-sdk/react'; +import { + Stack, + TextInput, + Button, + Paper, + ScrollArea, + Title, + Container, + Group, + Text, + LoadingOverlay, +} from '@mantine/core'; +import { useRouter } from 'next/navigation'; +import { useEffect, useRef } from 'react'; +import { NodeSuggestion } from '@/lib/ai-schemas'; + +export default function ChatPage() { + const router \= useRouter(); + const viewport \= useRef\(null); + + const { + messages, + input, + handleInputChange, + handleSubmit, + data, + isLoading, + } \= useChat({ + // Send the persona in the 'data' (formerly 'body') property \[21\] + data: { + persona: 'Socratic', // This could be a \ value + }, + // The 'experimental\_onToolCall' handler is fired when + // the AI returns the structured JSON 'suggest\_node' tool. + experimental\_onToolCall: (toolCall, appendToolResult) \=\> { + if (toolCall.toolName \=== 'suggest\_node') { + const { title, body } \= toolCall.args as NodeSuggestion; + + // Redirect to the editor with the AI-generated draft + const query \= new URLSearchParams({ title, body }).toString(); + router.push(\`/editor/new?${query}\`); + + // Return a message to display in the chat + return appendToolResult({ + toolName: 'suggest\_node', + args: { title, body }, + result: \`Drafting node: "${title}"\`, }); } - } - - return NextResponse.json(newNode); + }, + }); - } catch (error) { - console.error('SurrealDB write error:', error); - // TODO: Implement rollback for the ATproto post? - return NextResponse.json({ error: 'Failed to save to app cache' }, { status: 500 }); - } + // Auto-scroll to bottom + useEffect(() \=\> { + viewport.current?.scrollTo({ + top: viewport.current.scrollHeight, + behavior: 'smooth', + }); + }, \[messages\]); + + return ( + \ + \ + Ponderants Interview + \</Title\> + + \<ScrollArea + h="100%" + style={{ flex: 1 }} + viewportRef={viewport} + \> + \<Stack gap="md" pb="xl"\> + {messages.map((m) \=\> ( + \<Paper + key={m.id} + withBorder + shadow="md" + p="sm" + radius="lg" + style={{ + alignSelf: m.role \=== 'user'? 'flex-end' : 'flex-start', + backgroundColor: + m.role \=== 'user'? '\#343a40' : '\#212529', + }} + w="80%" + \> + \<Text fw={700} size="sm"\>{m.role \=== 'user'? 'You' : 'AI'}\</Text\> + \<Text style={{ whiteSpace: 'pre-wrap' }}\>{m.content}\</Text\> + \</Paper\> + ))} + \</Stack\> + \</ScrollArea\> + + \<form onSubmit={handleSubmit}\> + \<Paper withBorder p="sm" radius="xl" my="md"\> + \<Group\> + \<TextInput + value={input} + onChange={handleInputChange} + placeholder="Speak or type your thoughts..." + style={{ flex: 1 }} + variant="unstyled" + disabled={isLoading} + /\> + \<Button type="submit" radius="xl" loading={isLoading}\> + Send + \</Button\> + {/\* Voice button will go here \*/} + \</Group\> + \</Paper\> + \</form\> + \</Container\> + ); } ### **Test Specification** -This is an API-only commit. It will be tested via the end-to-end flow in **Commit 10 (Linking)**, which will provide the UI (the "Publish" button) to trigger this route. +**1\. Create Test File (tests/magnitude/07-chat.mag.ts)** + +Create a file at /tests/magnitude/07-chat.mag.ts: + +TypeScript + +import { test } from 'magnitude-test'; + +test('\[Happy Path\] User can chat with AI', async (agent) \=\> { + // Act: Go to chat page + await agent.act('Navigate to /chat'); + + // Check: Ensure the initial state is correct + await agent.check('The title "Ponderants Interview" is visible'); + await agent.check('The chat input field is empty'); + + // Act: Send a message + await agent.act( + 'Enter "I have an idea about decentralized social media" into the chat input' + ); + await agent.act('Click the "Send" button'); + + // Check: User's message appears + await agent.check( + 'The message "I have an idea about decentralized social media" appears in the chat list' + ); + + // Check: AI response appears (mocked) + // We mock the /api/chat response to return a simple text stream + await agent.check( + 'A new message from "AI" appears in the chat list with a response' + ); +}); + +test(' AI can trigger a node suggestion', async (agent) \=\> { + // Act: Go to chat page + await agent.act('Navigate to /chat'); + + // Act: Send a message that should trigger a node + await agent.act( + 'Enter "I think I have a fully formed thought: ATproto is the future of the internet because it separates data from the application." into the chat input' + ); + + // We mock the /api/chat response to return the 'suggest\_node' tool call + // with specific 'title' and 'body' arguments. + await agent.act('Click the "Send" button'); + + // Check: The 'experimental\_onToolCall' handler should fire + // and redirect the user to the editor. + await agent.check( + 'The browser URL is now "http://localhost:3000/editor/new"' + ); + + // Check: The editor page is pre-filled with the AI-generated content + await agent.check( + 'The page URL contains the query parameter "title=ATproto: The Future of the Internet"' + ); + await agent.check( + 'The page URL contains the query parameter "body=ATproto is the future..."' + ); +}); + diff --git a/package.json b/package.json index eca20ac..ca190e7 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@mantine/hooks": "latest", "@react-three/drei": "latest", "@react-three/fiber": "latest", + "@tabler/icons-react": "^3.35.0", "ai": "latest", "jsonwebtoken": "latest", "next": "latest", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b20b706..3f43970 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: '@react-three/fiber': specifier: latest version: 9.4.0(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(three@0.181.0) + '@tabler/icons-react': + specifier: ^3.35.0 + version: 3.35.0(react@19.2.0) ai: specifier: latest version: 5.0.89(zod@4.1.12) @@ -969,6 +972,14 @@ packages: '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + '@tabler/icons-react@3.35.0': + resolution: {integrity: sha512-XG7t2DYf3DyHT5jxFNp5xyLVbL4hMJYJhiSdHADzAjLRYfL7AnjlRfiHDHeXxkb2N103rEIvTsBRazxXtAUz2g==} + peerDependencies: + react: '>= 16' + + '@tabler/icons@3.35.0': + resolution: {integrity: sha512-yYXe+gJ56xlZFiXwV9zVoe3FWCGuZ/D7/G4ZIlDtGxSx5CGQK110wrnT29gUj52kEZoxqF7oURTk97GQxELOFQ==} + '@tweenjs/tween.js@23.1.3': resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==} @@ -4103,6 +4114,13 @@ snapshots: dependencies: tslib: 2.8.1 + '@tabler/icons-react@3.35.0(react@19.2.0)': + dependencies: + '@tabler/icons': 3.35.0 + react: 19.2.0 + + '@tabler/icons@3.35.0': {} + '@tweenjs/tween.js@23.1.3': {} '@tybys/wasm-util@0.10.1': diff --git a/tests/magnitude/09-voice.mag.ts b/tests/magnitude/09-voice.mag.ts new file mode 100644 index 0000000..3bd20c7 --- /dev/null +++ b/tests/magnitude/09-voice.mag.ts @@ -0,0 +1,40 @@ +import { test } from 'magnitude-test'; + +test('[Happy Path] User can record voice and see transcript', async (agent) => { + // Act: Go to chat page + await agent.act('Navigate to /chat'); + + // Check: Verify initial state + await agent.check('The chat input field is empty'); + await agent.check('A "Start Recording" button is visible'); + + // Act: Click the record button + // Note: This will require mocking the /api/voice-token response and the + // MediaDevices/WebSocket browser APIs in a real test environment + await agent.act('Click the "Start Recording" button'); + + // Check: UI updates to recording state + await agent.check('A "Stop Recording" button is visible'); + + // Act: Simulate receiving a transcript from the (mocked) Deepgram WebSocket + await agent.act( + 'Simulate an interim transcript "Hello world" from the Deepgram WebSocket' + ); + + // Check: The input field is updated + await agent.check('The chat input field contains "Hello world"'); + + // Act: Simulate a final transcript + await agent.act( + 'Simulate a final transcript "Hello world." from the Deepgram WebSocket' + ); + + // Check: The "Stop Recording" button is gone + await agent.check('A "Start Recording" button is visible again'); + + // Check: The chat input is cleared (because it was submitted) + await agent.check('The chat input field is empty'); + + // Check: The finalized transcript appears as a user message + await agent.check('The message "Hello world." appears in the chat list'); +});