Compare commits

...

4 Commits

Author SHA1 Message Date
abeatrix 54ce026ec7 Merge branch 'bee/stream-text' of https://github.com/cline/cline into bee/stream-text 2026-02-12 23:50:59 -08:00
abeatrix c065c4f239 Remove .only 2026-02-12 23:50:41 -08:00
Saoud Rizwan 64f001004c Merge branch 'main' into bee/stream-text 2026-02-12 23:47:06 -08:00
abeatrix 19de9d6e7b fix: duplicate streamed say blocks by merging partials by type 2026-02-12 23:33:05 -08:00
3 changed files with 129 additions and 25 deletions
+101
View File
@@ -87,4 +87,105 @@ describe("Task.say", () => {
expect(updateClineMessage.called).to.equal(false)
expect(postStateToWebview.calledOnce).to.equal(true)
})
it("ignores stale partial reasoning after a completed reasoning message", async () => {
const addToClineMessages = sinon.stub().resolves()
const updateClineMessage = sinon.stub().resolves()
const postStateToWebview = sinon.stub().resolves()
const fakeTask: any = {
taskState: { abort: false, lastMessageTs: 0 },
getCurrentProviderInfo: () => ({
providerId: "minimax",
model: { id: "MiniMax-M2.1" },
mode: "act",
}),
messageStateHandler: {
getClineMessages: () => [
{
type: "say",
say: "reasoning",
text: "Completed reasoning",
partial: false,
ts: Date.now(),
},
],
addToClineMessages,
updateClineMessage,
},
postStateToWebview,
}
const result = await Task.prototype.say.call(
fakeTask,
"reasoning",
"Late stale reasoning chunk",
undefined,
undefined,
true,
)
expect(result).to.equal(undefined)
expect(addToClineMessages.called).to.equal(false)
expect(updateClineMessage.called).to.equal(false)
expect(postStateToWebview.called).to.equal(false)
})
it("completes an earlier partial text even when a different say type was added after it", async () => {
const addToClineMessages = sinon.stub().resolves()
const updateClineMessage = sinon.stub().resolves()
const postStateToWebview = sinon.stub().resolves()
const partialTextTs = Date.now()
const fakeTask: any = {
taskState: { abort: false, lastMessageTs: 0 },
getCurrentProviderInfo: () => ({
providerId: "minimax",
model: { id: "MiniMax-M2.1" },
mode: "act",
}),
messageStateHandler: {
getClineMessages: () => [
{
type: "say",
say: "text",
text: "Now let me read a few source files to understand the code structure better:",
partial: true,
ts: partialTextTs,
},
{
type: "say",
say: "reasoning",
text: "Now I have a good understanding.",
partial: true,
ts: partialTextTs + 1,
},
],
addToClineMessages,
updateClineMessage,
},
postStateToWebview,
}
const result = await Task.prototype.say.call(
fakeTask,
"text",
"Now let me read a few source files to understand the code structure better: Let me also analyze the dependencies between different modules.",
undefined,
undefined,
false,
)
expect(result).to.equal(undefined)
expect(updateClineMessage.calledOnce).to.equal(true)
expect(updateClineMessage.firstCall.args[0]).to.equal(0)
expect(updateClineMessage.firstCall.args[1]).to.deep.equal({
text: "Now let me read a few source files to understand the code structure better: Let me also analyze the dependencies between different modules.",
images: undefined,
files: undefined,
partial: false,
})
expect(addToClineMessages.called).to.equal(false)
expect(postStateToWebview.called).to.equal(false)
})
})
+27 -24
View File
@@ -728,32 +728,35 @@ export class Task {
}
if (partial !== undefined) {
const lastMessage = this.messageStateHandler.getClineMessages().at(-1)
const isUpdatingPreviousPartial =
lastMessage && lastMessage.partial && lastMessage.type === "say" && lastMessage.say === type
const clineMessages = this.messageStateHandler.getClineMessages()
const lastSameTypeSayIndex = findLastIndex(clineMessages, (message) => message.type === "say" && message.say === type)
const lastSameTypeSay = lastSameTypeSayIndex >= 0 ? clineMessages[lastSameTypeSayIndex] : undefined
const isUpdatingPreviousPartial = lastSameTypeSay?.partial === true
const isStaleReasoningPartial = partial && type === "reasoning" && lastSameTypeSay && !lastSameTypeSay.partial
if (isStaleReasoningPartial) {
return undefined
}
const isDuplicateCompletedTextSay =
partial &&
type === "text" &&
lastMessage &&
lastMessage.type === "say" &&
lastMessage.say === "text" &&
!lastMessage.partial &&
(lastMessage.text ?? "") === (text ?? "")
lastSameTypeSay &&
!lastSameTypeSay.partial &&
(lastSameTypeSay.text ?? "") === (text ?? "")
if (isDuplicateCompletedTextSay) {
return undefined
}
if (partial) {
if (isUpdatingPreviousPartial) {
// existing partial message, so update it
const lastIndex = this.messageStateHandler.getClineMessages().length - 1
await this.messageStateHandler.updateClineMessage(lastIndex, {
await this.messageStateHandler.updateClineMessage(lastSameTypeSayIndex, {
text,
images,
files,
partial,
})
const protoMessage = convertClineMessageToProto(lastMessage)
const updatedMessage = this.messageStateHandler.getClineMessages()[lastSameTypeSayIndex]
const protoMessage = convertClineMessageToProto(updatedMessage)
await sendPartialMessageEvent(protoMessage)
return undefined
}
@@ -776,10 +779,9 @@ export class Task {
// partial=false means its a complete version of a previously partial message
if (isUpdatingPreviousPartial) {
// this is the complete version of a previously partial message, so replace the partial with the complete version
this.taskState.lastMessageTs = lastMessage.ts
const lastIndex = this.messageStateHandler.getClineMessages().length - 1
this.taskState.lastMessageTs = lastSameTypeSay.ts
// updateClineMessage emits the change event and saves to disk
await this.messageStateHandler.updateClineMessage(lastIndex, {
await this.messageStateHandler.updateClineMessage(lastSameTypeSayIndex, {
text,
images,
files,
@@ -787,7 +789,8 @@ export class Task {
})
// await this.postStateToWebview()
const protoMessage = convertClineMessageToProto(lastMessage)
const updatedMessage = this.messageStateHandler.getClineMessages()[lastSameTypeSayIndex]
const protoMessage = convertClineMessageToProto(updatedMessage)
await sendPartialMessageEvent(protoMessage) // more performant than an entire postStateToWebview
return undefined
}
@@ -2694,12 +2697,6 @@ export class Task {
break
}
case "text": {
// If we have reasoning content, finalize it before processing text (only once)
const currentReasoning = reasonsHandler.getCurrentReasoning()
if (currentReasoning?.thinking && assistantMessage.length === 0) {
// Complete the reasoning message (only once)
await this.say("reasoning", currentReasoning.thinking, undefined, undefined, false)
}
if (chunk.signature) {
assistantTextSignature = chunk.signature
}
@@ -2937,6 +2934,13 @@ export class Task {
const partialToolBlocks = toolUseHandler.getPartialToolUsesAsContent()?.map((block) => ({ ...block, partial: false }))
await this.processNativeToolCalls(assistantTextOnly, partialToolBlocks)
// Finalize reasoning once the stream is done so providers that continue
// emitting reasoning after text don't downgrade the UI back to partial.
const finalReasoning = reasonsHandler.getCurrentReasoning()
if (finalReasoning?.thinking) {
await this.say("reasoning", finalReasoning.thinking, undefined, undefined, false)
}
if (partialBlocks.length > 0) {
await this.presentAssistantMessage() // if there is content to update then it will complete and update this.userMessageContentReady to true, which we pwaitfor before making the next request. all this is really doing is presenting the last partial message that we just set to complete
}
@@ -3249,8 +3253,7 @@ export class Task {
*/
private formatWorkspaceRootsSection(): string {
const multiRootEnabled = isMultiRootEnabled(this.stateManager)
const hasWorkspaceManager = !!this.workspaceManager
const roots = hasWorkspaceManager ? this.workspaceManager!.getRoots() : []
const roots = this.workspaceManager?.getRoots() || []
// Only show workspace roots if multi-root is enabled and there are multiple roots
if (!multiRootEnabled || roots.length <= 1) {
@@ -3267,7 +3270,7 @@ export class Task {
}
// Add primary workspace information
const primary = this.workspaceManager!.getPrimaryRoot()
const primary = this.workspaceManager?.getPrimaryRoot()
const primaryName = this.getPrimaryWorkspaceName(primary)
section += `\n\nPrimary workspace: ${primaryName}`
@@ -16,7 +16,7 @@ import { mockFetchForTesting } from "@/shared/net"
import { Logger } from "@/shared/services/Logger"
import { BannerService } from "../BannerService"
describe.only("BannerService", () => {
describe("BannerService", () => {
let sandbox: sinon.SinonSandbox
let mockFetch: sinon.SinonStub
let token: string | null = "fake-token"