Repository navigation
[Perf] Throttle partial message updates while streaming #2120
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4366,6 +4366,154 @@ describe("Cline", () => { | |
| }) | ||
| }) | ||
|
|
||
| describe("partial message update throttling", () => { | ||
| function createStreamingTask() { | ||
| // Task mutates one ClineMessage across deltas and finalization, so snapshot the | ||
| // fields of each messageUpdated post when it happens instead of keeping the reference. | ||
| const messageUpdates: Array<Pick<ClineMessage, "ts" | "text" | "partial">> = [] | ||
| vi.mocked(mockProvider.postMessageToWebview).mockImplementation(async (message) => { | ||
| if (message.type === "messageUpdated" && message.clineMessage) { | ||
| const { ts, text, partial } = message.clineMessage | ||
| messageUpdates.push({ ts, text, partial }) | ||
| } | ||
| }) | ||
|
|
||
| const task = new Task({ | ||
| provider: mockProvider, | ||
| apiConfiguration: mockApiConfig, | ||
| task: "test task", | ||
| startTask: false, | ||
| }) | ||
| vi.spyOn(getTaskTestAccess(task), "saveClineMessages").mockResolvedValue(true) | ||
| return { task, messageUpdates } | ||
| } | ||
|
|
||
| beforeEach(() => { | ||
| vi.useFakeTimers() | ||
| }) | ||
|
|
||
| afterEach(() => { | ||
| vi.useRealTimers() | ||
| }) | ||
|
|
||
| it("coalesces a burst of partial updates and delivers the latest text last", async () => { | ||
| const { task, messageUpdates } = createStreamingTask() | ||
|
|
||
| await task.say("text", "chunk 0", undefined, true) | ||
| for (let i = 1; i <= 50; i++) { | ||
| await task.say("text", `chunk ${i}`, undefined, true) | ||
| vi.advanceTimersByTime(5) | ||
| } | ||
| await vi.advanceTimersByTimeAsync(500) | ||
|
|
||
| // 50 deltas over ~250ms: far fewer than one post per delta. | ||
| expect(messageUpdates.length).toBeGreaterThan(0) | ||
| expect(messageUpdates.length).toBeLessThan(10) | ||
| expect(messageUpdates.at(-1)).toMatchObject({ text: "chunk 50", partial: true }) | ||
|
Comment on lines
+4410
to
+4412
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔎 Supported by static analysis
Assert both sides of the 120 ms boundary. The burst test checks only the post count and final text after timers drain. The 1,000 ms test covers only widely separated updates. Neither test proves that an update remains queued at 119 ms and that the latest snapshot posts at 120 ms. Add a focused fake-timer assertion for these boundary cases.
|
||
| }) | ||
|
|
||
| it("lets the finalized message through and drops the queued partial post", async () => { | ||
| const { task, messageUpdates } = createStreamingTask() | ||
|
|
||
| await task.say("text", "first", undefined, true) | ||
| await task.say("text", "second", undefined, true) | ||
| await task.say("text", "third", undefined, true) | ||
| // "third" is queued behind the throttle; finalizing must cancel that queued post. | ||
| expect(vi.getTimerCount()).toBe(1) | ||
| await task.say("text", "final text", undefined, false) | ||
| expect(vi.getTimerCount()).toBe(0) | ||
| await vi.advanceTimersByTimeAsync(500) | ||
|
|
||
| expect(messageUpdates.map(({ text, partial }) => ({ text, partial }))).toEqual([ | ||
| { text: "second", partial: true }, | ||
| { text: "final text", partial: false }, | ||
| ]) | ||
| }) | ||
|
|
||
| it("drops a queued partial ask update when the ask is finalized", async () => { | ||
| const { task, messageUpdates } = createStreamingTask() | ||
|
|
||
| // Partial asks always reject with AskIgnoredError. | ||
| await task.ask("tool", "first", true).catch(() => {}) | ||
| await task.ask("tool", "second", true).catch(() => {}) | ||
| await task.ask("tool", "third", true).catch(() => {}) | ||
| expect(task["pendingPartialMessageTimer"]).toBeDefined() | ||
|
|
||
| // A complete ask resolves only after a response, so leave it pending. Step 1ms per check | ||
| // so the final post lands well before the queued timer would fire on its own. | ||
| void task.ask("tool", "final", false).catch(() => {}) | ||
| await vi.waitFor(() => expect(messageUpdates.at(-1)?.partial).toBe(false), { interval: 1 }) | ||
| expect(task["pendingPartialMessageTimer"]).toBeUndefined() | ||
| await vi.advanceTimersByTimeAsync(500) | ||
|
|
||
| expect(messageUpdates.map(({ text, partial }) => ({ text, partial }))).toEqual([ | ||
| { text: "second", partial: true }, | ||
| { text: "final", partial: false }, | ||
| ]) | ||
| }) | ||
|
|
||
| it("drops a queued partial update when the task is disposed", async () => { | ||
| const { task, messageUpdates } = createStreamingTask() | ||
|
|
||
| await task.say("text", "first", undefined, true) | ||
| await task.say("text", "second", undefined, true) | ||
| await task.say("text", "third", undefined, true) | ||
| expect(task["pendingPartialMessageTimer"]).toBeDefined() | ||
|
|
||
| await task.dispose() | ||
| expect(task["pendingPartialMessageTimer"]).toBeUndefined() | ||
| await vi.advanceTimersByTimeAsync(500) | ||
|
|
||
| expect(messageUpdates.map(({ text }) => text)).toEqual(["second"]) | ||
| }) | ||
|
Comment on lines
+4415
to
+4468
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win Add required coverage for ask finalization and disposal. The test-file instruction requires regression coverage for relevant boundary and cleanup cases. The current suite covers queued-update cancellation only through 🤖 Prompt for AI Agents |
||
|
|
||
| it("holds a queued update until the 120ms interval elapses", async () => { | ||
| const { task, messageUpdates } = createStreamingTask() | ||
|
|
||
| await task.say("text", "first", undefined, true) | ||
| await task.say("text", "second", undefined, true) | ||
| await task.say("text", "third", undefined, true) | ||
| expect(messageUpdates.map(({ text }) => text)).toEqual(["second"]) | ||
|
|
||
| await vi.advanceTimersByTimeAsync(119) | ||
| expect(messageUpdates.map(({ text }) => text)).toEqual(["second"]) | ||
|
|
||
| await vi.advanceTimersByTimeAsync(1) | ||
| expect(messageUpdates.map(({ text }) => text)).toEqual(["second", "third"]) | ||
| }) | ||
|
|
||
| it("delivers a queued update for one message before updating a different message", async () => { | ||
| const { task, messageUpdates } = createStreamingTask() | ||
|
|
||
| await task.say("reasoning", "r1", undefined, true) | ||
| await task.say("reasoning", "r2", undefined, true) | ||
| await task.say("reasoning", "r3", undefined, true) | ||
| expect(messageUpdates.map(({ text }) => text)).toEqual(["r2"]) | ||
|
|
||
| // A new message starts, then streams: its update must not replace the queued "r3". | ||
| // Message timestamps are Date.now(), which fake timers freeze, so step the clock. | ||
| await vi.advanceTimersByTimeAsync(1) | ||
| await task.say("text", "t1", undefined, true) | ||
| await task.say("text", "t2", undefined, true) | ||
| expect(messageUpdates.map(({ text }) => text)).toEqual(["r2", "r3"]) | ||
|
|
||
| await vi.advanceTimersByTimeAsync(500) | ||
| expect(messageUpdates.map(({ text }) => text)).toEqual(["r2", "r3", "t2"]) | ||
| }) | ||
|
|
||
| it("posts updates that are far enough apart immediately", async () => { | ||
| const { task, messageUpdates } = createStreamingTask() | ||
|
|
||
| await task.say("text", "one", undefined, true) | ||
| await task.say("text", "two", undefined, true) | ||
| await vi.advanceTimersByTimeAsync(1000) | ||
| await task.say("text", "three", undefined, true) | ||
|
|
||
| // "one" creates the message; "two" and "three" are each the first update in their window. | ||
| expect(messageUpdates.map(({ text }) => text)).toEqual(["two", "three"]) | ||
| }) | ||
| }) | ||
|
|
||
| describe("abortTask", () => { | ||
| it("should set abort flag and emit TaskAborted event", async () => { | ||
| const task = new Task({ | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Preserve queued updates for distinct messages.
If a partial
say("reasoning")update is queued and a new partialsay("text")message starts, the text message's next update can replace the queued reasoning update.updateClineMessagethen emits noRooCodeEventName.Messageupdate for that reasoning value. Flush the pending update when the message timestamp changes, or track pending updates by message identity. As per path instructions, “Verify extension/webview contracts” and “Trace changed inputs through normal, boundary, error, cancellation, retry, and default paths.”Source: Path instructions