Skip to content

Commit 7427ed4

Browse files
committed
fix(js/client): respect stream flag + structured chunks in handleChat
Generator chat-handler on the JS port unconditionally emitted chat-chunk envelopes (terminator hardcoded finish_reason='stop') and stringified any non-string chunk via String(chunk). The hub parks a Future in publisher.pending[request_id] for non-streaming HTTP callers and only chat-response resolves it — chat-chunks land on a no-op branch, the Future never resolves, and the caller times out at 60s. JS publishers using generators therefore broke every non-streaming call. Structured chunks carrying tool_call_delta were also lost: the dict went through String(chunk) = '[object Object]' delta with the tool call dropped on the floor. Same class as the Python b2fe21e (sync-generator stringify) and c3f34f9 (hardcoded finish_reason) fixes. Port mirrors Python's _handle_chat: - read 'stream' from chat-request options before handing to the handler - iterator + stream=true: emit chat-chunk per yield via the new serializeStreamChunk() (mirror of _serialize_stream_chunk); terminator echoes whichever finish_reason the handler last surfaced (no more hardcoded 'stop') - iterator + stream=false: accumulate via chunkFields() + accumulateToolCall() (mirrors _chunk_fields + _accumulate_tool_call), emit ONE chat-response carrying joined text + sorted tool_calls + the handler-supplied finish_reason ChatHandler return type widened from AsyncIterable<string>/Iterable<string> to AsyncIterable<ChatChunkLike>/Iterable<ChatChunkLike> so a JS publisher can declare its tool-call producers without casting.
1 parent 88dc876 commit 7427ed4

1 file changed

Lines changed: 143 additions & 17 deletions

File tree

js/src/client.ts

Lines changed: 143 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -26,15 +26,28 @@ import { AuthError, ZhubConnectionError } from './errors.js';
2626
const _globalWS = (globalThis as unknown as { WebSocket?: typeof WebSocket }).WebSocket;
2727
const WebSocketImpl: typeof WebSocket = (_globalWS ?? (WS as unknown as typeof WebSocket)) as typeof WebSocket;
2828

29+
/** A streaming chunk emitted by a chat handler. May be a plain text delta or
30+
* a structured payload carrying a `tool_call_delta`, `finish_reason`, and/or
31+
* `done` marker — same shape Python's `_serialize_stream_chunk` understands. */
32+
export type ChatChunkLike =
33+
| string
34+
| {
35+
delta?: string;
36+
tool_call_delta?: Record<string, unknown>;
37+
done?: boolean;
38+
finish_reason?: string;
39+
[key: string]: unknown;
40+
};
41+
2942
export type ChatHandler = (
3043
messages: Array<{ role: string; content: string }>,
3144
options: Record<string, unknown>,
3245
) =>
3346
| string
3447
| Promise<string>
3548
| { text: string; finish_reason?: string }
36-
| AsyncIterable<string>
37-
| Iterable<string>;
49+
| AsyncIterable<ChatChunkLike>
50+
| Iterable<ChatChunkLike>;
3851

3952
export type ChatResult = Record<string, unknown> & { text: string };
4053

@@ -271,28 +284,25 @@ export class ZhubPublication {
271284
const messages = (env.payload.messages as Array<{ role: string; content: string }>) ?? [];
272285
const options = { ...env.payload };
273286
delete (options as Record<string, unknown>).messages;
287+
const streamingRequested = Boolean((options as Record<string, unknown>).stream);
274288
try {
275289
const result = this.chatHandler(messages, options);
276290

277-
// Async iterator → stream chat-chunks
278-
if (result && typeof result === 'object' && Symbol.asyncIterator in result) {
279-
for await (const chunk of result as AsyncIterable<string>) {
280-
ws.send(JSON.stringify(chatChunk(String(chunk), env.request_id)));
281-
}
282-
ws.send(JSON.stringify(chatChunk('', env.request_id, true, 'stop')));
283-
return;
284-
}
285-
// Sync iterator (not string/dict) → also stream
286-
if (
291+
const isAsyncIter =
292+
result && typeof result === 'object' && Symbol.asyncIterator in (result as object);
293+
const isSyncIter =
294+
!isAsyncIter &&
287295
result && typeof result === 'object' &&
288296
Symbol.iterator in (result as object) &&
289297
typeof (result as { text?: string }).text === 'undefined' &&
290-
typeof result !== 'string'
291-
) {
292-
for (const chunk of result as Iterable<string>) {
293-
ws.send(JSON.stringify(chatChunk(String(chunk), env.request_id)));
298+
typeof result !== 'string';
299+
300+
if (isAsyncIter || isSyncIter) {
301+
if (streamingRequested) {
302+
await this.streamHandlerOutput(ws, env.request_id, result as AsyncIterable<ChatChunkLike> | Iterable<ChatChunkLike>);
303+
} else {
304+
await this.accumulateHandlerOutput(ws, env.request_id, result as AsyncIterable<ChatChunkLike> | Iterable<ChatChunkLike>);
294305
}
295-
ws.send(JSON.stringify(chatChunk('', env.request_id, true, 'stop')));
296306
return;
297307
}
298308

@@ -318,6 +328,122 @@ export class ZhubPublication {
318328
);
319329
}
320330
}
331+
332+
private async streamHandlerOutput(
333+
ws: WebSocket,
334+
requestId: string,
335+
iter: AsyncIterable<ChatChunkLike> | Iterable<ChatChunkLike>,
336+
): Promise<void> {
337+
let finalFinish: string | undefined;
338+
for await (const chunk of iter as AsyncIterable<ChatChunkLike>) {
339+
const { envelope: e, finishReason } = serializeStreamChunk(chunk, requestId);
340+
if (finishReason) finalFinish = finishReason;
341+
ws.send(JSON.stringify(e));
342+
}
343+
ws.send(JSON.stringify(chatChunk('', requestId, true, finalFinish ?? 'stop')));
344+
}
345+
346+
private async accumulateHandlerOutput(
347+
ws: WebSocket,
348+
requestId: string,
349+
iter: AsyncIterable<ChatChunkLike> | Iterable<ChatChunkLike>,
350+
): Promise<void> {
351+
const textParts: string[] = [];
352+
const tcSlots = new Map<number, Record<string, unknown>>();
353+
let finalFinish: string | undefined;
354+
for await (const chunk of iter as AsyncIterable<ChatChunkLike>) {
355+
const { text, toolCallDelta, finishReason } = chunkFields(chunk);
356+
if (text) textParts.push(text);
357+
if (toolCallDelta) accumulateToolCall(tcSlots, toolCallDelta);
358+
if (finishReason) finalFinish = finishReason;
359+
}
360+
const payload: Record<string, unknown> = {
361+
text: textParts.join(''),
362+
finish_reason: finalFinish ?? 'stop',
363+
};
364+
if (tcSlots.size > 0) {
365+
payload.tool_calls = Array.from(tcSlots.keys()).sort((a, b) => a - b).map((i) => tcSlots.get(i)!);
366+
}
367+
ws.send(
368+
JSON.stringify({ type: 'chat-response', request_id: requestId, payload }),
369+
);
370+
}
371+
}
372+
373+
/** Mirror of Python's `_serialize_stream_chunk`. Builds a chat-chunk envelope
374+
* from a string text-delta or a structured chunk carrying delta /
375+
* tool_call_delta / done / finish_reason. Returns the envelope plus any
376+
* finish_reason carried (so the caller can echo it on the terminator). */
377+
function serializeStreamChunk(
378+
chunk: ChatChunkLike,
379+
requestId: string,
380+
): { envelope: Envelope; finishReason?: string } {
381+
if (typeof chunk === 'string') {
382+
return { envelope: chatChunk(chunk, requestId) };
383+
}
384+
if (chunk && typeof chunk === 'object') {
385+
const c = chunk as Record<string, unknown>;
386+
const payload: Record<string, unknown> = {};
387+
const delta = (c.delta ?? '') as string;
388+
const tcd = c.tool_call_delta;
389+
const done = Boolean(c.done);
390+
const finish = (c.finish_reason as string | undefined) || undefined;
391+
if (delta) payload.delta = delta;
392+
if (tcd) payload.tool_call_delta = tcd;
393+
payload.done = done;
394+
if (finish) payload.finish_reason = finish;
395+
return {
396+
envelope: { type: 'chat-chunk', request_id: requestId, payload },
397+
finishReason: finish,
398+
};
399+
}
400+
return {
401+
envelope: { type: 'chat-chunk', request_id: requestId, payload: { delta: String(chunk), done: false } },
402+
};
403+
}
404+
405+
/** Mirror of Python's `_chunk_fields` — extract (text, tool_call_delta,
406+
* finish_reason) from a raw chat-handler chunk for non-streaming accumulation. */
407+
function chunkFields(chunk: ChatChunkLike): {
408+
text: string;
409+
toolCallDelta?: Record<string, unknown>;
410+
finishReason?: string;
411+
} {
412+
if (typeof chunk === 'string') return { text: chunk };
413+
if (chunk && typeof chunk === 'object') {
414+
const c = chunk as Record<string, unknown>;
415+
return {
416+
text: (c.delta as string) || '',
417+
toolCallDelta: c.tool_call_delta as Record<string, unknown> | undefined,
418+
finishReason: c.finish_reason as string | undefined,
419+
};
420+
}
421+
return { text: String(chunk) };
422+
}
423+
424+
/** Mirror of Python's `_accumulate_tool_call` — fold one tool_call delta into
425+
* `slots` keyed by index; set id/type once, keep the function name, concat arg
426+
* fragments. Same accumulator the hub uses on its streaming path, so a
427+
* non-streaming HTTP caller sees a fully assembled tool_calls list. */
428+
function accumulateToolCall(
429+
slots: Map<number, Record<string, unknown>>,
430+
tcd: Record<string, unknown>,
431+
): void {
432+
const idx = (tcd.index as number) ?? 0;
433+
let slot = slots.get(idx);
434+
if (!slot) {
435+
slot = { function: {} };
436+
slots.set(idx, slot);
437+
}
438+
if ('id' in tcd) slot.id = tcd.id;
439+
if ('type' in tcd) slot.type = tcd.type;
440+
const fnIn = (tcd.function as Record<string, unknown>) || {};
441+
const fn = (slot.function as Record<string, unknown>) ?? {};
442+
if ('name' in fnIn) fn.name = fnIn.name;
443+
if ('arguments' in fnIn) {
444+
fn.arguments = ((fn.arguments as string) ?? '') + (fnIn.arguments as string);
445+
}
446+
slot.function = fn;
321447
}
322448

323449
export function publish(opts: PublishOptions): ZhubPublication {

0 commit comments

Comments
 (0)