Skip to content

Commit 15d63a1

Browse files
committed
test(js/client): cover handleChat stream-flag + structured-chunk parity
Drives publish() against a real ws.WebSocketServer acting as the hub: server sends 'registered' + a 'chat-request' (with or without stream: true), then collects every envelope the publisher emits in response. No WS-layer mocking. 4 cases, one per fix dimension: - non-streaming, plain text generator: asserts exactly one chat-response (and zero chat-chunks) — pre-fix the publisher emitted 3 chunks + a terminator and never satisfied the hub's pending Future. - non-streaming, structured tool_call_delta chunks: asserts the response carries tool_calls (id/name/concatenated args) and the chunk-supplied finish_reason 'tool_calls' — pre-fix the dicts went through String(chunk) and every tool call was dropped. - streaming, handler-supplied finish_reason: asserts the terminator echoes 'length' from the final chunk — pre-fix the terminator was hardcoded 'stop'. - streaming, structured tool_call_delta: asserts the wire chunk carries tool_call_delta with id intact, and no envelope leaks delta: '[object Object]'. Mutation-verified against four separate one-line reverts of src/client.ts: each one fails exactly the case that targets it. Test suite 36 → 40 pass; package.json test runner updated to load the new compiled file.
1 parent 7427ed4 commit 15d63a1

2 files changed

Lines changed: 167 additions & 1 deletion

File tree

js/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
],
2727
"scripts": {
2828
"build": "tsc -p tsconfig.json",
29-
"test": "tsc -p tsconfig.test.json && node --test dist-test/test/manifest.test.js dist-test/test/protocol.test.js dist-test/test/client.test.js dist-test/test/client_url.test.js dist-test/test/client_lifecycle.test.js dist-test/test/client_stream.test.js"
29+
"test": "tsc -p tsconfig.test.json && node --test dist-test/test/manifest.test.js dist-test/test/protocol.test.js dist-test/test/client.test.js dist-test/test/client_url.test.js dist-test/test/client_lifecycle.test.js dist-test/test/client_stream.test.js dist-test/test/client_handle_chat.test.js"
3030
},
3131
"dependencies": {
3232
"ws": "^8.18.0"

js/test/client_handle_chat.test.ts

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
/**
2+
* ZhubPublication.handleChat() — JS port of Python's `_handle_chat`.
3+
*
4+
* Before the port, a generator chat-handler on the JS side ignored the
5+
* caller's `stream` flag and ALWAYS emitted chat-chunk envelopes (terminator
6+
* hardcoded `finish_reason: 'stop'`). When the hub serves a non-streaming
7+
* HTTP caller it parks a Future in `publisher.pending[request_id]` and only
8+
* the `chat-response` handler resolves it — chat-chunks land on a no-op
9+
* branch and the Future never resolves, so the HTTP caller times out 60s
10+
* later. JS publishers using generators therefore broke every non-streaming
11+
* call.
12+
*
13+
* Structured chunks (dicts/objects carrying `tool_call_delta` /
14+
* `finish_reason`) were also coerced via `String(chunk)` → "[object Object]"
15+
* deltas with all tool calls silently dropped. Same class as the Python
16+
* b2fe21e sync-generator stringify fix.
17+
*
18+
* These tests drive a real `publish()` against a fake hub WS server, send a
19+
* chat-request with/without `stream`, and assert the envelopes that come
20+
* back.
21+
*/
22+
import { describe, it } from 'node:test';
23+
import assert from 'node:assert/strict';
24+
import { WebSocketServer } from 'ws';
25+
import type { AddressInfo } from 'node:net';
26+
import { publish } from '../src/client.js';
27+
28+
function makeHubUrl(port: number): string {
29+
return `http://127.0.0.1:${port}`;
30+
}
31+
32+
/**
33+
* Spin up a fake hub WS that registers the publisher, then on first 'open'
34+
* sends a chat-request and collects every envelope the publisher sends back.
35+
* Returns the captured envelopes once the publisher emits a terminal
36+
* chat-response or done=true chat-chunk.
37+
*/
38+
async function driveOneChat(opts: {
39+
chatHandler: Parameters<typeof publish>[0]['chatHandler'];
40+
stream: boolean;
41+
requestId?: string;
42+
}): Promise<{ envelopes: Array<{ type: string; payload: Record<string, unknown> }> }> {
43+
const wss = new WebSocketServer({ port: 0 });
44+
await new Promise<void>((r) => wss.on('listening', r));
45+
const port = (wss.address() as AddressInfo).port;
46+
const requestId = opts.requestId ?? 'req-1';
47+
const captured: Array<{ type: string; payload: Record<string, unknown> }> = [];
48+
49+
const finished = new Promise<void>((resolve) => {
50+
wss.on('connection', (ws) => {
51+
ws.send(JSON.stringify({ type: 'registered', request_id: 'r0', payload: { name: 'p', base_url: '', api_key: 'zk_test' } }));
52+
ws.send(
53+
JSON.stringify({
54+
type: 'chat-request',
55+
request_id: requestId,
56+
payload: {
57+
messages: [{ role: 'user', content: 'hi' }],
58+
stream: opts.stream,
59+
},
60+
}),
61+
);
62+
ws.on('message', (raw) => {
63+
const env = JSON.parse(raw.toString());
64+
if (env.type !== 'chat-response' && env.type !== 'chat-chunk') return;
65+
captured.push(env);
66+
if (env.type === 'chat-response') resolve();
67+
if (env.type === 'chat-chunk' && env.payload?.done === true) resolve();
68+
});
69+
});
70+
});
71+
72+
const pub = publish({
73+
name: 'p',
74+
description: 't',
75+
hubUrl: makeHubUrl(port),
76+
apiKey: 'zk_test',
77+
chatHandler: opts.chatHandler,
78+
});
79+
80+
await Promise.race([
81+
finished,
82+
new Promise<void>((_, rj) => setTimeout(() => rj(new Error('timed out waiting for publisher response')), 3_000)),
83+
]);
84+
85+
await pub.stop();
86+
await new Promise<void>((r) => wss.close(() => r()));
87+
return { envelopes: captured };
88+
}
89+
90+
describe('ZhubPublication.handleChat — generator handler, non-streaming caller', () => {
91+
it('accumulates yielded text into a single chat-response (no chat-chunks leak through)', async () => {
92+
async function* handler() {
93+
yield 'hel';
94+
yield 'lo ';
95+
yield 'world';
96+
}
97+
const { envelopes } = await driveOneChat({ chatHandler: handler, stream: false });
98+
// Pre-fix: would emit 4 chat-chunks (3 deltas + terminator) and zero chat-response → caller times out.
99+
const responses = envelopes.filter((e) => e.type === 'chat-response');
100+
const chunks = envelopes.filter((e) => e.type === 'chat-chunk');
101+
assert.equal(responses.length, 1, `expected exactly one chat-response, got ${responses.length}`);
102+
assert.equal(chunks.length, 0, `expected zero chat-chunks (non-streaming caller), got ${chunks.length}`);
103+
assert.equal(responses[0].payload.text, 'hello world');
104+
assert.equal(responses[0].payload.finish_reason, 'stop');
105+
});
106+
107+
it('surfaces tool_call_delta into a response-level tool_calls array', async () => {
108+
// Structured chunks — pre-fix the dict went through String(chunk) =
109+
// "[object Object]" delta with all tool_call_delta dropped on the floor.
110+
async function* handler() {
111+
yield { tool_call_delta: { index: 0, id: 'call_1', type: 'function', function: { name: 'lookup_city' } } };
112+
yield { tool_call_delta: { index: 0, function: { arguments: '{"city":' } } };
113+
yield { tool_call_delta: { index: 0, function: { arguments: '"Paris"}' } } };
114+
yield { delta: '', finish_reason: 'tool_calls', done: true };
115+
}
116+
const { envelopes } = await driveOneChat({ chatHandler: handler, stream: false });
117+
const responses = envelopes.filter((e) => e.type === 'chat-response');
118+
assert.equal(responses.length, 1);
119+
const payload = responses[0].payload as Record<string, unknown>;
120+
assert.equal(payload.finish_reason, 'tool_calls', 'chunk-supplied finish_reason must reach the response');
121+
const tcs = payload.tool_calls as Array<Record<string, unknown>>;
122+
assert.equal(tcs.length, 1);
123+
assert.equal(tcs[0].id, 'call_1');
124+
const fn = tcs[0].function as Record<string, unknown>;
125+
assert.equal(fn.name, 'lookup_city');
126+
assert.equal(fn.arguments, '{"city":"Paris"}');
127+
});
128+
});
129+
130+
describe('ZhubPublication.handleChat — generator handler, streaming caller', () => {
131+
it('forwards each chunk and honors the handler-supplied finish_reason on the terminator', async () => {
132+
// Pre-fix: terminator hardcoded `chat_chunk('', id, true, 'stop')`, so a
133+
// handler that wanted to signal e.g. tool_calls saw 'stop' on the wire.
134+
async function* handler() {
135+
yield 'hello ';
136+
yield 'world';
137+
yield { delta: '', finish_reason: 'length', done: true };
138+
}
139+
const { envelopes } = await driveOneChat({ chatHandler: handler, stream: true });
140+
assert.ok(envelopes.every((e) => e.type === 'chat-chunk'), 'streaming caller should see only chat-chunks');
141+
// Last envelope is the terminator; its finish_reason must echo the handler's.
142+
const terminator = envelopes[envelopes.length - 1];
143+
assert.equal(terminator.payload.done, true);
144+
assert.equal(terminator.payload.finish_reason, 'length');
145+
});
146+
147+
it('serializes structured tool_call_delta chunks into chat-chunk envelopes (not String(chunk))', async () => {
148+
// Pre-fix: yielding a dict went through `chatChunk(String(chunk), ...)` =
149+
// delta '[object Object]' with the entire tool_call_delta dropped.
150+
async function* handler() {
151+
yield { tool_call_delta: { index: 0, id: 'call_x', function: { name: 'fetch' } } };
152+
yield { delta: '', done: true, finish_reason: 'tool_calls' };
153+
}
154+
const { envelopes } = await driveOneChat({ chatHandler: handler, stream: true });
155+
// Find the chunk that should carry the tool_call_delta.
156+
const withTcd = envelopes.find((e) => (e.payload as Record<string, unknown>).tool_call_delta);
157+
assert.ok(withTcd, 'expected a chat-chunk envelope carrying tool_call_delta');
158+
const tcd = (withTcd!.payload as Record<string, unknown>).tool_call_delta as Record<string, unknown>;
159+
assert.equal(tcd.id, 'call_x');
160+
// And no envelope should have leaked the JS toString.
161+
for (const e of envelopes) {
162+
const delta = (e.payload as Record<string, unknown>).delta;
163+
assert.notEqual(delta, '[object Object]', `chunk leaked toString: ${JSON.stringify(e)}`);
164+
}
165+
});
166+
});

0 commit comments

Comments
 (0)