test(mcplocal): prove chat SSE streams live through the proxy, not buffered
Some checks failed
CI/CD / typecheck (pull_request) Successful in 1m17s
CI/CD / test (pull_request) Successful in 1m27s
CI/CD / lint (pull_request) Successful in 2m55s
CI/CD / smoke (pull_request) Failing after 1m59s
CI/CD / build (pull_request) Successful in 4m33s
CI/CD / publish (pull_request) Has been skipped
Some checks failed
CI/CD / typecheck (pull_request) Successful in 1m17s
CI/CD / test (pull_request) Successful in 1m27s
CI/CD / lint (pull_request) Successful in 2m55s
CI/CD / smoke (pull_request) Failing after 1m59s
CI/CD / build (pull_request) Successful in 4m33s
CI/CD / publish (pull_request) Has been skipped
`mcpctl chat reviewer` showed nothing until the turn finished, then dumped the whole answer at once. mcpd streams token deltas and the CLI renders them incrementally — the sole buffering point was mcplocal's catch-all /api/v1/* proxy reading the whole SSE body via res.text() before replying. The previous commit (cherry-picked from feat/agentic-teams) pipes the body through instead; this one adds the cover that was missing: - proxy-long-running.test.ts: a progressive-delivery test in which the stand-in mcpd withholds its final frame until the client has observed the first delta through the proxy. A buffering proxy cannot satisfy that ordering — verified: the test fails in 3s (no hang) against a res.text() proxy and passes against the piped one. The existing SSE test used inject(), which collects the whole body and so passes either way. - agent-chat.smoke.test.ts: a live smoke that posts through mcplocal on localhost:3200 (the path `mcpctl chat` actually takes — every other chat smoke uses --direct and bypasses the proxy entirely) and asserts delta frames arrive spread across the generation window, not in one burst at stream end. Uses its own agent: the shared smoke agent pins replies to a single token, too short to tell live streaming from a buffer dump. Also settles the strict-boolean-expressions lint on the auth-header check the streaming split touched. Local: workspace 2546 passed, proxy suite 10/10, smoke file loads + self-skips. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016dNpnBqyyz9GxfznVcX2sP
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
import http from 'node:http';
|
||||
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
|
||||
@@ -144,6 +146,63 @@ describe('proxy — long-running route budget', () => {
|
||||
expect(res.body).toContain('[DONE]');
|
||||
});
|
||||
|
||||
it('delivers each SSE frame while the upstream is still generating', async () => {
|
||||
// The buffering regression is invisible to the pass-through test above:
|
||||
// `inject()` collects the whole body, so a proxy that buffers via
|
||||
// res.text() still passes it. This test proves *progressive* delivery by
|
||||
// making the upstream withhold its final frame until the client has
|
||||
// observed the first one. A buffering proxy can never satisfy that
|
||||
// ordering — the 3s guard resolves the gate so the run fails cleanly
|
||||
// instead of deadlocking.
|
||||
let openGate: (seen: boolean) => void = () => {};
|
||||
const clientSawFirstFrame = new Promise<boolean>((r) => { openGate = r; });
|
||||
const guard = setTimeout(() => openGate(false), 3_000);
|
||||
|
||||
const base = await startUpstream((a) => {
|
||||
a.post('/api/v1/agents/:name/chat', async (_req, reply) => {
|
||||
reply.raw.writeHead(200, { 'Content-Type': 'text/event-stream' });
|
||||
reply.raw.write('data: {"type":"text","delta":"live"}\n\n');
|
||||
await clientSawFirstFrame;
|
||||
reply.raw.write('data: {"type":"final"}\n\n');
|
||||
reply.raw.write('data: [DONE]\n\n');
|
||||
reply.raw.end();
|
||||
return reply;
|
||||
});
|
||||
});
|
||||
const proxy = await startProxy(base, 50);
|
||||
await proxy.listen({ port: 0, host: '127.0.0.1' });
|
||||
const addr = proxy.server.address();
|
||||
if (addr === null || typeof addr === 'string') throw new Error('no address');
|
||||
|
||||
const body = await new Promise<string>((resolve, reject) => {
|
||||
const req = http.request({
|
||||
hostname: '127.0.0.1',
|
||||
port: addr.port,
|
||||
path: '/api/v1/agents/reviewer/chat',
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}, (res) => {
|
||||
let acc = '';
|
||||
res.setEncoding('utf-8');
|
||||
res.on('data', (chunk: string) => {
|
||||
acc += chunk;
|
||||
if (acc.includes('"delta":"live"')) openGate(true);
|
||||
});
|
||||
res.on('end', () => resolve(acc));
|
||||
res.on('error', reject);
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.end(JSON.stringify({ message: 'hi', stream: true }));
|
||||
});
|
||||
clearTimeout(guard);
|
||||
|
||||
// The ordering proof: the first frame reached the client while the
|
||||
// upstream was still holding the stream open.
|
||||
await expect(clientSawFirstFrame).resolves.toBe(true);
|
||||
expect(body).toContain('"type":"final"');
|
||||
expect(body).toContain('[DONE]');
|
||||
});
|
||||
|
||||
it('relays a non-200 status from a streaming route', async () => {
|
||||
const base = await startUpstream((a) => {
|
||||
a.post('/api/v1/agents/:name/chat', async (_req, reply) => {
|
||||
|
||||
Reference in New Issue
Block a user