mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Implements the approved simplification Agent Note: sse.ts now pipes the
response body through TextDecoderStream and EventSourceParserStream
(eventsource-parser/stream) and keeps only the DeepSeek protocol shim —
yield each event's data, terminate on [DONE], throw
LlmError('STREAM_CLOSED') on EOF without the sentinel. The SSE
spec-conformance tests are deleted; sse.spec.ts pins only the
[DONE]/STREAM_CLOSED/EOF contract, including the new spec-strict verdict
that an unterminated trailing event is truncation (the old parser
flushed it — a robustness nicety no real provider shape needs).
eventsource-parser@^3.1.0 becomes llm-deepseek's second runtime
dependency (already in the lockfile transitively via the MCP SDK).
Docs: the Agent Note moves proposed/ → implemented/ and is rewritten per
the lifecycle contract; the rejected NIH roll-up note's inbound links
follow. The twin-adapters note, dsh-llm LlmAdapter JSDoc (and its
type-equiv fences), cookbook, group/package READMEs, root AGENTS.md
layout line, sdk-helper comments, and the regenerated config catalog
drop the "hand-rolled fetch + SSE" claim in both languages; all eight
touched pairs re-recorded.
59 lines
2.3 KiB
TypeScript
59 lines
2.3 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import { LlmError } from '@deepseek-ai/dsh-llm'
|
|
import { DONE, parseSse } from '../src/sse.ts'
|
|
|
|
/**
|
|
* DeepSeek protocol contract only: the [DONE] sentinel and STREAM_CLOSED on
|
|
* EOF without it. SSE framing (chunk splits, CRLF, multi-data joins, comments)
|
|
* is eventsource-parser's contract, not re-proven here.
|
|
*/
|
|
|
|
/** Build an SSE byte stream from string fragments (fragments = network reads). */
|
|
function bytes(...fragments: string[]): ReadableStream<Uint8Array<ArrayBuffer>> {
|
|
const encoder = new TextEncoder()
|
|
return new ReadableStream({
|
|
start(controller) {
|
|
for (const fragment of fragments) controller.enqueue(encoder.encode(fragment))
|
|
controller.close()
|
|
},
|
|
})
|
|
}
|
|
|
|
async function collect(stream: AsyncIterable<string>): Promise<string[]> {
|
|
const out: string[] = []
|
|
for await (const item of stream) out.push(item)
|
|
return out
|
|
}
|
|
|
|
describe('parseSse', () => {
|
|
it('yields event payloads and the DONE sentinel', async () => {
|
|
const events = await collect(parseSse(bytes('data: {"a":1}\n\ndata: [DONE]\n\n')))
|
|
expect(events).toEqual(['{"a":1}', DONE])
|
|
})
|
|
|
|
it('stops yielding after DONE even when more data follows', async () => {
|
|
const events = await collect(parseSse(bytes('data: [DONE]\n\ndata: {"late":1}\n\n')))
|
|
expect(events).toEqual([DONE])
|
|
})
|
|
|
|
it('throws STREAM_CLOSED when the stream ends without DONE', async () => {
|
|
await expect(collect(parseSse(bytes('data: {"a":1}\n\n')))).rejects.toThrow(LlmError)
|
|
await expect(collect(parseSse(bytes('data: {"a":1}\n\n')))).rejects.toThrow(/without \[DONE\]/)
|
|
})
|
|
|
|
it('throws STREAM_CLOSED for an empty stream', async () => {
|
|
await expect(collect(parseSse(bytes()))).rejects.toThrow(/without \[DONE\]/)
|
|
})
|
|
|
|
it('throws STREAM_CLOSED for a mid-event close', async () => {
|
|
await expect(collect(parseSse(bytes('data: {"a"')))).rejects.toThrow(/without \[DONE\]/)
|
|
})
|
|
|
|
it('treats a final DONE missing its blank-line terminator as truncation', async () => {
|
|
// Spec-strict framing: an event dispatches only on its blank-line
|
|
// terminator, so an unterminated tail at EOF is STREAM_CLOSED — real
|
|
// providers always terminate events, so a missing terminator is truncation.
|
|
await expect(collect(parseSse(bytes('data: {"a":1}\n\ndata: [DONE]')))).rejects.toThrow(/without \[DONE\]/)
|
|
})
|
|
})
|