fix: harden Hermes stream recovery around tool boundaries (#189)

This commit is contained in:
Zhicheng Han
2026-04-24 15:42:42 +02:00
committed by GitHub
parent edd41e6eb7
commit 009acc1c28
6 changed files with 496 additions and 114 deletions
+85
View File
@@ -414,4 +414,89 @@ describe('SSE stream interception — run.completed', () => {
expect(mockUpdateUsage).toHaveBeenCalledWith('session-split', 200, 50)
})
it('forwards colon-containing SSE deltas around tool events unchanged and disables buffering', async () => {
const runId = 'run-colon-tool'
const sseData = [
`data: ${JSON.stringify({ event: 'message.delta', run_id: runId, delta: '让我直接读文件:A: B' })}\n\n`,
`data: ${JSON.stringify({ event: 'tool.started', run_id: runId, tool: 'read_file', preview: 'file:a.md' })}\n\n`,
`data: ${JSON.stringify({ event: 'tool.completed', run_id: runId })}\n\n`,
`data: ${JSON.stringify({ event: 'message.delta', run_id: runId, delta: '继续:done' })}\n\n`,
]
mockFetch.mockResolvedValue({
status: 200,
headers: new Headers({ 'content-type': 'text/event-stream' }),
body: createSSEBody(sseData),
})
const ctx = createMockCtx({
path: `/api/hermes/v1/runs/${runId}/events`,
search: '',
})
await proxy(ctx)
const forwarded = ctx.res.write.mock.calls
.map(([chunk]: [Uint8Array]) => new TextDecoder().decode(chunk))
.join('')
expect(forwarded).toBe(sseData.join(''))
expect(ctx.set).toHaveBeenCalledWith('Content-Type', 'text/event-stream')
expect(ctx.set).toHaveBeenCalledWith('Cache-Control', 'no-cache, no-transform')
expect(ctx.set).toHaveBeenCalledWith('X-Accel-Buffering', 'no')
})
it('intercepts run.completed with CRLF delimiters and data without a space', async () => {
const runId = 'run-crlf'
setRunSession(runId, 'session-crlf')
const completedJson = JSON.stringify({ event: 'run.completed', run_id: runId, usage: { input_tokens: 321, output_tokens: 45, total_tokens: 366 } })
const sseData = [`data:${completedJson}\r\n\r\n`]
mockFetch.mockResolvedValue({
status: 200,
headers: new Headers({ 'content-type': 'text/event-stream' }),
body: createSSEBody(sseData),
})
const ctx = createMockCtx({
path: `/api/hermes/v1/runs/${runId}/events`,
search: '',
})
await proxy(ctx)
expect(mockUpdateUsage).toHaveBeenCalledWith('session-crlf', 321, 45)
})
it('does not let usage accounting failures abort the SSE stream', async () => {
const runId = 'run-usage-fails'
setRunSession(runId, 'session-usage-fails')
mockUpdateUsage.mockImplementationOnce(() => {
throw new Error('usage db unavailable')
})
const sseData = [
`data: ${JSON.stringify({ event: 'message.delta', run_id: runId, delta: 'before' })}\n\n`,
`data: ${JSON.stringify({ event: 'run.completed', run_id: runId, usage: { input_tokens: 1, output_tokens: 2, total_tokens: 3 } })}\n\n`,
]
mockFetch.mockResolvedValue({
status: 200,
headers: new Headers({ 'content-type': 'text/event-stream' }),
body: createSSEBody(sseData),
})
const ctx = createMockCtx({
path: `/api/hermes/v1/runs/${runId}/events`,
search: '',
})
await proxy(ctx)
const forwarded = ctx.res.write.mock.calls
.map(([chunk]: [Uint8Array]) => new TextDecoder().decode(chunk))
.join('')
expect(ctx.status).toBe(200)
expect(forwarded).toBe(sseData.join(''))
expect(ctx.res.end).toHaveBeenCalled()
})
})