@@ -171,9 +171,21 @@ internal class MessageStore {
171171 * (e.g. a tool going running -> completed) stayed stale until app restart. */
172172 private val streamedSincePivot = mutableSetOf<String>()
173173
174− /** Mark the point a reconnect re-seed's REST fetch begins: parts streamed from here on
175− * are newer than the fetched snapshot. Call under the same lock as [seed]/[reduce]. */
176− fun beginReseed() { streamedSincePivot.clear() }
174+ /** Message ids whose `info` was updated via a live SSE [reduce] since the last reseed
175+ * pivot ([beginReseed]) or seed. Parallels [streamedSincePivot] but for message info:
176+ * on a reconnect re-seed these are the messages whose info changed *during* the REST
177+ * fetch, so the in-memory info is newer than the snapshot and must win; every other
178+ * message's info is taken from the authoritative REST snapshot. Without this the merge
179+ * kept the in-memory info, so a run that finished server-side during the disconnect
180+ * (e.g. cost/token totals, completion time) stayed stale until app restart. */
181+ private val messageInfoUpdatedSincePivot = mutableSetOf<String>()
182+
183+ /** Mark the point a reconnect re-seed's REST fetch begins: parts/info streamed from here
184+ * on are newer than the fetched snapshot. Call under the same lock as [seed]/[reduce]. */
185+ fun beginReseed() {
186+ streamedSincePivot.clear()
187+ messageInfoUpdatedSincePivot.clear()
188+ }
177189
178190 /** Monotonic counter for synthetic UnknownMessage keys, avoiding nanoTime collisions. */
179191 private val unknownCounter = java.util.concurrent.atomic.AtomicLong(0)
@@ -220,7 +232,11 @@ internal class MessageStore {
220232 for (p in existing.parts) {
221233 if (p.id !in snapshotIds) ordered.add(p)
222234 }
223− val info = if (existing.info is UnknownMessage) m.info else existing.info
235+ // Adopt the authoritative REST info unless the in-memory info was itself
236+ // updated live since the pivot (i.e. during this fetch), in which case it's
237+ // newer than the snapshot. Otherwise the REST snapshot wins, discarding
238+ // in-memory info that went stale during a disconnect.
239+ val info = if (existing.info is UnknownMessage || m.info.id !in messageInfoUpdatedSincePivot) m.info else existing.info
224240 val merged = MessageWithParts(info = info, parts = ordered)
225241 if (merged != existing) {
226242 messages[m.info.id] = merged
@@ -229,9 +245,10 @@ internal class MessageStore {
229245 }
230246 }
231247 if (evictOldMessages()) changed = true
232− // Reset the pivot: everything just merged is now the baseline, so only parts that
233− // stream in *after* this seed can outrank the next reconnect's snapshot.
248+ // Reset the pivot: everything just merged is now the baseline, so only parts/info
249+ // that stream in *after* this seed can outrank the next reconnect's snapshot.
234250 streamedSincePivot.clear()
251+ messageInfoUpdatedSincePivot.clear()
235252 return changed
236253 }
237254
@@ -254,6 +271,10 @@ internal class MessageStore {
254271 }
255272 val existing = messages[key]
256273 messages[key] = existing?.copy(info = info) ?: MessageWithParts(info)
274+ // Record that this message's info streamed in live, so a re-seed after a
275+ // reconnect knows it's newer than the REST snapshot and keeps it (see
276+ // seed()/beginReseed()).
277+ messageInfoUpdatedSincePivot.add(key)
257278 if (existing == null) evictOldMessages()
258279 true
259280 }