diff --git a/.changeset/isolated-reasoning-streams.md b/.changeset/isolated-reasoning-streams.md new file mode 100644 index 0000000000..10d21a5345 --- /dev/null +++ b/.changeset/isolated-reasoning-streams.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Prevent duplicate reasoning and response text while subagent sessions are open. diff --git a/packages/kilo-vscode/tests/unit/session-parts.test.ts b/packages/kilo-vscode/tests/unit/session-parts.test.ts index 16cebaf7ac..1c951eb8da 100644 --- a/packages/kilo-vscode/tests/unit/session-parts.test.ts +++ b/packages/kilo-vscode/tests/unit/session-parts.test.ts @@ -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", () => { diff --git a/packages/kilo-vscode/webview-ui/src/context/session-parts.ts b/packages/kilo-vscode/webview-ui/src/context/session-parts.ts index 883a2b7148..b025feabd7 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session-parts.ts +++ b/packages/kilo-vscode/webview-ui/src/context/session-parts.ts @@ -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 { 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, 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 } } diff --git a/packages/kilo-vscode/webview-ui/src/context/session.tsx b/packages/kilo-vscode/webview-ui/src/context/session.tsx index 64c1753c5f..54841acdae 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/session.tsx @@ -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)