fix(agent-proxy): reconnect with events.list backfill so long sessions don't cut off
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>
This commit is contained in:
+80
-9
@@ -20,6 +20,7 @@ export default function App() {
|
||||
const [isThinking, setIsThinking] = useState(false)
|
||||
const [isStreaming, setIsStreaming] = useState(false)
|
||||
const [result, setResult] = useState('')
|
||||
const [status, setStatus] = useState(null)
|
||||
const [streamError, setStreamError] = useState(null)
|
||||
const resultRef = useRef(null)
|
||||
|
||||
@@ -47,27 +48,72 @@ export default function App() {
|
||||
return Object.keys(next).length === 0
|
||||
}
|
||||
|
||||
const handleEvent = (msg) => {
|
||||
switch (msg?.type) {
|
||||
case 'text':
|
||||
if (typeof msg.text === 'string' && msg.text) {
|
||||
setResult((prev) => prev + msg.text)
|
||||
}
|
||||
break
|
||||
case 'status':
|
||||
setStatus(msg)
|
||||
break
|
||||
case 'error':
|
||||
case 'session_error':
|
||||
setStreamError(msg.message || 'streaming error')
|
||||
break
|
||||
case 'done':
|
||||
case 'heartbeat':
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Parse the NDJSON stream from /agent-proxy: one JSON object per line.
|
||||
const readStream = async (response) => {
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder('utf-8')
|
||||
let firstChunk = true
|
||||
let buffer = ''
|
||||
let firstByte = true
|
||||
|
||||
const flushLines = () => {
|
||||
let nl
|
||||
while ((nl = buffer.indexOf('\n')) !== -1) {
|
||||
const line = buffer.slice(0, nl).trim()
|
||||
buffer = buffer.slice(nl + 1)
|
||||
if (!line) continue
|
||||
try {
|
||||
handleEvent(JSON.parse(line))
|
||||
} catch {
|
||||
// Ignore malformed lines — the heartbeat / done sentinel
|
||||
// will still tell us when the stream is over.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
const chunk = decoder.decode(value, { stream: true })
|
||||
if (firstChunk) {
|
||||
// Once data starts flowing, flip 'thinking' off and 'streaming' on.
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
if (firstByte) {
|
||||
// Once any bytes arrive, flip 'thinking' off and 'streaming' on.
|
||||
setIsThinking(false)
|
||||
setIsStreaming(true)
|
||||
firstChunk = false
|
||||
firstByte = false
|
||||
}
|
||||
flushLines()
|
||||
}
|
||||
// Flush any trailing buffered bytes (line without final \n).
|
||||
buffer += decoder.decode()
|
||||
if (buffer.trim()) {
|
||||
try {
|
||||
handleEvent(JSON.parse(buffer.trim()))
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
setResult((prev) => prev + chunk)
|
||||
}
|
||||
// Flush any remaining buffered bytes
|
||||
const tail = decoder.decode()
|
||||
if (tail) setResult((prev) => prev + tail)
|
||||
setIsStreaming(false)
|
||||
setStatus(null)
|
||||
}
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
@@ -75,6 +121,7 @@ export default function App() {
|
||||
if (!validate()) return
|
||||
|
||||
setResult('')
|
||||
setStatus(null)
|
||||
setStreamError(null)
|
||||
setIsSubmitting(true)
|
||||
setIsThinking(true)
|
||||
@@ -101,6 +148,7 @@ export default function App() {
|
||||
setIsSubmitting(false)
|
||||
setIsThinking(false)
|
||||
setIsStreaming(false)
|
||||
setStatus(null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,6 +232,12 @@ export default function App() {
|
||||
</StatusBanner>
|
||||
)}
|
||||
|
||||
{isStreaming && status && (
|
||||
<StatusBanner>
|
||||
<Spinner /> {renderStatus(status)}
|
||||
</StatusBanner>
|
||||
)}
|
||||
|
||||
{streamError && (
|
||||
<div className="rounded-md border border-red-300 bg-red-50 p-4 text-red-800">
|
||||
{streamError}
|
||||
@@ -228,6 +282,23 @@ function Field({ label, name, type = 'text', value, onChange, error, placeholder
|
||||
)
|
||||
}
|
||||
|
||||
function renderStatus(status) {
|
||||
if (!status) return null
|
||||
switch (status.kind) {
|
||||
case 'thinking':
|
||||
return 'Thinking…'
|
||||
case 'tool_use':
|
||||
return status.label || `Using tool: ${status.name || 'tool'}`
|
||||
case 'tool_result':
|
||||
if (status.ok) return 'Tool finished — continuing…'
|
||||
return `Tool error: ${status.message || 'unknown error'}`
|
||||
case 'session_error':
|
||||
return `Session error: ${status.message || 'unknown error'}`
|
||||
default:
|
||||
return 'Working…'
|
||||
}
|
||||
}
|
||||
|
||||
function StatusBanner({ children }) {
|
||||
return (
|
||||
<div className="mb-4 flex items-center gap-3 rounded-md border border-stone-300 bg-stone-100 px-4 py-3 text-stone-700">
|
||||
|
||||
Reference in New Issue
Block a user