Bug description
Sometimes all assistant output in a chat appears twice, strictly interleaved chunk-by-chunk:
TheThe roaring roaring run run died died with with the the session session close close;; I'll I'll restart restart it it …
Tool labels and "Thinking" headings are doubled as well. The strict alternation (delta1, delta1, delta2, delta2, …) indicates two consumers appending the same delta stream concurrently.
Root cause
KimiRuntime.openSession / attachResumedSession check this.sessions.get(id) and only then await several RPCs (resumeSession, metadata updates, detachView, …) before wrapSession registers the runtime in the map. Two concurrent calls for the same session id (sidebar + editor panel opening at once, or a window reload overlapping a reattach) both see an empty map, both resume, and both call wrapSession, which unconditionally sessions.set(session.id, runtime) — the later runtime overwrites the earlier one in the map, but the earlier SessionRuntime is never closed: its session.onEvent listener (and approval/question handler registrations, which are keyed by session id in the SDK rpc layer) stay live.
Both runtimes then adapt and broadcast every SDK event. When the same webviewId is subscribed in both (both racing calls subscribe it, or it re-attaches later), the webview's appendOrCreate concatenates both copies into the same item — producing the interleaved duplication. The webview store has no id-based dedup, so nothing downstream catches it.
Reproduction
Timing-dependent in production, but deterministic in a unit test: two concurrent openSession calls with the same sessionId create two SessionRuntimes (observable via the session's onEvent subscription count = 2), and one emitted assistant.delta produces two ContentPart broadcasts per view.
Suggested fix
wrapSession should return the existing runtime when this.sessions already has one for the session id. A resumed Session handle is inert until wrapped (its constructor registers nothing — listeners and approval handlers are only installed by the SessionRuntime constructor), so the loser's handle can simply be dropped; the shared engine session must NOT be closed (that would kill it for the winning runtime too).
Bug description
Sometimes all assistant output in a chat appears twice, strictly interleaved chunk-by-chunk:
Tool labels and "Thinking" headings are doubled as well. The strict alternation (delta1, delta1, delta2, delta2, …) indicates two consumers appending the same delta stream concurrently.
Root cause
KimiRuntime.openSession/attachResumedSessioncheckthis.sessions.get(id)and only thenawaitseveral RPCs (resumeSession, metadata updates,detachView, …) beforewrapSessionregisters the runtime in the map. Two concurrent calls for the same session id (sidebar + editor panel opening at once, or a window reload overlapping a reattach) both see an empty map, both resume, and both callwrapSession, which unconditionallysessions.set(session.id, runtime)— the later runtime overwrites the earlier one in the map, but the earlierSessionRuntimeis never closed: itssession.onEventlistener (and approval/question handler registrations, which are keyed by session id in the SDK rpc layer) stay live.Both runtimes then adapt and broadcast every SDK event. When the same webviewId is subscribed in both (both racing calls subscribe it, or it re-attaches later), the webview's
appendOrCreateconcatenates both copies into the same item — producing the interleaved duplication. The webview store has no id-based dedup, so nothing downstream catches it.Reproduction
Timing-dependent in production, but deterministic in a unit test: two concurrent
openSessioncalls with the samesessionIdcreate twoSessionRuntimes (observable via the session'sonEventsubscription count = 2), and one emittedassistant.deltaproduces twoContentPartbroadcasts per view.Suggested fix
wrapSessionshould return the existing runtime whenthis.sessionsalready has one for the session id. A resumedSessionhandle is inert until wrapped (its constructor registers nothing — listeners and approval handlers are only installed by theSessionRuntimeconstructor), so the loser's handle can simply be dropped; the shared engine session must NOT be closed (that would kill it for the winning runtime too).