Add document-level styles so the downloaded .docx has:
- Noto Serif font throughout (matching the web UI)
- Sized headings: H1 20pt, H2 16pt, H3 14pt, H4 12pt
- 11pt body text with 1.15× line spacing
- Consistent color (#1f1b16) and spacing
- Styled hyperlinks (blue, underlined)
- List paragraph font inheritance
Co-Authored-By: alex <alex@semipublic.co>
- Copy Text: copies raw markdown to clipboard with visual feedback
- Download .docx: converts markdown to a properly formatted Word
document using the docx library (headings, bold/italic, lists,
tables, blockquotes, links, horizontal rules)
- Both buttons appear below the response article after streaming ends
- docx/file-saver are lazy-loaded (dynamic import) so they don't
bloat the initial bundle (~308 KB main vs ~407 KB docx chunk)
Co-Authored-By: alex <alex@semipublic.co>
Root cause of "segment_end never written, response just runs until
Netlify kills it": the Fetch spec only honors AbortSignal for the
request/header phase. Once you have `res.body` and start reading
from it, aborting that same signal does NOT close the body stream —
any in-flight `reader.read()` hangs indefinitely.
So when the segment timer fired and called segmentAbort.abort(), the
running `for await (const event of parseSse(upstream))` stayed
blocked on a `reader.read()` that never resolved. The while loop
never exited, segment_end was never written, controller.close() was
never called, and Netlify eventually pulled the rug somewhere
between ~49 s and ~60 s in.
Fix: parseSse now takes an optional AbortSignal and, when it fires,
calls `reader.cancel()` directly. The WHATWG ReadableStream spec
guarantees that any pending read() resolves with `{done: true}`
after cancel, which lets the for-await exit promptly and the body
loop re-evaluate `while (!done && !segmenting)` → break →
writeJson(segment_end) → controller.close(). Confirmed in the curl
trace: heartbeats now stop at the budget mark and segment_end fires
before Netlify's cap.
Co-Authored-By: alex <alex@semipublic.co>
Two related fixes after re-testing on the deploy preview:
1. SEGMENT_BUDGET_MS: 54_000 -> 40_000.
Empirically, Netlify Edge Function responses get cut anywhere
between ~49 s and ~60 s wall-clock — significantly more variance
than the docs imply. With a 54 s budget, our segment_end write +
controller.close() sometimes lands AFTER the platform has already
killed the response (curl: 'HTTP/2 stream 1 was not closed cleanly
before end of the underlying stream'). 40 s gives ~10 s of headroom
on the low end of the observed range while still keeping segment
overhead reasonable (~1 segment per 40 s of brief).
2. HEARTBEAT_MS: 10_000 -> 5_000.
Tighter keep-alive so the connection stays warm even during long
model-thinking gaps where the agent isn't emitting events.
3. Drop the 'await upstream.cancel()' between backfill and tail.
The upstream fetch's body is already cleaned up by
segmentAbort.abort() (or will be by the runtime once we drop our
reference). On Deno Edge, awaiting cancel() on an already-aborted
body can hang, which prevents segment_end from being written.
Co-Authored-By: alex <alex@semipublic.co>
The Managed Agents events endpoint (`GET /v1/sessions/{id}/events`)
does NOT support filtering by event id. It returns an opaque
`next_page` cursor on each response and accepts it back via the
`page` query parameter; an `after_id=` filter returns 400 Bad
Request, which caused every segment resume to fail backfill (visible
as `{"type":"status","kind":"session_error","message":"Backfill
failed: events.list returned 400 Bad Request"}`).
Caught during testing of commit 8e44de5: resuming from a segment
boundary always returned 400 and the brief silently lost events from
the previous segment.
Changes:
- `listAllEvents` now paginates via `page` / `next_page` and pulls
the full session history (limit=1000). The Anthropic API has no
per-id filter, so the caller is responsible for skipping events
already delivered.
- New `pastInitialId` flag at the top of the body loop: on resume,
mute every event up to and including `initialLastEventId`
(still adding them to `seenEventIds` so the live stream doesn't
re-emit them), then start delivering. On a brand-new session the
flag starts true and is a no-op.
- Safety fallback: if backfill completes without ever seeing
`initialLastEventId` (stale cursor / truncated history), flip
the flag to true so we don't get stuck muting forever — the live
stream will start delivering whatever shows up next.
Co-Authored-By: alex <alex@semipublic.co>
Netlify Edge Functions empirically cap a single streaming response at
~60s wall-clock, regardless of activity. Confirmed against Netlify's
own canonical SSE example (edge-functions-examples.netlify.app/sse)
which also cuts at +60.1s. The Anthropic Managed Agent session is
fine for several minutes; the cap is per-HTTP-response.
This commit splits a long brief across multiple HTTP responses, while
keeping the UX of one continuous stream:
netlify/edge-functions/agent-proxy.ts
- Accept either a new-session payload {stationName, stationLocation,
stationWebsite} or a resume payload {sessionId, lastEventId,
startedAt}.
- On resume, skip session creation + user.message send. Just reopen
the live SSE stream and backfill via
GET /v1/sessions/{id}/events?after_id=lastEventId (deduped by
event.id), then keep tailing.
- Single AbortController per segment. A 54s timer aborts the upstream,
the for-await loops exit, and we write one final NDJSON line:
{type:'segment_end', sessionId, lastEventId, startedAt}.
- The 20-min OVERALL_BUDGET_MS is enforced via Date.now() - startedAt
so it spans across all segments.
- Refactor main loop so every iteration is openStream + backfill +
tail. Cleaner than the previous initial-stream + reconnect-only-on-
drop pattern.
src/App.jsx
- readStream() now returns a {sessionId, lastEventId, startedAt}
payload if it saw a segment_end, or null if the stream ended
cleanly.
- handleSubmit() loops, reopening /api/agent-proxy with the resume
payload until readStream returns null. Spinner/status state stays
on across segments so the UI shows one continuous stream.
README.md
- Document the segmented-streaming protocol and why it exists.
Co-Authored-By: alex <alex@semipublic.co>
The reconnect + events.list backfill in c283d88 is correct but never ran:
the previous v2 Node Function was killed at ~27 s (well before the 20 min
reconnect budget could matter), so streams always died after the first MCP
tool batch.
Move the proxy to a Netlify Edge Function (Deno runtime) which has no
streaming-duration cap as long as we keep writing to the response body.
Same reconnect / backfill / dedupe-by-event-id pattern; same NDJSON wire
protocol to the browser. Implemented with plain fetch() against the
Anthropic REST API (npm packages on Edge are beta) so we have no SDK
runtime dependency.
Frontend now POSTs to /api/agent-proxy. The Anthropic SDK is removed
from the package; @netlify/edge-functions is added for ambient types.
Co-Authored-By: alex <alex@semipublic.co>
The Anthropic Managed-Agents SSE stream (`/v1/sessions/{id}/events/stream`)
is not guaranteed to stay open for the life of a multi-turn session.
Inter-turn gaps (e.g. while MCP tools or the next model request run) of
~25s+ are common and can trigger upstream / proxy read timeouts; when
that happens, the SDK's `for await` iterator just ends and the proxy
silently closed the downstream response, leaving the browser with only
the events from the first tool batch.
Implement the official 'consolidation' pattern: treat any upstream
end-of-stream that isn't a terminal `session.status_*` as a transient
drop, fetch what we missed via `sessions.events.list()` (deduped by
event.id), reopen the live stream, and keep going — up to a 20 minute
wall-clock budget so a wedged session can't pin the function forever.
Also switch the wire format from inline-markdown-with-status-comments to
NDJSON, so the React side can render in-flight tool activity in a
separate status banner from the streaming brief. The existing zero-width
heartbeat is replaced by an explicit `{"type":"heartbeat"}` line.
Drop the `agent.tool_use` write-tool special case — the agent now
streams the brief directly via `agent.message` text blocks.
Co-Authored-By: alex <alex@semipublic.co>