mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
fix(chat): stop duplicating assistant replies on multi-segment turns (#1648)
This commit is contained in:
@@ -120,6 +120,13 @@ function deleteSegmentDelivery(deliveries: Map<string, SegmentDelivery>, key: st
|
||||
deliveries.delete(key);
|
||||
}
|
||||
|
||||
// Delivery is complete iff every expected segment_index arrived. Do NOT also
|
||||
// compare reconstructed segments against event.full_response — the server
|
||||
// trims each segment and normalises joiners during segmentation
|
||||
// (presentation.rs::segment_for_delivery), while full_response keeps the raw
|
||||
// LLM text. A byte-equality check therefore fails on virtually every
|
||||
// multi-segment turn and triggers the reconciliation path, producing a
|
||||
// duplicate assistant message.
|
||||
function hasCompleteSegmentDelivery(
|
||||
event: ChatDoneEvent,
|
||||
delivery: SegmentDelivery | undefined
|
||||
@@ -127,14 +134,10 @@ function hasCompleteSegmentDelivery(
|
||||
const expected = event.segment_total ?? 0;
|
||||
if (expected <= 0 || !delivery) return false;
|
||||
if (delivery.segments.size < expected) return false;
|
||||
|
||||
let reconstructed = '';
|
||||
for (let i = 0; i < expected; i += 1) {
|
||||
const segment = delivery.segments.get(i);
|
||||
if (segment === undefined) return false;
|
||||
reconstructed += segment;
|
||||
if (!delivery.segments.has(i)) return false;
|
||||
}
|
||||
return reconstructed === event.full_response;
|
||||
return true;
|
||||
}
|
||||
|
||||
function chatDoneExtraMetadata(event: ChatDoneEvent): Record<string, unknown> | undefined {
|
||||
|
||||
@@ -324,21 +324,26 @@ describe('ChatRuntimeProvider — dedupe, proactive resolution, mid-turn invaria
|
||||
expect(threadApi.appendMessage).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('reconciles when segments are present but not in full_response order', async () => {
|
||||
it('does not reconcile when all segments arrived even if full_response differs (trim/joiner)', async () => {
|
||||
// Regression: the server's segmenter trims each segment and joins with
|
||||
// "\n\n", while chat_done.full_response is the raw LLM text. A strict
|
||||
// byte-equality check used to fire reconciliation on every multi-segment
|
||||
// turn — once we have all expected segment_index values, delivery is
|
||||
// complete regardless of full_response content.
|
||||
const listeners = renderProvider();
|
||||
|
||||
act(() => {
|
||||
listeners.onSegment?.({
|
||||
thread_id: 't-out-of-order',
|
||||
request_id: 'r-out-of-order',
|
||||
full_response: 'Alpha',
|
||||
thread_id: 't-trim',
|
||||
request_id: 'r-trim',
|
||||
full_response: 'Hello.',
|
||||
segment_index: 0,
|
||||
segment_total: 2,
|
||||
});
|
||||
listeners.onSegment?.({
|
||||
thread_id: 't-out-of-order',
|
||||
request_id: 'r-out-of-order',
|
||||
full_response: 'Beta',
|
||||
thread_id: 't-trim',
|
||||
request_id: 'r-trim',
|
||||
full_response: 'World.',
|
||||
segment_index: 1,
|
||||
segment_total: 2,
|
||||
});
|
||||
@@ -348,9 +353,44 @@ describe('ChatRuntimeProvider — dedupe, proactive resolution, mid-turn invaria
|
||||
|
||||
act(() => {
|
||||
listeners.onDone?.({
|
||||
thread_id: 't-out-of-order',
|
||||
request_id: 'r-out-of-order',
|
||||
full_response: 'BetaAlpha',
|
||||
thread_id: 't-trim',
|
||||
request_id: 'r-trim',
|
||||
// Raw LLM output: leading/trailing whitespace + paragraph break.
|
||||
// segments.join('') === 'Hello.World.' !== full_response, but
|
||||
// delivery is still complete.
|
||||
full_response: '\nHello.\n\nWorld.\n',
|
||||
rounds_used: 1,
|
||||
total_input_tokens: 10,
|
||||
total_output_tokens: 20,
|
||||
segment_total: 2,
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => expect(mockRefetchSnapshot).toHaveBeenCalledTimes(1));
|
||||
expect(threadApi.appendMessage).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('reconciles when a segment is missing', async () => {
|
||||
const listeners = renderProvider();
|
||||
|
||||
act(() => {
|
||||
listeners.onSegment?.({
|
||||
thread_id: 't-missing',
|
||||
request_id: 'r-missing',
|
||||
full_response: 'Part one.',
|
||||
segment_index: 0,
|
||||
segment_total: 2,
|
||||
});
|
||||
// segment_index 1 never arrives.
|
||||
});
|
||||
|
||||
await waitFor(() => expect(threadApi.appendMessage).toHaveBeenCalledTimes(1));
|
||||
|
||||
act(() => {
|
||||
listeners.onDone?.({
|
||||
thread_id: 't-missing',
|
||||
request_id: 'r-missing',
|
||||
full_response: 'Part one.\n\nPart two.',
|
||||
rounds_used: 1,
|
||||
total_input_tokens: 10,
|
||||
total_output_tokens: 20,
|
||||
@@ -360,11 +400,11 @@ describe('ChatRuntimeProvider — dedupe, proactive resolution, mid-turn invaria
|
||||
|
||||
await waitFor(() =>
|
||||
expect(threadApi.appendMessage).toHaveBeenCalledWith(
|
||||
't-out-of-order',
|
||||
expect.objectContaining({ content: 'BetaAlpha', sender: 'agent' })
|
||||
't-missing',
|
||||
expect.objectContaining({ content: 'Part one.\n\nPart two.', sender: 'agent' })
|
||||
)
|
||||
);
|
||||
expect(threadApi.appendMessage).toHaveBeenCalledTimes(3);
|
||||
expect(threadApi.appendMessage).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('expires stale segment delivery state before chat_done reconciliation', async () => {
|
||||
|
||||
Reference in New Issue
Block a user