From 947e62ac8d4e8a1f81133e033f98032e0f426e72 Mon Sep 17 00:00:00 2001 From: Christian Geier Date: Sun, 19 Apr 2026 00:16:42 +0200 Subject: [PATCH 1/7] fix: reconcile tool-part freshness during session sync --- app-prefixable/src/context/sync.tsx | 83 +++++++++++++++++++++++++---- 1 file changed, 73 insertions(+), 10 deletions(-) diff --git a/app-prefixable/src/context/sync.tsx b/app-prefixable/src/context/sync.tsx index 0b303b00..b84641fc 100644 --- a/app-prefixable/src/context/sync.tsx +++ b/app-prefixable/src/context/sync.tsx @@ -58,6 +58,70 @@ function sortParts(parts: Part[]): Part[] { return [...withId, ...withoutId] } +function toolEnd(part: Extract): number { + const state = part.state + if (state.status === "completed") return state.time.end + if (state.status === "error") return state.time.end + return 0 +} + +function toolStart(part: Extract): number { + const state = part.state + if (state.status === "running") return state.time.start + if (state.status === "completed") return state.time.start + if (state.status === "error") return state.time.start + return 0 +} + +function toolRank(part: Extract): number { + const state = part.state + if (state.status === "pending") return 1 + if (state.status === "running") return 2 + return 3 +} + +function mergePart(existing: Part, synced: Part): Part { + if (existing.type !== "tool") return synced + if (synced.type !== "tool") return synced + + const existingEnd = toolEnd(existing) + const syncedEnd = toolEnd(synced) + if (existingEnd > syncedEnd) return existing + if (syncedEnd > existingEnd) return synced + + const existingStart = toolStart(existing) + const syncedStart = toolStart(synced) + if (existingStart > syncedStart) return existing + if (syncedStart > existingStart) return synced + + const existingRank = toolRank(existing) + const syncedRank = toolRank(synced) + if (existingRank > syncedRank) return existing + if (syncedRank > existingRank) return synced + + return synced +} + +function mergeMessage(existing: MessageWithParts, synced: MessageWithParts): MessageWithParts { + const map = new Map(existing.parts.map((part) => [part.id, part])) + const merged = synced.parts.map((part) => { + const current = map.get(part.id) + if (!current) return part + return mergePart(current, part) + }) + const ids = new Set(merged.map((part) => part.id)) + + for (const part of existing.parts) { + if (ids.has(part.id)) continue + merged.push(part) + } + + return { + info: synced.info, + parts: sortParts(merged), + } +} + function errorText(err: unknown) { if (err instanceof Error && err.message.trim()) return err.message return "Failed to bootstrap app state from API." @@ -405,19 +469,18 @@ export function SyncProvider(props: ParentProps) { setStore("message", sessionID, (existing: MessageWithParts[]) => { if (!existing || existing.length === 0) return synced - // Merge: use existing message if it has more recent parts - const merged = synced.map((s) => { - const e = existing.find((m) => m.info.id === s.info.id) - if (!e) return s - // Keep existing if it has more parts (SSE updates arrived) - return e.parts.length >= s.parts.length ? e : s + const map = new Map(existing.map((msg) => [msg.info.id, msg])) + const merged = synced.map((msg) => { + const current = map.get(msg.info.id) + if (!current) return msg + return mergeMessage(current, msg) }) + const ids = new Set(merged.map((msg) => msg.info.id)) // Add any messages from existing that aren't in synced (new SSE messages) - for (const e of existing) { - if (!merged.find((m) => m.info.id === e.info.id)) { - merged.push(e) - } + for (const msg of existing) { + if (ids.has(msg.info.id)) continue + merged.push(msg) } return merged.sort((a, b) => cmp(a.info.id, b.info.id)) From 1bd9e587c4b6422ccb54892a1792e38229048bef Mon Sep 17 00:00:00 2001 From: Christian Geier Date: Mon, 20 Apr 2026 17:45:19 +0200 Subject: [PATCH 2/7] fix: address Copilot review comments --- app-prefixable/src/context/sync.tsx | 46 ++++++++++++++++++++++++----- 1 file changed, 38 insertions(+), 8 deletions(-) diff --git a/app-prefixable/src/context/sync.tsx b/app-prefixable/src/context/sync.tsx index b84641fc..e9225830 100644 --- a/app-prefixable/src/context/sync.tsx +++ b/app-prefixable/src/context/sync.tsx @@ -310,17 +310,34 @@ export function SyncProvider(props: ParentProps) { setStore("message", session.id, reconcile([])) } + function isNewer(a: Part, b: Part): boolean { + const aEnd = a.time?.completed ?? a.time?.start + const bEnd = b.time?.completed ?? b.time?.start + if (!aEnd) return false + if (!bEnd) return true + return aEnd > bEnd + } + // Message part events - the main real-time update mechanism if (event.type === "message.part.updated") { const part = props.part as Part if (!part?.sessionID || !part?.messageID) return - // Update or insert the part + const hasId = !!part.id setStore("part", part.messageID, (existing: Part[] | undefined) => { if (!existing) return sortParts([part]) - const idx = existing.findIndex((p) => p.id === part.id) - if (idx === -1) return sortParts([...existing, part]) - return existing.map((p, i) => (i === idx ? part : p)) + + if (hasId) { + const idx = existing.findIndex((p) => p.id === part.id) + if (idx === -1) return sortParts([...existing, part]) + const existingPart = existing[idx] + if (!existingPart.id || isNewer(existingPart, part)) return existing + return existing.map((p, i) => (i === idx ? part : p)) + } + + const noIdParts = existing.filter((p) => !p.id) + const withIdParts = existing.filter((p) => !!p.id) + return sortParts([...withIdParts, ...noIdParts, part]) }) // Update parts in existing messages only - don't synthesize messages from parts @@ -333,8 +350,13 @@ export function SyncProvider(props: ParentProps) { // Update existing message parts return msgs.map((m, i) => { if (i !== msgIdx) return m - const partIdx = m.parts.findIndex((p) => p.id === part.id) - const newParts = partIdx === -1 ? [...m.parts, part] : m.parts.map((p, pi) => (pi === partIdx ? part : p)) + if (hasId) { + const partIdx = m.parts.findIndex((p) => p.id === part.id) + const newParts = partIdx === -1 ? [...m.parts, part] : m.parts.map((p, pi) => (pi === partIdx ? part : p)) + return { ...m, parts: newParts } + } + return { ...m, parts: [...m.parts, part] } + }) return { ...m, parts: newParts } }) }) @@ -370,9 +392,17 @@ export function SyncProvider(props: ParentProps) { if (!existing || existing.length === 0) return existing return existing.map((m) => { if (m.info.id !== info.id) return m - // Merge info and optionally update parts if provided + // Prefer info with newer time.completed + const mergedInfo = + m.info.time?.completed && info.time?.completed + ? m.info.time.completed > info.time.completed + ? m.info + : info + : info.time?.completed + ? info + : m.info const updatedParts = parts ? sortParts(parts) : m.parts - return { info, parts: updatedParts } + return { info: mergedInfo, parts: updatedParts } }) }) From 5d4a9e29f63703e7b7489755616591c94dec6d7b Mon Sep 17 00:00:00 2001 From: Christian Geier Date: Mon, 20 Apr 2026 17:53:13 +0200 Subject: [PATCH 3/7] fix: address second round Copilot review comments --- app-prefixable/src/context/sync.tsx | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/app-prefixable/src/context/sync.tsx b/app-prefixable/src/context/sync.tsx index e9225830..e685daa2 100644 --- a/app-prefixable/src/context/sync.tsx +++ b/app-prefixable/src/context/sync.tsx @@ -1,6 +1,6 @@ import { createContext, useContext, onCleanup, batch, type ParentProps } from "solid-js" import { createStore, reconcile, produce } from "solid-js/store" -import type { Session, Message, Part, Provider } from "../sdk/client" +import type { Session, Message, Part, Provider, TextPart } from "../sdk/client" import { useSDK } from "./sdk" import { useServer } from "./server" import { createSSEParser } from "../utils/sse" @@ -81,7 +81,17 @@ function toolRank(part: Extract): number { } function mergePart(existing: Part, synced: Part): Part { - if (existing.type !== "tool") return synced + if (existing.type !== "tool") { + if (synced.type !== "tool") { + const existingTime = (existing as TextPart).time + const syncedTime = (synced as TextPart).time + const existingEnd = existingTime?.end ?? existingTime?.start ?? 0 + const syncedEnd = syncedTime?.end ?? syncedTime?.start ?? 0 + if (existingEnd > syncedEnd) return existing + return synced + } + return synced + } if (synced.type !== "tool") return synced const existingEnd = toolEnd(existing) @@ -311,8 +321,11 @@ export function SyncProvider(props: ParentProps) { } function isNewer(a: Part, b: Part): boolean { - const aEnd = a.time?.completed ?? a.time?.start - const bEnd = b.time?.completed ?? b.time?.start + if (a.type !== "tool" || b.type !== "tool") return false + const aState = a.state as ToolPart["state"] + const bState = b.state as ToolPart["state"] + const aEnd = aState.time?.end ?? aState.time?.start + const bEnd = bState.time?.end ?? bState.time?.start if (!aEnd) return false if (!bEnd) return true return aEnd > bEnd @@ -357,8 +370,7 @@ export function SyncProvider(props: ParentProps) { } return { ...m, parts: [...m.parts, part] } }) - return { ...m, parts: newParts } - }) + return { ...m, parts: newParts } }) } From 1ad13355c954af199e21802969ac810197e82b52 Mon Sep 17 00:00:00 2001 From: Christian Geier Date: Mon, 20 Apr 2026 18:01:14 +0200 Subject: [PATCH 4/7] fix: resolve Copilot review comments (syntax error, missing IDs, type) --- app-prefixable/src/context/sync.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app-prefixable/src/context/sync.tsx b/app-prefixable/src/context/sync.tsx index e685daa2..db08c86b 100644 --- a/app-prefixable/src/context/sync.tsx +++ b/app-prefixable/src/context/sync.tsx @@ -113,15 +113,16 @@ function mergePart(existing: Part, synced: Part): Part { } function mergeMessage(existing: MessageWithParts, synced: MessageWithParts): MessageWithParts { - const map = new Map(existing.parts.map((part) => [part.id, part])) + const existingWithId = existing.parts.filter((p) => !!p.id) + const map = new Map(existingWithId.map((part) => [part.id, part])) const merged = synced.parts.map((part) => { const current = map.get(part.id) if (!current) return part return mergePart(current, part) }) - const ids = new Set(merged.map((part) => part.id)) + const ids = new Set(merged.filter((p) => !!p.id).map((part) => part.id)) - for (const part of existing.parts) { + for (const part of existingWithId) { if (ids.has(part.id)) continue merged.push(part) } @@ -322,8 +323,8 @@ export function SyncProvider(props: ParentProps) { function isNewer(a: Part, b: Part): boolean { if (a.type !== "tool" || b.type !== "tool") return false - const aState = a.state as ToolPart["state"] - const bState = b.state as ToolPart["state"] + const aState = a.state as Extract["state"] + const bState = b.state as Extract["state"] const aEnd = aState.time?.end ?? aState.time?.start const bEnd = bState.time?.end ?? bState.time?.start if (!aEnd) return false @@ -370,7 +371,6 @@ export function SyncProvider(props: ParentProps) { } return { ...m, parts: [...m.parts, part] } }) - return { ...m, parts: newParts } }) } From cbffa535042311c136f8f56c2405fe93f5e7fcdc Mon Sep 17 00:00:00 2001 From: Christian Geier Date: Mon, 20 Apr 2026 18:08:31 +0200 Subject: [PATCH 5/7] fix: narrow mergePart by part.type and preserve id-less parts in mergeMessage (#3112022389, #3112022429, #3112022489) --- app-prefixable/src/context/sync.tsx | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/app-prefixable/src/context/sync.tsx b/app-prefixable/src/context/sync.tsx index db08c86b..5a62fe95 100644 --- a/app-prefixable/src/context/sync.tsx +++ b/app-prefixable/src/context/sync.tsx @@ -83,10 +83,18 @@ function toolRank(part: Extract): number { function mergePart(existing: Part, synced: Part): Part { if (existing.type !== "tool") { if (synced.type !== "tool") { - const existingTime = (existing as TextPart).time - const syncedTime = (synced as TextPart).time - const existingEnd = existingTime?.end ?? existingTime?.start ?? 0 - const syncedEnd = syncedTime?.end ?? syncedTime?.start ?? 0 + const getTimeValue = (part: Part): number => { + if (part.type === "text" || part.type === "reasoning") { + const t = (part as TextPart).time + return t?.end ?? t?.start ?? 0 + } + if (part.type === "retry") { + return (part as { time?: { created?: number } }).time?.created ?? 0 + } + return 0 + } + const existingEnd = getTimeValue(existing) + const syncedEnd = getTimeValue(synced) if (existingEnd > syncedEnd) return existing return synced } @@ -114,6 +122,7 @@ function mergePart(existing: Part, synced: Part): Part { function mergeMessage(existing: MessageWithParts, synced: MessageWithParts): MessageWithParts { const existingWithId = existing.parts.filter((p) => !!p.id) + const existingWithoutId = existing.parts.filter((p) => !p.id) const map = new Map(existingWithId.map((part) => [part.id, part])) const merged = synced.parts.map((part) => { const current = map.get(part.id) @@ -127,6 +136,10 @@ function mergeMessage(existing: MessageWithParts, synced: MessageWithParts): Mes merged.push(part) } + for (const part of existingWithoutId) { + merged.push(part) + } + return { info: synced.info, parts: sortParts(merged), @@ -366,6 +379,10 @@ export function SyncProvider(props: ParentProps) { if (i !== msgIdx) return m if (hasId) { const partIdx = m.parts.findIndex((p) => p.id === part.id) + if (partIdx !== -1) { + const existingPart = m.parts[partIdx] + if (!existingPart.id || isNewer(existingPart, part)) return m + } const newParts = partIdx === -1 ? [...m.parts, part] : m.parts.map((p, pi) => (pi === partIdx ? part : p)) return { ...m, parts: newParts } } From d164a9570606dbb68e4233c230469fe1eecca1d3 Mon Sep 17 00:00:00 2001 From: Christian Geier Date: Mon, 20 Apr 2026 18:19:09 +0200 Subject: [PATCH 6/7] fix: prefer existing when freshness signals are equal in mergePart --- app-prefixable/src/context/sync.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app-prefixable/src/context/sync.tsx b/app-prefixable/src/context/sync.tsx index 5a62fe95..856fc5fd 100644 --- a/app-prefixable/src/context/sync.tsx +++ b/app-prefixable/src/context/sync.tsx @@ -96,7 +96,8 @@ function mergePart(existing: Part, synced: Part): Part { const existingEnd = getTimeValue(existing) const syncedEnd = getTimeValue(synced) if (existingEnd > syncedEnd) return existing - return synced + if (syncedEnd > existingEnd) return synced + return existing } return synced } @@ -117,7 +118,7 @@ function mergePart(existing: Part, synced: Part): Part { if (existingRank > syncedRank) return existing if (syncedRank > existingRank) return synced - return synced + return existing } function mergeMessage(existing: MessageWithParts, synced: MessageWithParts): MessageWithParts { From 92b2e2a84b28b51eef3772b76da3f8eebd05b0c6 Mon Sep 17 00:00:00 2001 From: Christian Geier Date: Tue, 21 Apr 2026 13:26:26 +0200 Subject: [PATCH 7/7] fix: guard time access in isNewer for pending tool states --- app-prefixable/src/context/sync.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/app-prefixable/src/context/sync.tsx b/app-prefixable/src/context/sync.tsx index 856fc5fd..4255ea40 100644 --- a/app-prefixable/src/context/sync.tsx +++ b/app-prefixable/src/context/sync.tsx @@ -339,6 +339,7 @@ export function SyncProvider(props: ParentProps) { if (a.type !== "tool" || b.type !== "tool") return false const aState = a.state as Extract["state"] const bState = b.state as Extract["state"] + if (aState.status === "pending" || bState.status === "pending") return false const aEnd = aState.time?.end ?? aState.time?.start const bEnd = bState.time?.end ?? bState.time?.start if (!aEnd) return false