Merge pull request #13430 from Kilo-Org/investigate-duplicate-sse-rendering

fix(vscode): prevent duplicate reasoning with subagent inspectors
This commit is contained in:
Marius
2026-08-25 18:04:48 +02:00
committed by GitHub
4 changed files with 77 additions and 8 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Prevent duplicate reasoning and response text while subagent sessions are open.
@@ -1,5 +1,6 @@
import { describe, expect, it } from "bun:test"
import { mergeOptimisticPart, mergeParts, sameParts } from "../../webview-ui/src/context/session-parts"
import { createStore, produce } from "solid-js/store"
import { isolate, mergeOptimisticPart, mergeParts, sameParts } from "../../webview-ui/src/context/session-parts"
import type { Part } from "../../webview-ui/src/types/messages"
function text(id: string, value: string, time: { start?: number; end?: number } = {}): Part {
@@ -20,6 +21,39 @@ function value(parts: Part[], id: string) {
return part.text
}
describe("isolate", () => {
it("keeps shared reasoning snapshots independent across session stores", () => {
const shared = {
id: "r1",
messageID: "m1",
type: "reasoning",
text: "Thinking",
time: { start: 1 },
} satisfies Part
const [first, setFirst] = createStore({ parts: [shared].map(isolate) })
const [second, setSecond] = createStore({ parts: [shared].map(isolate) })
setFirst(
"parts",
produce((parts) => {
const part = parts[0]
if (part?.type === "reasoning") part.text += " once"
}),
)
setSecond(
"parts",
produce((parts) => {
const part = parts[0]
if (part?.type === "reasoning") part.text += " once"
}),
)
expect(first.parts[0]?.type === "reasoning" && first.parts[0].text).toBe("Thinking once")
expect(second.parts[0]?.type === "reasoning" && second.parts[0].text).toBe("Thinking once")
expect(shared.text).toBe("Thinking")
})
})
describe("mergeParts", () => {
it("keeps a final streamed tail part created after the reconcile snapshot started", () => {
const parts = mergeParts(
@@ -120,6 +154,31 @@ describe("mergeOptimisticPart", () => {
expect(result.parts.map((part) => part.id)).toEqual(["client-text", "server-file"])
expect(result.replaced).toBe("client-file")
})
it("keeps streamed deltas independent across session stores", () => {
const shared = text("server", "start")
const [first, setFirst] = createStore({ parts: mergeOptimisticPart([], new Set(), shared).parts })
const [second, setSecond] = createStore({ parts: mergeOptimisticPart([], new Set(), shared).parts })
setFirst(
"parts",
produce((parts) => {
const part = parts[0]
if (part?.type === "text") part.text += " chunk"
}),
)
setSecond(
"parts",
produce((parts) => {
const part = parts[0]
if (part?.type === "text") part.text += " chunk"
}),
)
expect(value(first.parts, "server")).toBe("start chunk")
expect(value(second.parts, "server")).toBe("start chunk")
expect(value([shared], "server")).toBe("start")
})
})
describe("sameParts", () => {
@@ -1,5 +1,9 @@
import type { Part } from "../types/messages"
export function isolate(part: Part): Part {
return { ...part }
}
function stream(part: Part): part is Extract<Part, { type: "text" | "reasoning" }> {
return part.type === "text" || part.type === "reasoning"
}
@@ -27,10 +31,11 @@ export function sameParts(local: Part[] = [], snapshot: Part[] = []): boolean {
export function mergeOptimisticPart(current: Part[], ids: ReadonlySet<string>, part: Part) {
const index = current.findIndex((item) => ids.has(item.id) && item.type === part.type)
if (index < 0) return { parts: [...current, part] }
const copy = isolate(part)
if (index < 0) return { parts: [...current, copy] }
const old = current[index]!
const next = current.slice()
next[index] = part
next[index] = copy
return { parts: next, replaced: old.id }
}
@@ -79,7 +79,7 @@ import { getAgentModel } from "./session-model-store"
import { resolveMessagePrefs } from "./session-preferences"
import { errorIDs, preserveSessionErrors, withoutResolvedSessionErrors } from "./session-errors"
import { PartStash } from "./part-stash"
import { mergeOptimisticPart, mergeParts } from "./session-parts"
import { isolate, mergeOptimisticPart, mergeParts } from "./session-parts"
import { mergeMessages, sameReconcileShape } from "./session-merge"
import { state as todoState } from "./todo-revert"
import { sessionVariantKeys, transferVariants, variantKey } from "./session-variant-store"
@@ -1457,7 +1457,7 @@ export const SessionProvider: ParentComponent = (props) => {
const cutoff = Math.max(0, messages.length - 15)
for (let i = 0; i < messages.length; i++) {
const msg = messages[i]!
const parts = msg.parts ?? []
const parts = msg.parts?.map(isolate) ?? []
if (mode === "reconcile" && store.parts[msg.id] && !optimisticParts.has(msg.id)) {
const merged = mergeParts(store.parts[msg.id], parts, input.since ?? Number.POSITIVE_INFINITY)
setStore("parts", msg.id, reconcile(merged, { key: "id" }))
@@ -1546,7 +1546,7 @@ export const SessionProvider: ParentComponent = (props) => {
if (message.parts && message.parts.length > 0) {
optimisticParts.delete(message.id)
stash.remove(message.id)
setStore("parts", message.id, message.parts)
setStore("parts", message.id, message.parts.map(isolate))
}
rebuildToolParts(message.sessionID, store.messages[message.sessionID] ?? [])
}
@@ -1624,7 +1624,7 @@ export const SessionProvider: ParentComponent = (props) => {
}
} else {
// Add new part
list.push(part)
list.push(isolate(part))
}
}),
)
@@ -2067,7 +2067,7 @@ export const SessionProvider: ParentComponent = (props) => {
setStore("messages", key, messages)
for (const msg of messages) {
if (msg.parts && msg.parts.length > 0) {
setStore("parts", msg.id, msg.parts)
setStore("parts", msg.id, msg.parts.map(isolate))
}
}
rebuildToolParts(key, messages)