Skip to content

Commit cfe142e

Browse files
authored
fix(vscode-ide-companion): preserve split stream ordering (#3450)
1 parent 60a6dfc commit cfe142e

2 files changed

Lines changed: 111 additions & 4 deletions

File tree

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Qwen Team
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
/** @vitest-environment jsdom */
8+
9+
import { act } from 'react';
10+
import { createRoot, type Root } from 'react-dom/client';
11+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
12+
import { useMessageHandling, type TextMessage } from './useMessageHandling.js';
13+
14+
type MessageHandlingApi = ReturnType<typeof useMessageHandling>;
15+
16+
function renderHookHarness() {
17+
const container = document.createElement('div');
18+
document.body.appendChild(container);
19+
const root = createRoot(container);
20+
21+
let latestApi: MessageHandlingApi | null = null;
22+
23+
function Harness() {
24+
latestApi = useMessageHandling();
25+
return null;
26+
}
27+
28+
act(() => {
29+
root.render(<Harness />);
30+
});
31+
32+
return {
33+
container,
34+
root,
35+
get api(): MessageHandlingApi {
36+
if (!latestApi) {
37+
throw new Error('Hook API is not available');
38+
}
39+
return latestApi;
40+
},
41+
};
42+
}
43+
44+
describe('useMessageHandling', () => {
45+
let root: Root | null = null;
46+
let container: HTMLDivElement | null = null;
47+
48+
beforeEach(() => {
49+
(
50+
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
51+
).IS_REACT_ACT_ENVIRONMENT = true;
52+
});
53+
54+
afterEach(() => {
55+
if (root) {
56+
act(() => {
57+
root?.unmount();
58+
});
59+
root = null;
60+
}
61+
if (container) {
62+
container.remove();
63+
container = null;
64+
}
65+
});
66+
67+
it('keeps the original stream timestamp when a tool call splits one assistant reply into multiple segments', () => {
68+
const rendered = renderHookHarness();
69+
root = rendered.root;
70+
container = rendered.container;
71+
72+
act(() => {
73+
rendered.api.startStreaming(1_000);
74+
});
75+
76+
act(() => {
77+
rendered.api.appendStreamChunk('before tool call');
78+
});
79+
80+
act(() => {
81+
rendered.api.breakAssistantSegment();
82+
});
83+
84+
act(() => {
85+
rendered.api.appendStreamChunk('after tool call');
86+
});
87+
88+
const assistantMessages = rendered.api.messages.filter(
89+
(message): message is TextMessage => message.role === 'assistant',
90+
);
91+
92+
expect(assistantMessages).toHaveLength(2);
93+
expect(assistantMessages.map((message) => message.timestamp)).toEqual([
94+
1_000, 1_000,
95+
]);
96+
});
97+
});

packages/vscode-ide-companion/src/webview/hooks/message/useMessageHandling.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ export const useMessageHandling = () => {
3535
const streamingMessageIndexRef = useRef<number | null>(null);
3636
// Track the index of the current aggregated thinking message
3737
const thinkingMessageIndexRef = useRef<number | null>(null);
38+
// Preserve one stable timestamp for all message segments in the same turn.
39+
const currentStreamTimestampRef = useRef<number | null>(null);
3840

3941
/**
4042
* Add message
@@ -54,6 +56,9 @@ export const useMessageHandling = () => {
5456
* Start streaming response
5557
*/
5658
const startStreaming = useCallback((timestamp?: number) => {
59+
const resolvedTimestamp =
60+
typeof timestamp === 'number' ? timestamp : Date.now();
61+
currentStreamTimestampRef.current = resolvedTimestamp;
5762
// Create an assistant placeholder message immediately so tool calls won't jump before it
5863
setMessages((prev) => {
5964
// Record index of the placeholder to update on chunks
@@ -63,8 +68,8 @@ export const useMessageHandling = () => {
6368
{
6469
role: 'assistant',
6570
content: '',
66-
// Use provided timestamp (from extension) to keep ordering stable
67-
timestamp: typeof timestamp === 'number' ? timestamp : Date.now(),
71+
// Use one stable turn timestamp so later split segments sort correctly.
72+
timestamp: resolvedTimestamp,
6873
},
6974
];
7075
});
@@ -89,7 +94,11 @@ export const useMessageHandling = () => {
8994
if (idx === null) {
9095
idx = next.length;
9196
streamingMessageIndexRef.current = idx;
92-
next.push({ role: 'assistant', content: '', timestamp: Date.now() });
97+
next.push({
98+
role: 'assistant',
99+
content: '',
100+
timestamp: currentStreamTimestampRef.current ?? Date.now(),
101+
});
93102
}
94103

95104
if (idx < 0 || idx >= next.length) {
@@ -122,6 +131,7 @@ export const useMessageHandling = () => {
122131
setIsStreaming(false);
123132
streamingMessageIndexRef.current = null;
124133
thinkingMessageIndexRef.current = null;
134+
currentStreamTimestampRef.current = null;
125135
}, []);
126136

127137
/**
@@ -173,7 +183,7 @@ export const useMessageHandling = () => {
173183
assistantIdx >= 0 &&
174184
assistantIdx < next.length
175185
? next[assistantIdx].timestamp
176-
: Date.now();
186+
: (currentStreamTimestampRef.current ?? Date.now());
177187
next.push({
178188
role: 'thinking',
179189
content: '',

0 commit comments

Comments
 (0)