|
| 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