diff --git a/demos/02-lumacare/README.md b/demos/02-lumacare/README.md index e7b1077..b5d047c 100644 --- a/demos/02-lumacare/README.md +++ b/demos/02-lumacare/README.md @@ -23,8 +23,10 @@ The checked-in `config/agents.json` maps the `care_companion` feature to its pur ## What is included - Calm, responsive family-facing care guidance +- A stable guide viewport across waiting, live streaming, and completed states - Structured plain-language guides and care-team questions -- Data-only streaming with immediate recovery-ID persistence +- Data-only streaming with isolated per-run buffers and immediate recovery-ID persistence +- Explicit per-run cancellation that remains safe during fast stop/retry and caregiver switching - Session recall and continue/revise flows - Ownership checks for session details, cancellation, previews, and downloads - Sandboxed HTML previews and authenticated artifact/archive proxying @@ -41,3 +43,5 @@ npm run test --workspace=@harnessrouter/lumacare npm run build --workspace=@harnessrouter/lumacare npm run preview:lumacare ``` + +The LumaCare tests include stream parsing, cross-user ownership, family-facing guide formatting, checklist-state preservation, and responsive guide-layout contracts. diff --git a/demos/02-lumacare/server/index.ts b/demos/02-lumacare/server/index.ts index b4dfbed..cc8d164 100644 --- a/demos/02-lumacare/server/index.ts +++ b/demos/02-lumacare/server/index.ts @@ -14,6 +14,9 @@ dotenv.config({ path: path.resolve(root, '../..', '.env') }); dotenv.config({ path: path.join(root, '.env'), override: true }); const agentMap = JSON.parse(fs.readFileSync(path.join(root, 'config/agents.json'), 'utf8')) as Record; const store = new OwnershipStore(path.join(root, 'data/ownership.json')); +type ActiveRun = { userId: string; controller: AbortController; reader: ReadableStreamDefaultReader | null; sessionId: string | null }; +const activeRuns = new Map(); +const cancelledRuns = new Map(); const apiBase = 'https://api.harnessrouter.ai'; const apiKey = process.env.HR_API_KEY; @@ -78,10 +81,17 @@ app.post('/api/runs', async (req, res) => { const input = String(req.body.input || '').trim(); const previousResponseId = req.body.previousResponseId ? String(req.body.previousResponseId) : null; const sessionId = req.body.sessionId ? String(req.body.sessionId) : null; + const clientRunId = String(req.body.clientRunId || ''); const harnessId = agentMap[featureKey]; if (!harnessId) return res.status(400).json({ error: 'Unknown feature' }); if (!input || input.length > 20_000) return res.status(400).json({ error: 'Enter a task under 20,000 characters.' }); + if (!/^[0-9a-f-]{36}$/i.test(clientRunId)) return res.status(400).json({ error: 'A valid client run ID is required.' }); + if (cancelledRuns.get(clientRunId) === req.productUser.id) { + cancelledRuns.delete(clientRunId); + return res.status(409).json({ error: 'This run was cancelled before it started.' }); + } + if (activeRuns.has(clientRunId)) return res.status(409).json({ error: 'This run is already active.' }); if ((previousResponseId || sessionId) && !(previousResponseId && sessionId)) { return res.status(400).json({ error: 'A continuation requires both recovery identifiers.' }); } @@ -89,6 +99,12 @@ app.post('/api/runs', async (req, res) => { return res.status(404).json({ error: 'Session not found' }); } + const upstreamController = new AbortController(); + const activeRun: ActiveRun = { userId: req.productUser.id, controller: upstreamController, reader: null, sessionId }; + activeRuns.set(clientRunId, activeRun); + let recoveredSessionId = sessionId; + let recoveredResponseId = previousResponseId; + const requestBody: Record = { input, stream: true }; if (previousResponseId && sessionId) { requestBody.previous_response_id = previousResponseId; @@ -104,13 +120,18 @@ app.post('/api/runs', async (req, res) => { 'Idempotency-Key': crypto.randomUUID(), }), body: JSON.stringify(requestBody), + signal: upstreamController.signal, }); } catch { + activeRuns.delete(clientRunId); + if (upstreamController.signal.aborted) return res.end(); + if (res.closed) return; return res.status(502).json({ error: 'HarnessRouter could not be reached.' }); } if (!upstream.ok || !upstream.body) { const detail = await upstream.json().catch(() => ({ detail: 'Agent request failed.' })) as { detail?: string }; + activeRuns.delete(clientRunId); return res.status(upstream.status || 502).json({ error: detail.detail || 'Agent request failed.' }); } @@ -120,11 +141,10 @@ app.post('/api/runs', async (req, res) => { res.setHeader('Connection', 'keep-alive'); res.flushHeaders(); - const reader = upstream.body.getReader(); + const streamReader = upstream.body.getReader(); + activeRun.reader = streamReader; const decoder = new TextDecoder(); let buffer = ''; - let recoveredSessionId = sessionId; - let recoveredResponseId = previousResponseId; const handleFrame = (frame: string) => { const dataLines = frame.split(/\r?\n/).filter((line) => line.startsWith('data:')); @@ -136,6 +156,7 @@ app.post('/api/runs', async (req, res) => { if (event.type === 'response.created') { recoveredResponseId = event.response?.id || recoveredResponseId; recoveredSessionId = event.response?.metadata?.session_id || recoveredSessionId; + activeRun.sessionId = recoveredSessionId; if (recoveredResponseId && recoveredSessionId) { const now = new Date().toISOString(); const existing = store.getAuthorized(recoveredSessionId, req.productUser.id); @@ -170,7 +191,7 @@ app.post('/api/runs', async (req, res) => { try { while (true) { - const { done, value } = await reader.read(); + const { done, value } = await streamReader.read(); if (done) break; const text = decoder.decode(value, { stream: true }); if (!res.closed) res.write(text); @@ -191,6 +212,32 @@ app.post('/api/runs', async (req, res) => { } } finally { if (!res.closed) res.end(); + if (activeRuns.get(clientRunId) === activeRun) activeRuns.delete(clientRunId); + } +}); + +app.post('/api/runs/:clientRunId/cancel', async (req, res) => { + const clientRunId = String(req.params.clientRunId); + const run = activeRuns.get(clientRunId); + if (!run) { + cancelledRuns.set(clientRunId, req.productUser.id); + setTimeout(() => { if (cancelledRuns.get(clientRunId) === req.productUser.id) cancelledRuns.delete(clientRunId); }, 30_000).unref(); + return res.json({ cancelled: true }); + } + if (run.userId !== req.productUser.id) return res.status(404).json({ error: 'Active run not found' }); + run.controller.abort(); + await run.reader?.cancel().catch(() => {}); + try { + if (run.sessionId) { + const result = await upstreamJson(`/v1/sessions/${encodeURIComponent(run.sessionId)}/cancel`, { method: 'POST' }); + store.update(run.sessionId, { status: 'cancelled' }); + return res.json(result); + } + return res.json({ cancelled: true }); + } catch (error) { + errorResponse(res, error); + } finally { + activeRuns.delete(clientRunId); } }); diff --git a/demos/02-lumacare/src/App.tsx b/demos/02-lumacare/src/App.tsx index f03995b..f1aab79 100644 --- a/demos/02-lumacare/src/App.tsx +++ b/demos/02-lumacare/src/App.tsx @@ -5,9 +5,11 @@ import { Phone, RefreshCw, ShieldCheck, Sparkles, X, } from 'lucide-react'; import { api, apiHeaders, SSEParser, type StreamEvent } from './api'; +import { normalizeGuideText, parseChecklistItem } from './guide'; import type { AgentFile, SessionRecord } from './types'; type Config = { user: { id: string; name: string; initials: string }; users: { id: string; name: string; initials: string }[]; agent: { name: string; model: string } }; +type ActiveRun = { id: number; clientRunId: string; userId: string; controller: AbortController; session: SessionRecord | null; buffer: string; frame: number | null }; const quickPrompts = [ { icon: MessageCircle, tone: 'lavender', title: 'Prepare for our next visit', text: 'Help me prepare for our next oncology appointment. Give me a short checklist and questions to ask.' }, { icon: LifeBuoy, tone: 'mint', title: 'Make today a little easier', text: 'My child is having a hard day during treatment. Help me make a gentle plan for comfort, routines, and what to note for the care team.' }, @@ -30,11 +32,14 @@ function extractTurnText(payload: any): string { } function Guide({ text }: { text: string }) { - return
{text.split('\n').map((line, index) => { + const displayText = normalizeGuideText(text); + return
{displayText.split('\n').map((line, index) => { const clean = line.trim(); + const task = parseChecklistItem(clean); if (!clean) return
; if (clean.startsWith('## ')) return

{clean.slice(3)}

; if (clean.startsWith('# ')) return

{clean.slice(2)}

; + if (task) return
{task.text}
; if (/^[-*] /.test(clean)) return
{clean.slice(2).replace(/\*\*/g, '')}
; if (/^\d+\. /.test(clean)) return
{clean.match(/^\d+/)?.[0]}{clean.replace(/^\d+\. /, '').replace(/\*\*/g, '')}
; return

{clean.replace(/\*\*/g, '')}

; @@ -48,12 +53,39 @@ export default function App() { const [activeSession, setActiveSession] = useState(null); const [files, setFiles] = useState([]); const [input, setInput] = useState(''); + const [submittedPrompt, setSubmittedPrompt] = useState(''); const [answer, setAnswer] = useState(''); const [status, setStatus] = useState<'idle' | 'running' | 'completed' | 'incomplete' | 'failed' | 'cancelled'>('idle'); const [error, setError] = useState(''); const [historyOpen, setHistoryOpen] = useState(false); const [mobileNav, setMobileNav] = useState(false); + const [loadingSession, setLoadingSession] = useState(false); + const [elapsedSeconds, setElapsedSeconds] = useState(0); + const [isFollowing, setIsFollowing] = useState(true); const composerRef = useRef(null); + const resultRef = useRef(null); + const resultBodyRef = useRef(null); + const activeRunRef = useRef(null); + const runSequenceRef = useRef(0); + + useEffect(() => { + if (status !== 'running') { setElapsedSeconds(0); return; } + const startedAt = Date.now(); + const timer = window.setInterval(() => setElapsedSeconds(Math.floor((Date.now() - startedAt) / 1000)), 1000); + return () => window.clearInterval(timer); + }, [status]); + + useEffect(() => { + if (status !== 'running') return; + const mobile = window.matchMedia('(max-width: 850px)').matches; + window.setTimeout(() => resultRef.current?.scrollIntoView({ behavior: mobile ? 'smooth' : 'auto', block: 'start' }), 80); + }, [status]); + + useEffect(() => { + const body = resultBodyRef.current; + if (!body || status !== 'running' || !isFollowing) return; + body.scrollTop = body.scrollHeight; + }, [answer, status, isFollowing]); const loadSessions = useCallback(async () => { const result = await api<{ sessions: SessionRecord[] }>('/api/sessions', userId); @@ -61,10 +93,21 @@ export default function App() { }, [userId]); useEffect(() => { + const stopRun = () => { + const run = activeRunRef.current; + if (!run || run.userId !== userId) return; + runSequenceRef.current += 1; + activeRunRef.current = null; + run.controller.abort(); + if (run.frame !== null) cancelAnimationFrame(run.frame); + run.buffer = ''; + api(`/api/runs/${run.clientRunId}/cancel`, run.userId, { method: 'POST' }).catch(() => {}); + }; localStorage.setItem('lumacare-user', userId); setActiveSession(null); setAnswer(''); setFiles([]); setStatus('idle'); api('/api/config', userId).then(setConfig).catch((e) => setError(e.message)); loadSessions().catch(() => {}); + return stopRun; }, [userId, loadSessions]); const refreshSession = useCallback(async (session: SessionRecord) => { @@ -76,30 +119,66 @@ export default function App() { loadSessions().catch(() => {}); return record; }, [loadSessions, userId]); - const handleEvent = useCallback((event: StreamEvent, prompt: string) => { - if (event.type === 'response.created') setActiveSession({ sessionId: event.response?.metadata?.session_id, responseId: event.response?.id, userId, title: prompt.length > 64 ? `${prompt.slice(0, 64)}…` : prompt, prompt, status: 'running', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }); - else if (event.type === 'response.output_text.delta') setAnswer((value) => value + String(event.delta || '')); - else if (event.type === 'response.completed' || event.type === 'response.incomplete' || event.type === 'response.failed') setStatus(event.type === 'response.completed' ? 'completed' : event.type === 'response.incomplete' ? 'incomplete' : event.response?.status === 'cancelled' ? 'cancelled' : 'failed'); + const handleEvent = useCallback((event: StreamEvent, prompt: string, run: ActiveRun) => { + if (activeRunRef.current !== run) return; + if (event.type === 'response.created') { + const session = { sessionId: event.response?.metadata?.session_id, responseId: event.response?.id, userId, title: prompt.length > 64 ? `${prompt.slice(0, 64)}…` : prompt, prompt, status: 'running', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }; + run.session = session; setActiveSession(session); + } else if (event.type === 'response.output_text.delta') { + run.buffer += String(event.delta || ''); + if (run.frame === null) run.frame = requestAnimationFrame(() => { + if (activeRunRef.current !== run) return; + const delta = run.buffer; run.buffer = ''; run.frame = null; + setAnswer((value) => value + delta); + }); + } else if (event.type === 'response.completed' || event.type === 'response.incomplete' || event.type === 'response.failed') { + if (run.frame !== null) cancelAnimationFrame(run.frame); + const delta = run.buffer; run.buffer = ''; run.frame = null; + if (delta) setAnswer((value) => value + delta); + setStatus(event.type === 'response.completed' ? 'completed' : event.type === 'response.incomplete' ? 'incomplete' : event.response?.status === 'cancelled' ? 'cancelled' : 'failed'); + } }, [userId]); const runTask = async (continuation?: string) => { const prompt = (continuation || input).trim(); if (!prompt || status === 'running') return; - setError(''); setAnswer(''); setFiles([]); setStatus('running'); if (!continuation) setInput(''); + const previousRun = activeRunRef.current; + if (previousRun) { + previousRun.controller.abort(); + if (previousRun.frame !== null) cancelAnimationFrame(previousRun.frame); + previousRun.buffer = ''; + api(`/api/runs/${previousRun.clientRunId}/cancel`, previousRun.userId, { method: 'POST' }).catch(() => {}); + } + setError(''); setAnswer(''); setFiles([]); setIsFollowing(true); setSubmittedPrompt(prompt); setStatus('running'); if (!continuation) setInput(''); + const controller = new AbortController(); + const run: ActiveRun = { id: ++runSequenceRef.current, clientRunId: crypto.randomUUID(), userId, controller, session: continuation ? activeSession : null, buffer: '', frame: null }; + activeRunRef.current = run; + if (!continuation) setActiveSession(null); try { - const body: Record = { featureKey: 'care_companion', input: prompt }; + const body: Record = { featureKey: 'care_companion', clientRunId: run.clientRunId, input: prompt }; if (activeSession && continuation) { body.sessionId = activeSession.sessionId; body.previousResponseId = activeSession.responseId; } - const response = await fetch('/api/runs', { method: 'POST', headers: apiHeaders(userId, true), body: JSON.stringify(body) }); + const response = await fetch('/api/runs', { method: 'POST', headers: apiHeaders(userId, true), body: JSON.stringify(body), signal: controller.signal }); if (!response.ok || !response.body) { const data = await response.json().catch(() => ({})); throw new Error(data.error || 'LumaCare could not start your guide.'); } const parser = new SSEParser(); const reader = response.body.getReader(); const decoder = new TextDecoder(); - while (true) { const { done, value } = await reader.read(); if (done) break; parser.push(decoder.decode(value, { stream: true })).forEach((event) => handleEvent(event, prompt)); } - parser.flush().forEach((event) => handleEvent(event, prompt)); - setTimeout(() => setActiveSession((current) => { if (current) refreshSession(current).catch(() => {}); return current; }), 700); - } catch (e) { setStatus('failed'); setError((e as Error).message); } + while (true) { const { done, value } = await reader.read(); if (done) break; parser.push(decoder.decode(value, { stream: true })).forEach((event) => handleEvent(event, prompt, run)); } + parser.flush().forEach((event) => handleEvent(event, prompt, run)); + window.setTimeout(() => { if (runSequenceRef.current === run.id && run.session) refreshSession(run.session).catch(() => {}); }, 700); + } catch (e) { if ((e as Error).name !== 'AbortError' && activeRunRef.current === run) { setStatus('failed'); setError((e as Error).message); } } + finally { if (activeRunRef.current === run) activeRunRef.current = null; } }; - const openSession = async (session: SessionRecord) => { setHistoryOpen(false); setAnswer(''); setStatus('running'); try { await refreshSession(session); } catch (e) { setError((e as Error).message); setStatus('failed'); } }; - const cancelRun = async () => { if (!activeSession) return; await api(`/api/sessions/${activeSession.sessionId}/cancel`, userId, { method: 'POST' }); setStatus('cancelled'); }; + const openSession = async (session: SessionRecord) => { setHistoryOpen(false); setAnswer(''); setLoadingSession(true); try { await refreshSession(session); } catch (e) { setError((e as Error).message); setStatus('failed'); } finally { setLoadingSession(false); } }; + const cancelRun = async () => { + const run = activeRunRef.current; + if (!run) return; + runSequenceRef.current += 1; + activeRunRef.current = null; + run.controller.abort(); + if (run.frame !== null) cancelAnimationFrame(run.frame); + run.buffer = ''; + setInput((value) => value || submittedPrompt); setStatus('cancelled'); + api(`/api/runs/${run.clientRunId}/cancel`, run.userId, { method: 'POST' }).catch(() => setError('The guide stopped here, but server cancellation could not be confirmed.')); + }; const usePrompt = (text: string) => { setInput(text); requestAnimationFrame(() => composerRef.current?.focus()); }; const downloadGuide = async () => { if (!activeSession || !files.length) return; @@ -118,16 +197,22 @@ export default function App() {
{user?.initials || 'AM'}
{user?.name || 'Alex Morgan'}Caregiver account
-
Connected with care guidanceAI support for families
-
+
Connected with care guidanceAI support for families
+
HERE WITH YOU

One next step
at a time.

Practical, compassionate support for the moments between appointments.

Think this may be an emergency?Call local emergency services. For fever or infection concerns during treatment, contact your child’s oncology team now and follow their fever plan.
- {!answer && status !== 'running' ? <>
START HERE

What would help right now?

You don’t need the perfect words.

{quickPrompts.map(({ icon: Icon, tone, title, text }) => )}
“
There is no right way to feel today.
Small steps still count.
: -
Your LumaCare guide{status === 'running' ? 'Creating a thoughtful response…' : 'Ready to review with your care team'}
{status === 'completed' && files.length > 0 && }
{status === 'running' && !answer ?
Listening carefully and organizing the next steps…
: }{status === 'incomplete' && }
} + {!answer && status !== 'running' && status !== 'cancelled' ? <>
START HERE

What would help right now?

You don’t need the perfect words.

{quickPrompts.map(({ icon: Icon, tone, title, text }) => )}
“
There is no right way to feel today.
Small steps still count.
: +
Your LumaCare guide{loadingSession ? 'Opening your saved guide…' : status === 'running' && answer ? 'Writing your guide live…' : status === 'running' ? 'Preparing a thoughtful response…' : status === 'cancelled' ? 'Guide stopped' : 'Ready to review with your care team'}
{status === 'running' && answer && Live}{status === 'completed' && files.length > 0 && }
+
{ const body = event.currentTarget; setIsFollowing(body.scrollHeight - body.scrollTop - body.clientHeight < 48); }}> + {status === 'running' && !answer ?
Putting your guide togetherThis usually takes 15–30 seconds. You can safely stop at any time.
  1. Request received
  2. = 3 ? 'done' : 'active'}>{elapsedSeconds >= 3 ? : }Organizing the next steps
  3. = 10 ? 'active' : ''}>{elapsedSeconds >= 10 ? : 3}Writing your care guide
Working for {elapsedSeconds}s
: status === 'cancelled' && !answer ?
Guide stoppedYour message is back in the composer, ready to edit or try again.
:
} + {status === 'incomplete' && } +
+ {status === 'running' && answer && !isFollowing && } +
}
Don’t include identifying details