@kortix/sdk 0.12.8 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (261) hide show
  1. package/README.md +3 -6
  2. package/dist/browser/session-sync/session-sync-registry.d.ts +7 -2
  3. package/dist/browser/session-sync/session-sync-registry.d.ts.map +1 -1
  4. package/dist/browser/session-sync/session-sync-registry.js +33 -44
  5. package/dist/browser/session-sync/session-sync-registry.js.map +1 -1
  6. package/dist/browser/stores/opencode-compaction-store.d.ts +9 -3
  7. package/dist/browser/stores/opencode-compaction-store.d.ts.map +1 -1
  8. package/dist/browser/stores/opencode-compaction-store.js +5 -12
  9. package/dist/browser/stores/opencode-compaction-store.js.map +1 -1
  10. package/dist/browser/stores/sandbox-connection-store.d.ts +28 -17
  11. package/dist/browser/stores/sandbox-connection-store.d.ts.map +1 -1
  12. package/dist/browser/stores/sandbox-connection-store.js +32 -84
  13. package/dist/browser/stores/sandbox-connection-store.js.map +1 -1
  14. package/dist/browser/stores/session-working-store.d.ts +73 -0
  15. package/dist/browser/stores/session-working-store.d.ts.map +1 -0
  16. package/dist/browser/stores/session-working-store.js +86 -0
  17. package/dist/browser/stores/session-working-store.js.map +1 -0
  18. package/dist/browser/stores/sync-store/types.d.ts +14 -4
  19. package/dist/browser/stores/sync-store/types.d.ts.map +1 -1
  20. package/dist/browser/stores/sync-store.d.ts +140 -3
  21. package/dist/browser/stores/sync-store.d.ts.map +1 -1
  22. package/dist/browser/stores/sync-store.js +425 -36
  23. package/dist/browser/stores/sync-store.js.map +1 -1
  24. package/dist/core/client/kortix.d.ts +102 -1
  25. package/dist/core/client/kortix.d.ts.map +1 -1
  26. package/dist/core/client/kortix.js +78 -0
  27. package/dist/core/client/kortix.js.map +1 -1
  28. package/dist/core/files/client.d.ts +50 -2
  29. package/dist/core/files/client.d.ts.map +1 -1
  30. package/dist/core/files/client.js +253 -44
  31. package/dist/core/files/client.js.map +1 -1
  32. package/dist/core/files/types.d.ts +11 -0
  33. package/dist/core/files/types.d.ts.map +1 -1
  34. package/dist/core/http/abort-error.d.ts +89 -0
  35. package/dist/core/http/abort-error.d.ts.map +1 -0
  36. package/dist/core/http/abort-error.js +190 -0
  37. package/dist/core/http/abort-error.js.map +1 -0
  38. package/dist/core/http/api-client.d.ts.map +1 -1
  39. package/dist/core/http/api-client.js +18 -4
  40. package/dist/core/http/api-client.js.map +1 -1
  41. package/dist/core/rest/platform-client/github-app.d.ts +13 -0
  42. package/dist/core/rest/platform-client/github-app.d.ts.map +1 -1
  43. package/dist/core/rest/platform-client/github-app.js +2 -0
  44. package/dist/core/rest/platform-client/github-app.js.map +1 -1
  45. package/dist/core/rest/platform-client/members.d.ts +10 -64
  46. package/dist/core/rest/platform-client/members.d.ts.map +1 -1
  47. package/dist/core/rest/platform-client/members.js +10 -28
  48. package/dist/core/rest/platform-client/members.js.map +1 -1
  49. package/dist/core/rest/projects-client/accounts.d.ts +30 -1
  50. package/dist/core/rest/projects-client/accounts.d.ts.map +1 -1
  51. package/dist/core/rest/projects-client/accounts.js +1 -1
  52. package/dist/core/rest/projects-client/accounts.js.map +1 -1
  53. package/dist/core/rest/projects-client/apps.d.ts +13 -0
  54. package/dist/core/rest/projects-client/apps.d.ts.map +1 -1
  55. package/dist/core/rest/projects-client/apps.js.map +1 -1
  56. package/dist/core/rest/projects-client/assignments.d.ts +114 -0
  57. package/dist/core/rest/projects-client/assignments.d.ts.map +1 -0
  58. package/dist/core/rest/projects-client/assignments.js +91 -0
  59. package/dist/core/rest/projects-client/assignments.js.map +1 -0
  60. package/dist/core/rest/projects-client/billing.d.ts +10 -1
  61. package/dist/core/rest/projects-client/billing.d.ts.map +1 -1
  62. package/dist/core/rest/projects-client/billing.js +11 -2
  63. package/dist/core/rest/projects-client/billing.js.map +1 -1
  64. package/dist/core/rest/projects-client/connectors.d.ts +2 -0
  65. package/dist/core/rest/projects-client/connectors.d.ts.map +1 -1
  66. package/dist/core/rest/projects-client/connectors.js.map +1 -1
  67. package/dist/core/rest/projects-client/files.js +2 -2
  68. package/dist/core/rest/projects-client/files.js.map +1 -1
  69. package/dist/core/rest/projects-client/gateway.d.ts +51 -0
  70. package/dist/core/rest/projects-client/gateway.d.ts.map +1 -1
  71. package/dist/core/rest/projects-client/gateway.js.map +1 -1
  72. package/dist/core/rest/projects-client/iam.d.ts +51 -3
  73. package/dist/core/rest/projects-client/iam.d.ts.map +1 -1
  74. package/dist/core/rest/projects-client/iam.js +18 -1
  75. package/dist/core/rest/projects-client/iam.js.map +1 -1
  76. package/dist/core/rest/projects-client/index.d.ts +1 -0
  77. package/dist/core/rest/projects-client/index.d.ts.map +1 -1
  78. package/dist/core/rest/projects-client/index.js +1 -0
  79. package/dist/core/rest/projects-client/index.js.map +1 -1
  80. package/dist/core/rest/projects-client/projects.d.ts +1 -1
  81. package/dist/core/rest/projects-client/projects.d.ts.map +1 -1
  82. package/dist/core/rest/projects-client/projects.js +3 -0
  83. package/dist/core/rest/projects-client/projects.js.map +1 -1
  84. package/dist/core/rest/projects-client/secrets.d.ts +11 -0
  85. package/dist/core/rest/projects-client/secrets.d.ts.map +1 -1
  86. package/dist/core/rest/projects-client/secrets.js +1 -1
  87. package/dist/core/rest/projects-client/secrets.js.map +1 -1
  88. package/dist/core/rest/projects-client/session-costs.d.ts +33 -0
  89. package/dist/core/rest/projects-client/session-costs.d.ts.map +1 -1
  90. package/dist/core/rest/projects-client/session-costs.js.map +1 -1
  91. package/dist/core/rest/projects-client/sessions.d.ts +226 -2
  92. package/dist/core/rest/projects-client/sessions.d.ts.map +1 -1
  93. package/dist/core/rest/projects-client/sessions.js +101 -4
  94. package/dist/core/rest/projects-client/sessions.js.map +1 -1
  95. package/dist/core/rest/projects-client/shared.d.ts +10 -1
  96. package/dist/core/rest/projects-client/shared.d.ts.map +1 -1
  97. package/dist/core/rest/projects-client/shared.js.map +1 -1
  98. package/dist/core/rest/projects-client/tokens.d.ts +13 -1
  99. package/dist/core/rest/projects-client/tokens.d.ts.map +1 -1
  100. package/dist/core/rest/projects-client/tokens.js +7 -2
  101. package/dist/core/rest/projects-client/tokens.js.map +1 -1
  102. package/dist/core/rest/projects-client/triggers.d.ts +69 -2
  103. package/dist/core/rest/projects-client/triggers.d.ts.map +1 -1
  104. package/dist/core/rest/projects-client/triggers.js +1 -1
  105. package/dist/core/rest/projects-client/triggers.js.map +1 -1
  106. package/dist/core/session/compaction.d.ts +98 -0
  107. package/dist/core/session/compaction.d.ts.map +1 -0
  108. package/dist/core/session/compaction.js +108 -0
  109. package/dist/core/session/compaction.js.map +1 -0
  110. package/dist/core/session/health.d.ts +22 -0
  111. package/dist/core/session/health.d.ts.map +1 -1
  112. package/dist/core/session/health.js +31 -5
  113. package/dist/core/session/health.js.map +1 -1
  114. package/dist/core/session/index.d.ts +2 -0
  115. package/dist/core/session/index.d.ts.map +1 -1
  116. package/dist/core/session/index.js +2 -0
  117. package/dist/core/session/index.js.map +1 -1
  118. package/dist/core/session/message-queue.d.ts +79 -15
  119. package/dist/core/session/message-queue.d.ts.map +1 -1
  120. package/dist/core/session/message-queue.js +73 -27
  121. package/dist/core/session/message-queue.js.map +1 -1
  122. package/dist/core/session/rewind.d.ts +76 -10
  123. package/dist/core/session/rewind.d.ts.map +1 -1
  124. package/dist/core/session/rewind.js +68 -11
  125. package/dist/core/session/rewind.js.map +1 -1
  126. package/dist/core/session/working.d.ts +241 -0
  127. package/dist/core/session/working.d.ts.map +1 -0
  128. package/dist/core/session/working.js +261 -0
  129. package/dist/core/session/working.js.map +1 -0
  130. package/dist/core/session-sync/session-sync-controller.d.ts +35 -20
  131. package/dist/core/session-sync/session-sync-controller.d.ts.map +1 -1
  132. package/dist/core/session-sync/session-sync-controller.js +48 -115
  133. package/dist/core/session-sync/session-sync-controller.js.map +1 -1
  134. package/dist/core/stream/event-stream.d.ts +22 -0
  135. package/dist/core/stream/event-stream.d.ts.map +1 -1
  136. package/dist/core/stream/event-stream.js +132 -36
  137. package/dist/core/stream/event-stream.js.map +1 -1
  138. package/dist/core/turns/grouping.d.ts +32 -2
  139. package/dist/core/turns/grouping.d.ts.map +1 -1
  140. package/dist/core/turns/grouping.js +31 -2
  141. package/dist/core/turns/grouping.js.map +1 -1
  142. package/dist/core/turns/index.d.ts +1 -0
  143. package/dist/core/turns/index.d.ts.map +1 -1
  144. package/dist/core/turns/index.js +1 -0
  145. package/dist/core/turns/index.js.map +1 -1
  146. package/dist/core/turns/open-turn.d.ts +81 -0
  147. package/dist/core/turns/open-turn.d.ts.map +1 -0
  148. package/dist/core/turns/open-turn.js +77 -0
  149. package/dist/core/turns/open-turn.js.map +1 -0
  150. package/dist/core/turns/state.js +26 -26
  151. package/dist/core/turns/state.js.map +1 -1
  152. package/dist/core/turns/types.d.ts +5 -0
  153. package/dist/core/turns/types.d.ts.map +1 -1
  154. package/dist/index.d.ts +2 -1
  155. package/dist/index.d.ts.map +1 -1
  156. package/dist/index.js +2 -1
  157. package/dist/index.js.map +1 -1
  158. package/dist/kortix.esm.min.js +25 -25
  159. package/dist/kortix.global.js +25 -25
  160. package/dist/node/server.d.ts.map +1 -1
  161. package/dist/node/server.js +14 -2
  162. package/dist/node/server.js.map +1 -1
  163. package/dist/react/index.d.ts +2 -1
  164. package/dist/react/index.d.ts.map +1 -1
  165. package/dist/react/index.js +7 -1
  166. package/dist/react/index.js.map +1 -1
  167. package/dist/react/initial-session-pin.d.ts +17 -0
  168. package/dist/react/initial-session-pin.d.ts.map +1 -1
  169. package/dist/react/initial-session-pin.js +58 -0
  170. package/dist/react/initial-session-pin.js.map +1 -1
  171. package/dist/react/opencode.d.ts +6 -2
  172. package/dist/react/opencode.d.ts.map +1 -1
  173. package/dist/react/opencode.js +5 -1
  174. package/dist/react/opencode.js.map +1 -1
  175. package/dist/react/prefetch-session-start.d.ts +10 -4
  176. package/dist/react/prefetch-session-start.d.ts.map +1 -1
  177. package/dist/react/prefetch-session-start.js +10 -4
  178. package/dist/react/prefetch-session-start.js.map +1 -1
  179. package/dist/react/query-keys.d.ts +8 -2
  180. package/dist/react/query-keys.d.ts.map +1 -1
  181. package/dist/react/query-keys.js +8 -2
  182. package/dist/react/query-keys.js.map +1 -1
  183. package/dist/react/session-start-stash.d.ts +25 -0
  184. package/dist/react/session-start-stash.d.ts.map +1 -1
  185. package/dist/react/session-start-stash.js +34 -3
  186. package/dist/react/session-start-stash.js.map +1 -1
  187. package/dist/react/session-title-sync.d.ts +5 -1
  188. package/dist/react/session-title-sync.d.ts.map +1 -1
  189. package/dist/react/session-title-sync.js +16 -4
  190. package/dist/react/session-title-sync.js.map +1 -1
  191. package/dist/react/use-can.d.ts +105 -0
  192. package/dist/react/use-can.d.ts.map +1 -0
  193. package/dist/react/use-can.js +190 -0
  194. package/dist/react/use-can.js.map +1 -0
  195. package/dist/react/use-canonical-opencode-session.d.ts +6 -1
  196. package/dist/react/use-canonical-opencode-session.d.ts.map +1 -1
  197. package/dist/react/use-canonical-opencode-session.js +46 -3
  198. package/dist/react/use-canonical-opencode-session.js.map +1 -1
  199. package/dist/react/use-opencode-events/handle-event.d.ts +10 -0
  200. package/dist/react/use-opencode-events/handle-event.d.ts.map +1 -1
  201. package/dist/react/use-opencode-events/handle-event.js +76 -7
  202. package/dist/react/use-opencode-events/handle-event.js.map +1 -1
  203. package/dist/react/use-opencode-events/helpers.d.ts +21 -8
  204. package/dist/react/use-opencode-events/helpers.d.ts.map +1 -1
  205. package/dist/react/use-opencode-events/helpers.js +25 -26
  206. package/dist/react/use-opencode-events/helpers.js.map +1 -1
  207. package/dist/react/use-opencode-events/index.d.ts.map +1 -1
  208. package/dist/react/use-opencode-events/index.js +43 -26
  209. package/dist/react/use-opencode-events/index.js.map +1 -1
  210. package/dist/react/use-opencode-events/use-event-stream-refs.d.ts.map +1 -1
  211. package/dist/react/use-opencode-events/use-event-stream-refs.js +66 -3
  212. package/dist/react/use-opencode-events/use-event-stream-refs.js.map +1 -1
  213. package/dist/react/use-opencode-local.d.ts +17 -0
  214. package/dist/react/use-opencode-local.d.ts.map +1 -1
  215. package/dist/react/use-opencode-local.js +41 -7
  216. package/dist/react/use-opencode-local.js.map +1 -1
  217. package/dist/react/use-opencode-sessions/messages.d.ts +101 -1
  218. package/dist/react/use-opencode-sessions/messages.d.ts.map +1 -1
  219. package/dist/react/use-opencode-sessions/messages.js +481 -63
  220. package/dist/react/use-opencode-sessions/messages.js.map +1 -1
  221. package/dist/react/use-project-apps.d.ts +24 -1
  222. package/dist/react/use-project-apps.d.ts.map +1 -1
  223. package/dist/react/use-project-apps.js +5 -3
  224. package/dist/react/use-project-apps.js.map +1 -1
  225. package/dist/react/use-runtime-boot-stalled.d.ts +32 -0
  226. package/dist/react/use-runtime-boot-stalled.d.ts.map +1 -0
  227. package/dist/react/use-runtime-boot-stalled.js +48 -0
  228. package/dist/react/use-runtime-boot-stalled.js.map +1 -0
  229. package/dist/react/use-runtime-reconnect.d.ts +67 -1
  230. package/dist/react/use-runtime-reconnect.d.ts.map +1 -1
  231. package/dist/react/use-runtime-reconnect.js +116 -18
  232. package/dist/react/use-runtime-reconnect.js.map +1 -1
  233. package/dist/react/use-session-prompts.d.ts +113 -0
  234. package/dist/react/use-session-prompts.d.ts.map +1 -0
  235. package/dist/react/use-session-prompts.js +242 -0
  236. package/dist/react/use-session-prompts.js.map +1 -0
  237. package/dist/react/use-session-send.d.ts +169 -20
  238. package/dist/react/use-session-send.d.ts.map +1 -1
  239. package/dist/react/use-session-send.js +178 -29
  240. package/dist/react/use-session-send.js.map +1 -1
  241. package/dist/react/use-session-sync.d.ts +35 -2
  242. package/dist/react/use-session-sync.d.ts.map +1 -1
  243. package/dist/react/use-session-sync.js +46 -9
  244. package/dist/react/use-session-sync.js.map +1 -1
  245. package/dist/react/use-session-working.d.ts +57 -0
  246. package/dist/react/use-session-working.d.ts.map +1 -0
  247. package/dist/react/use-session-working.js +178 -0
  248. package/dist/react/use-session-working.js.map +1 -0
  249. package/dist/react/use-session.d.ts +122 -5
  250. package/dist/react/use-session.d.ts.map +1 -1
  251. package/dist/react/use-session.js +343 -69
  252. package/dist/react/use-session.js.map +1 -1
  253. package/package.json +2 -2
  254. package/dist/browser/stores/tab-store.d.ts +0 -91
  255. package/dist/browser/stores/tab-store.d.ts.map +0 -1
  256. package/dist/browser/stores/tab-store.js +0 -369
  257. package/dist/browser/stores/tab-store.js.map +0 -1
  258. package/dist/core/session/send-queue.d.ts +0 -70
  259. package/dist/core/session/send-queue.d.ts.map +0 -1
  260. package/dist/core/session/send-queue.js +0 -116
  261. package/dist/core/session/send-queue.js.map +0 -1
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
  import { create } from "zustand";
3
+ import { commitSessionRewind, isWithinRewindWindow, stageSessionRewind, } from "../../core/session/rewind.js";
3
4
  import { ascendingId } from "./sync-store/ascending-id.js";
4
5
  import { Binary } from "./sync-store/binary.js";
5
6
  import { writeStreamCache } from "./sync-store/stream-cache.js";
@@ -7,6 +8,30 @@ import { writeStreamCache } from "./sync-store/stream-cache.js";
7
8
  // `Binary`, `ascendingId`, and the `MessageWithParts` type remain importable
8
9
  // from this module exactly as before.
9
10
  export { ascendingId, Binary };
11
+ /**
12
+ * Do two `SessionStatus` values say the same thing?
13
+ *
14
+ * NOT a deep equality: it compares `type` plus exactly the fields the retry
15
+ * readers actually read — `getRetryInfo` / `getRetryMessage` in
16
+ * `core/turns/state.ts` take `attempt`, `message` and `next`. A blanket
17
+ * deep-equal would also compare `action`, which nothing reads, and would then
18
+ * mint a new observation for a change no surface can render.
19
+ *
20
+ * The point of the comparison is object identity, not the boolean: a status
21
+ * frame is an OBSERVATION, and `useSessionWorking` dates it by the identity of
22
+ * the object in the store. Two equal values are one observation.
23
+ */
24
+ export function sameSessionStatus(a, b) {
25
+ if (a === b)
26
+ return true;
27
+ if (!a || !b)
28
+ return false;
29
+ if (a.type !== b.type)
30
+ return false;
31
+ if (a.type !== "retry" || b.type !== "retry")
32
+ return true;
33
+ return a.attempt === b.attempt && a.message === b.message && a.next === b.next;
34
+ }
10
35
  function isTextLikePart(part) {
11
36
  return part.type === "text" || part.type === "reasoning";
12
37
  }
@@ -26,6 +51,35 @@ const optimisticIds = new Map();
26
51
  // match: the server has not been told it exists, so nothing the server returns
27
52
  // can be a copy of it.
28
53
  const dispatchedOptimisticIds = new Map();
54
+ // Optimistic ids whose prompt-inbox row LANDED (see `markOptimisticInboxBacked`).
55
+ // The idle sweep skips these: the control plane holds the message durably and
56
+ // will deliver it, so "the session went idle and this was never echoed" is
57
+ // not evidence it was lost.
58
+ const inboxBackedOptimisticIds = new Map();
59
+ // optimistic id → the runtime id that superseded it, and the inverse. Written
60
+ // at supersede time (SSE and hydrate), so a host can keep one identity for a
61
+ // prompt across the swap. Bounded by the number of prompts sent from this tab
62
+ // per session; released with the session.
63
+ const optimisticEchoes = new Map();
64
+ const optimisticOrigins = new Map();
65
+ function recordOptimisticEcho(sessionID, optimisticID, echoID) {
66
+ if (optimisticID === echoID)
67
+ return;
68
+ let fwd = optimisticEchoes.get(sessionID);
69
+ if (!fwd)
70
+ optimisticEchoes.set(sessionID, (fwd = new Map()));
71
+ fwd.set(optimisticID, echoID);
72
+ let rev = optimisticOrigins.get(sessionID);
73
+ if (!rev)
74
+ optimisticOrigins.set(sessionID, (rev = new Map()));
75
+ rev.set(echoID, optimisticID);
76
+ }
77
+ /** Forget every optimistic mark for one id — confirmed, superseded, or removed. */
78
+ function releaseOptimisticId(sessionID, id) {
79
+ untrackId(optimisticIds, sessionID, id);
80
+ untrackId(dispatchedOptimisticIds, sessionID, id);
81
+ untrackId(inboxBackedOptimisticIds, sessionID, id);
82
+ }
29
83
  function trackId(store, sessionID, id) {
30
84
  const bucket = store.get(sessionID);
31
85
  if (bucket)
@@ -49,6 +103,9 @@ function hasTrackedId(store, sessionID, id) {
49
103
  function forgetSessionIds(sessionID) {
50
104
  optimisticIds.delete(sessionID);
51
105
  dispatchedOptimisticIds.delete(sessionID);
106
+ inboxBackedOptimisticIds.delete(sessionID);
107
+ optimisticEchoes.delete(sessionID);
108
+ optimisticOrigins.delete(sessionID);
52
109
  // The joined rows hold the very message and part arrays this session's
53
110
  // data is being dropped from. Left behind, they keep the transcript
54
111
  // reachable and the drop achieves nothing.
@@ -65,6 +122,9 @@ function forgetSessionIds(sessionID) {
65
122
  // bridge-clearing branch fired for it too, wiping every part already
66
123
  // stored under it. See bridgedPartIds below.
67
124
  bridgedPartIds.delete(sessionID);
125
+ // Same leak class, on the two T14 + T16 maps above.
126
+ deltaEventTails.delete(sessionID);
127
+ stubAssistantIds.delete(sessionID);
68
128
  }
69
129
  const isOptimistic = (sessionID, messageID) => hasTrackedId(optimisticIds, sessionID, messageID);
70
130
  const isDispatched = (sessionID, messageID) => hasTrackedId(dispatchedOptimisticIds, sessionID, messageID);
@@ -97,6 +157,57 @@ const bridgedPartIds = new Map();
97
157
  // session's bucket fixes it.
98
158
  const deltaActiveParts = new Map();
99
159
  // ---------------------------------------------------------------------------
160
+ // Part-delta idempotency (T14).
161
+ //
162
+ // `applyPartDelta` used to be `existing + delta` with nothing consulted for
163
+ // identity — a duplicate delivery of the SAME `message.part.delta` doubled
164
+ // the streamed text. Realistic duplicate sources: the vendor SSE client's
165
+ // retry loop briefly stacking a second live connection before
166
+ // `event-stream.ts`'s own single-live-stream invariant (or the `sseMaxRetryAttempts:
167
+ // 1` pin) catches up, or a second mounted subscriber replaying the same
168
+ // stream.
169
+ //
170
+ // The wire's own `EventMessagePartDelta` (`@opencode-ai/sdk`'s
171
+ // `v2/gen/types.gen.d.ts`) carries a top-level `id: string` on every event —
172
+ // NOT inside `properties`, so `applyEvent`'s `message.part.delta` case now
173
+ // threads `event.id` through as `applyPartDelta`'s new optional trailing
174
+ // `eventID` param. Two independently-broadcast SSE readers of the SAME
175
+ // published event (a stacked connection, a second subscriber) both receive
176
+ // the identical event object and therefore the identical `id` — which makes
177
+ // event identity the correct dedupe key, not delta CONTENT: content-based
178
+ // dedupe (e.g. "reject a delta whose text matches the last one applied at
179
+ // this length") would false-positive on legitimately-repeated content, such
180
+ // as streaming "..." one identical character at a time.
181
+ //
182
+ // Keyed by session (same shape/reason as the maps above), then by
183
+ // `${messageID}:${partID}:${field}` (a part could in principle accumulate
184
+ // more than one delta-bearing field) to the SET of event ids already applied
185
+ // for that key. A stacked duplicate connection can redeliver a whole tail of
186
+ // the stream, not just the single most-recent event — "replay the last N
187
+ // deltas again" is exactly what a reconnect-and-resume does — so every
188
+ // applied id for the key is kept, not just the last one; a Set lookup stays
189
+ // O(1) either way. Bounded by the per-session clears below, so this never
190
+ // grows across an entire session's lifetime, only within one streaming turn.
191
+ // A delta that arrives with no `eventID` (a caller predating this change, or
192
+ // a hand-built synthetic delta) gets NO protection — identical to this
193
+ // function's behavior before this change — rather than risk a content-based
194
+ // false positive (see the module comment above for why content isn't used).
195
+ //
196
+ // Cleared per-session on `session.idle`/`session.error` (a new turn's deltas
197
+ // use brand-new part ids anyway, so nothing realistic is lost) and released
198
+ // wholesale by `forgetSessionIds`/`reset()`, matching `deltaActiveParts`.
199
+ const deltaEventTails = new Map();
200
+ /** Session-scoped tracking for `session.error`'s stub assistant message (see
201
+ * the handler below) — same shape/reason as the maps above. A stub only
202
+ * ever stands in for a real assistant message that has not arrived yet;
203
+ * `hydrate` reconciles (drops) it the moment the server's own transcript
204
+ * contains a real one for this session, which is the promise the stub's own
205
+ * creation comment made but that nothing previously fulfilled. Released
206
+ * wholesale by `forgetSessionIds`/`reset()`; NOT cleared on idle/error — the
207
+ * stub message it names still lives in `messages[sessionID]` until hydrate
208
+ * reconciles it or the session itself ends. */
209
+ const stubAssistantIds = new Map();
210
+ // ---------------------------------------------------------------------------
100
211
  // Session retention — how a transcript ever LEAVES memory again.
101
212
  //
102
213
  // `messages` and `parts` are keyed by session and nothing removed a key.
@@ -315,6 +426,8 @@ export const useSyncStore = create()((set, get) => ({
315
426
  sessionStatus: {},
316
427
  diffs: {},
317
428
  todos: {},
429
+ sessionRevert: {},
430
+ sessionRevertNeedsTailReconcile: {},
318
431
  // Compat alias
319
432
  get statuses() {
320
433
  return get().sessionStatus;
@@ -372,22 +485,18 @@ export const useSyncStore = create()((set, get) => ({
372
485
  let list;
373
486
  let bridgeCleared = false;
374
487
  if (hasTrackedId(bridgedPartIds, partSessionID, messageID)) {
375
- // Only retire the optimistic/bridged user text once a REAL text
376
- // part with actual content arrives. A stray non-text part or an
377
- // empty text snapshot must NOT wipe the bridge, otherwise the
378
- // user's message renders as an empty bubble.
379
- const incoming = part;
380
- const incomingIsRealText = incoming?.type === "text" &&
381
- typeof incoming.text === "string" &&
382
- incoming.text.length > 0;
383
- if (incomingIsRealText) {
384
- untrackId(bridgedPartIds, partSessionID, messageID);
385
- list = [];
386
- bridgeCleared = true;
387
- }
388
- else {
389
- list = s.parts[messageID] ?? [];
390
- }
488
+ // T16 retire the bridge on ANY real part arrival for this
489
+ // message: text, non-text (tool/file/step/…), or an empty-text
490
+ // snapshot. This used to retire ONLY on a non-empty text part, so a
491
+ // message whose first real part was a tool call (or an empty text
492
+ // snapshot before content streamed in) kept the optimistic bridge
493
+ // alongside the real parts — duplicating the user's text bubble.
494
+ // The server has started sending this message's real parts either
495
+ // way, so the bridge's job — standing in until real data exists —
496
+ // is done the moment ANY of them arrives.
497
+ untrackId(bridgedPartIds, partSessionID, messageID);
498
+ list = [];
499
+ bridgeCleared = true;
391
500
  }
392
501
  else {
393
502
  list = s.parts[messageID] ?? [];
@@ -467,7 +576,7 @@ export const useSyncStore = create()((set, get) => ({
467
576
  }
468
577
  return { parts: { ...s.parts, [messageID]: next } };
469
578
  }),
470
- applyPartDelta: (sessionID, messageID, partID, field, delta) => {
579
+ applyPartDelta: (sessionID, messageID, partID, field, delta, eventID) => {
471
580
  trackId(deltaActiveParts, sessionID, partID);
472
581
  set((s) => {
473
582
  const list = s.parts[messageID];
@@ -476,6 +585,33 @@ export const useSyncStore = create()((set, get) => ({
476
585
  const result = Binary.search(list, partID, (p) => p.id);
477
586
  if (!result.found)
478
587
  return s;
588
+ // Duplicate-delivery no-op — see `deltaEventTails` above. Only acts
589
+ // when the caller supplied an event id (every real wire delta does);
590
+ // a delta with no id gets no protection here, same as before this
591
+ // change. Checked and RECORDED here, inside the actual-apply
592
+ // branch (after the list/found checks above), not before `set()`
593
+ // runs at all: recording it earlier marked an event id "applied"
594
+ // even on the not-found path — a delta whose target part doesn't
595
+ // exist yet (or was dropped) — which then permanently blocked a
596
+ // later LEGITIMATE redelivery of that same event once the part
597
+ // did exist. See "a delta that finds no target part does not
598
+ // consume the event id" in the test file.
599
+ if (eventID) {
600
+ const tailKey = `${messageID}:${partID}:${field}`;
601
+ let sessionTails = deltaEventTails.get(sessionID);
602
+ if (!sessionTails) {
603
+ sessionTails = new Map();
604
+ deltaEventTails.set(sessionID, sessionTails);
605
+ }
606
+ let appliedIds = sessionTails.get(tailKey);
607
+ if (appliedIds?.has(eventID))
608
+ return s;
609
+ if (!appliedIds) {
610
+ appliedIds = new Set();
611
+ sessionTails.set(tailKey, appliedIds);
612
+ }
613
+ appliedIds.add(eventID);
614
+ }
479
615
  const next = [...list];
480
616
  const part = { ...next[result.index] };
481
617
  const existing = part[field];
@@ -484,15 +620,106 @@ export const useSyncStore = create()((set, get) => ({
484
620
  return { parts: { ...s.parts, [messageID]: next } };
485
621
  });
486
622
  },
487
- setStatus: (sessionID, status) => set((s) => ({
488
- sessionStatus: { ...s.sessionStatus, [sessionID]: status },
489
- })),
623
+ setStatus: (sessionID, status) => set((s) => {
624
+ // A value that did not change is not news. `useSessionWorking` stamps
625
+ // its stream observation from this object's IDENTITY, so an
626
+ // equal-valued rewrite that minted a new object re-started
627
+ // `STREAM_OBSERVATION_MAX_MS` from zero on every write — the bound
628
+ // that stops a dead stream from deciding was never reached. See
629
+ // `sameSessionStatus`.
630
+ if (sameSessionStatus(s.sessionStatus[sessionID], status))
631
+ return s;
632
+ return { sessionStatus: { ...s.sessionStatus, [sessionID]: status } };
633
+ }),
490
634
  setDiff: (sessionID, diffs) => set((s) => ({
491
635
  diffs: { ...s.diffs, [sessionID]: diffs },
492
636
  })),
493
637
  setTodo: (sessionID, todos) => set((s) => ({
494
638
  todos: { ...s.todos, [sessionID]: todos },
495
639
  })),
640
+ stageSessionRevert: (sessionId, messageId) => set((s) => {
641
+ const current = s.sessionRevert[sessionId];
642
+ if (current?.staged && current.messageId === messageId)
643
+ return s;
644
+ return {
645
+ sessionRevert: {
646
+ ...s.sessionRevert,
647
+ [sessionId]: stageSessionRewind(get().getMessages(sessionId), messageId),
648
+ },
649
+ };
650
+ }),
651
+ commitSessionRevert: (sessionId) => set((s) => {
652
+ const current = s.sessionRevert[sessionId] ?? null;
653
+ const next = commitSessionRewind(current);
654
+ if (next === current)
655
+ return s;
656
+ return { sessionRevert: { ...s.sessionRevert, [sessionId]: next } };
657
+ }),
658
+ clearSessionRevert: (sessionId) => set((s) => {
659
+ if (!s.sessionRevert[sessionId])
660
+ return s;
661
+ return { sessionRevert: { ...s.sessionRevert, [sessionId]: null } };
662
+ }),
663
+ applyCommittedRevert: (sessionId, boundaryId, watermark) => set((s) => {
664
+ const list = s.messages[sessionId];
665
+ if (!list || list.length === 0) {
666
+ return { sessionRevert: { ...s.sessionRevert, [sessionId]: null } };
667
+ }
668
+ const rewind = { messageId: boundaryId, watermark, staged: false };
669
+ const removedIds = [];
670
+ const kept = list.filter((m) => {
671
+ if (isWithinRewindWindow(m.id, rewind)) {
672
+ removedIds.push(m.id);
673
+ return false;
674
+ }
675
+ return true;
676
+ });
677
+ const nextParts = { ...s.parts };
678
+ for (const id of removedIds)
679
+ delete nextParts[id];
680
+ return {
681
+ messages: { ...s.messages, [sessionId]: kept },
682
+ parts: nextParts,
683
+ sessionRevert: { ...s.sessionRevert, [sessionId]: null },
684
+ };
685
+ }),
686
+ markSessionRevertNeedsTailReconcile: (sessionId) => set((s) => {
687
+ if (s.sessionRevertNeedsTailReconcile[sessionId])
688
+ return s;
689
+ return {
690
+ sessionRevertNeedsTailReconcile: {
691
+ ...s.sessionRevertNeedsTailReconcile,
692
+ [sessionId]: true,
693
+ },
694
+ };
695
+ }),
696
+ clearSessionRevertNeedsTailReconcile: (sessionId) => set((s) => {
697
+ if (!s.sessionRevertNeedsTailReconcile[sessionId])
698
+ return s;
699
+ const { [sessionId]: _, ...rest } = s.sessionRevertNeedsTailReconcile;
700
+ return { sessionRevertNeedsTailReconcile: rest };
701
+ }),
702
+ syncSessionRevertFromInfo: (sessionId, revert) => {
703
+ if (!revert?.messageID)
704
+ return;
705
+ const current = get().sessionRevert[sessionId];
706
+ // F3 review finding: `null` is a deliberate tombstone — this store's
707
+ // own record of "committed or cleared, on purpose" (set by
708
+ // `applyCommittedRevert`/`clearSessionRevert`) — distinct from
709
+ // `undefined` ("nothing ever tracked here"). A `session.updated` that
710
+ // raced one of those two events can still carry the OLD `info.revert`
711
+ // pointer; re-staging off it here would hide every post-rewind
712
+ // message behind a phantom Restore. The tombstone wins until a
713
+ // genuinely fresh revert arrives through the real `.staged` wire
714
+ // event (`stageSessionRevert`, called directly, which overwrites
715
+ // unconditionally and so clears the tombstone normally) — never
716
+ // through this best-effort info-snapshot recovery path.
717
+ if (current === null)
718
+ return;
719
+ if (current?.messageId === revert.messageID)
720
+ return;
721
+ get().stageSessionRevert(sessionId, revert.messageID);
722
+ },
496
723
  optimisticAdd: (sessionID, message, messageParts) => {
497
724
  trackId(optimisticIds, sessionID, message.id);
498
725
  // The user has typed into this session, so the disk repaint stands down.
@@ -523,9 +750,20 @@ export const useSyncStore = create()((set, get) => ({
523
750
  if (isOptimistic(sessionID, messageID))
524
751
  trackId(dispatchedOptimisticIds, sessionID, messageID);
525
752
  },
753
+ markOptimisticInboxBacked: (sessionID, messageID) => {
754
+ if (isOptimistic(sessionID, messageID))
755
+ trackId(inboxBackedOptimisticIds, sessionID, messageID);
756
+ },
757
+ optimisticEchoOf: (sessionID, optimisticID) => optimisticEchoes.get(sessionID)?.get(optimisticID),
758
+ optimisticOriginOf: (sessionID, echoID) => optimisticOrigins.get(sessionID)?.get(echoID),
526
759
  optimisticRemove: (sessionID, messageID) => {
527
- untrackId(optimisticIds, sessionID, messageID);
528
- untrackId(dispatchedOptimisticIds, sessionID, messageID);
760
+ // Only an OPTIMISTIC message can be removed this way. One the runtime has
761
+ // confirmed (same-id echo, or the store never tracked it) is the
762
+ // transcript's; a host that removes its inbox row must not take the
763
+ // persisted message with it.
764
+ if (!isOptimistic(sessionID, messageID))
765
+ return;
766
+ releaseOptimisticId(sessionID, messageID);
529
767
  set((s) => {
530
768
  const list = s.messages[sessionID];
531
769
  if (!list)
@@ -549,15 +787,16 @@ export const useSyncStore = create()((set, get) => ({
549
787
  const list = s.messages[sessionID];
550
788
  if (!list)
551
789
  return s;
790
+ // An inbox-backed message is held by the control plane and will be
791
+ // delivered; a local idle is not evidence it was lost. It stays.
552
792
  const optIds = list
553
- .filter((m) => isOptimistic(sessionID, m.id))
793
+ .filter((m) => isOptimistic(sessionID, m.id) &&
794
+ !hasTrackedId(inboxBackedOptimisticIds, sessionID, m.id))
554
795
  .map((m) => m.id);
555
796
  if (optIds.length === 0)
556
797
  return s;
557
- for (const id of optIds) {
558
- untrackId(optimisticIds, sessionID, id);
559
- untrackId(dispatchedOptimisticIds, sessionID, id);
560
- }
798
+ for (const id of optIds)
799
+ releaseOptimisticId(sessionID, id);
561
800
  const filtered = list.filter((m) => !optIds.includes(m.id));
562
801
  const newParts = { ...s.parts };
563
802
  for (const id of optIds)
@@ -586,6 +825,11 @@ export const useSyncStore = create()((set, get) => ({
586
825
  sessionStatus: { ...s.sessionStatus, [sessionID]: { type: "idle" } },
587
826
  diffs: { ...s.diffs, [sessionID]: [] },
588
827
  todos: { ...s.todos, [sessionID]: [] },
828
+ sessionRevert: { ...s.sessionRevert, [sessionID]: null },
829
+ sessionRevertNeedsTailReconcile: (() => {
830
+ const { [sessionID]: _, ...rest } = s.sessionRevertNeedsTailReconcile;
831
+ return rest;
832
+ })(),
589
833
  };
590
834
  }),
591
835
  retainSession: (sessionID) => {
@@ -669,6 +913,22 @@ export const useSyncStore = create()((set, get) => ({
669
913
  .filter((m) => !!m?.info?.id)
670
914
  .map((m) => m.info)
671
915
  .sort((a, b) => cmp(a.id, b.id));
916
+ // T16 — reconcile a `session.error` stub assistant message
917
+ // (see `stubAssistantIds` and the `session.error` handler above). The
918
+ // stub only ever stood in for a real assistant message that had not
919
+ // arrived yet, sorted BELOW every server id (`ascendingId('msg')`) —
920
+ // wrong once real data exists. The moment the server's OWN transcript
921
+ // contains a real assistant message for this session, every
922
+ // currently-tracked stub for it is stale and is dropped below rather
923
+ // than kept alongside the real one at the wrong position. If the
924
+ // incoming snapshot has no assistant message at all yet, nothing has
925
+ // arrived to reconcile against, so the stub is left untouched.
926
+ const trackedStubIds = stubAssistantIds.get(sessionID);
927
+ const reconcileStubs = !!trackedStubIds &&
928
+ trackedStubIds.size > 0 &&
929
+ incoming.some((m) => m.role === "assistant");
930
+ if (reconcileStubs)
931
+ stubAssistantIds.delete(sessionID);
672
932
  // Merge incoming messages with existing ones — never delete messages
673
933
  // that exist in the sync store but are missing from the fetch (they may
674
934
  // be from a newer turn that the server hasn't persisted yet).
@@ -689,6 +949,21 @@ export const useSyncStore = create()((set, get) => ({
689
949
  //
690
950
  // The rule is now identity-based, in two passes.
691
951
  const existingIds = new Set(existing.map((m) => m.id));
952
+ // The echo may arrive under the optimistic message's OWN id: the host
953
+ // minted the wire id and painted the bubble with it. That is a
954
+ // confirmation, not a duplicate — release the marks so the message is
955
+ // ordinary from here (the sweep leaves it alone, real parts win), and
956
+ // bridge its optimistic parts until real ones land.
957
+ for (const m of incoming) {
958
+ if (m.role !== "user" || !isOptimistic(sessionID, m.id))
959
+ continue;
960
+ releaseOptimisticId(sessionID, m.id);
961
+ const optimisticParts = s.parts[m.id];
962
+ const entry = msgs.find((x) => x.info.id === m.id);
963
+ if ((entry?.parts?.length ?? 0) === 0 && optimisticParts?.length) {
964
+ trackId(bridgedPartIds, sessionID, m.id);
965
+ }
966
+ }
692
967
  // Pass 0 — candidate echoes. A real user message we have NEVER seen
693
968
  // before may be the server's copy of something still in flight. One we
694
969
  // already held is history, and history cannot supersede the present.
@@ -724,6 +999,12 @@ export const useSyncStore = create()((set, get) => ({
724
999
  const unmatchedOptimisticUsers = [];
725
1000
  for (const m of existing) {
726
1001
  if (!seen.has(m.id)) {
1002
+ if (reconcileStubs && trackedStubIds.has(m.id)) {
1003
+ // Superseded by a real assistant message this same hydrate
1004
+ // snapshot introduced — drop it, don't reinsert it below the
1005
+ // real data at its stale `ascendingId` position.
1006
+ continue;
1007
+ }
727
1008
  if (isOptimistic(sessionID, m.id)) {
728
1009
  if (m.role !== "user") {
729
1010
  deferredOptimistic.push(m);
@@ -770,10 +1051,13 @@ export const useSyncStore = create()((set, get) => ({
770
1051
  deferredOptimistic.push(m);
771
1052
  }
772
1053
  }
773
- // Clean up superseded optimistic IDs
1054
+ // Clean up superseded optimistic IDs, remembering which runtime id
1055
+ // each one became.
774
1056
  for (const id of supersededOptimistic) {
775
- untrackId(optimisticIds, sessionID, id);
776
- untrackId(dispatchedOptimisticIds, sessionID, id);
1057
+ releaseOptimisticId(sessionID, id);
1058
+ const echoId = supersededBy.get(id);
1059
+ if (echoId)
1060
+ recordOptimisticEcho(sessionID, id, echoId);
777
1061
  }
778
1062
  // Append surviving optimistic messages at the end
779
1063
  for (const m of deferredOptimistic) {
@@ -852,7 +1136,38 @@ export const useSyncStore = create()((set, get) => ({
852
1136
  }
853
1137
  return inP;
854
1138
  });
855
- for (const ep of extras) {
1139
+ // T16 dedupe extras by content identity. An "extra" is an
1140
+ // existing part whose id the incoming snapshot no longer has — the
1141
+ // server may simply not have persisted it yet (kept, as before), OR
1142
+ // the server RE-ISSUED the same content under a NEW part id (a real
1143
+ // defect: the old SSE-accumulated twin stayed in `exParts` forever,
1144
+ // duplicating the text inside this one message). Distinguish the two
1145
+ // conservatively: drop an extra only when it is text-like AND its own
1146
+ // accumulated text is a PREFIX of (or equal to) some incoming
1147
+ // text-like part of the SAME type — i.e. the incoming copy confidently
1148
+ // re-issues it, not merely resembles it. Non-text-like extras (tool,
1149
+ // permission, file, step, …) are never dropped by content — they carry
1150
+ // distinct identity per id and a coincidental text match doesn't apply
1151
+ // to them at all.
1152
+ //
1153
+ // F1 review finding: an extra still tracked in `deltaActiveParts` (this
1154
+ // session is actively applying deltas to it right now) is EXEMPT from
1155
+ // this filter regardless of what it prefixes. The heuristic above
1156
+ // assumes a text-prefix match means the server re-issued the SAME
1157
+ // content under a new id and the extra is an abandoned twin — but a
1158
+ // live streaming target is never abandoned, and dropping it here also
1159
+ // permanently blocks its later deltas (their event ids would already
1160
+ // be recorded as applied — see `applyPartDelta`'s not-found path).
1161
+ const survivingExtras = extras.filter((extra) => {
1162
+ if (hasTrackedId(deltaActiveParts, sessionID, extra.id))
1163
+ return true;
1164
+ if (!isTextLikePart(extra) || extra.text.length === 0)
1165
+ return true;
1166
+ return !inParts.some((inP) => inP.type === extra.type &&
1167
+ isTextLikePart(inP) &&
1168
+ inP.text.startsWith(extra.text));
1169
+ });
1170
+ for (const ep of survivingExtras) {
856
1171
  const r = Binary.search(reconciled, ep.id, (p) => p.id);
857
1172
  if (!r.found)
858
1173
  reconciled.splice(r.index, 0, ep);
@@ -867,8 +1182,13 @@ export const useSyncStore = create()((set, get) => ({
867
1182
  reset: () => {
868
1183
  optimisticIds.clear();
869
1184
  dispatchedOptimisticIds.clear();
1185
+ inboxBackedOptimisticIds.clear();
1186
+ optimisticEchoes.clear();
1187
+ optimisticOrigins.clear();
870
1188
  bridgedPartIds.clear();
871
1189
  deltaActiveParts.clear();
1190
+ deltaEventTails.clear();
1191
+ stubAssistantIds.clear();
872
1192
  // The data these holds protected is gone, so the holds are too —
873
1193
  // otherwise a late unmount could free a session the next page opened.
874
1194
  sessionConsumers.clear();
@@ -881,6 +1201,8 @@ export const useSyncStore = create()((set, get) => ({
881
1201
  sessionStatus: {},
882
1202
  diffs: {},
883
1203
  todos: {},
1204
+ sessionRevert: {},
1205
+ sessionRevertNeedsTailReconcile: {},
884
1206
  });
885
1207
  },
886
1208
  // ---- Selector: join messages + parts into MessageWithParts[] ----
@@ -912,6 +1234,19 @@ export const useSyncStore = create()((set, get) => ({
912
1234
  // optimistic message(s) in a SINGLE atomic set() call.
913
1235
  // This prevents the intermediate render where the user bubble
914
1236
  // vanishes (optimistic removed) before the real one appears.
1237
+ if (info.role === "user" && isOptimistic(info.sessionID, info.id)) {
1238
+ // The echo under the optimistic message's OWN id (the host minted
1239
+ // the wire id and painted with it): confirmed in place. Release the
1240
+ // marks — it is an ordinary message from here — and bridge the
1241
+ // optimistic parts until the real ones arrive, so the bubble never
1242
+ // blinks empty and never shows both.
1243
+ releaseOptimisticId(info.sessionID, info.id);
1244
+ if (get().parts[info.id]?.length) {
1245
+ trackId(bridgedPartIds, info.sessionID, info.id);
1246
+ }
1247
+ store.upsertMessage(info.sessionID, info);
1248
+ return;
1249
+ }
915
1250
  if (info.role === "user" && !isOptimistic(info.sessionID, info.id)) {
916
1251
  const msgs = get().messages[info.sessionID];
917
1252
  if (msgs) {
@@ -960,10 +1295,11 @@ export const useSyncStore = create()((set, get) => ({
960
1295
  const matched = byPartId ?? optimisticUsers.find((m) => isDispatched(info.sessionID, m.id));
961
1296
  const optIds = matched ? [matched.id] : [];
962
1297
  if (optIds.length > 0) {
963
- // Clean up optimistic tracking
1298
+ // Clean up optimistic tracking, remembering the runtime id
1299
+ // each superseded message became.
964
1300
  for (const id of optIds) {
965
- untrackId(optimisticIds, info.sessionID, id);
966
- untrackId(dispatchedOptimisticIds, info.sessionID, id);
1301
+ releaseOptimisticId(info.sessionID, id);
1302
+ recordOptimisticEcho(info.sessionID, id, info.id);
967
1303
  }
968
1304
  // Atomic: remove optimistic + insert real in one set()
969
1305
  set((s) => {
@@ -1102,7 +1438,10 @@ export const useSyncStore = create()((set, get) => ({
1102
1438
  [props.field]: "",
1103
1439
  });
1104
1440
  }
1105
- store.applyPartDelta(props.sessionID, props.messageID, props.partID, props.field, props.delta);
1441
+ // `event.id` is a top-level field of every wire event (see
1442
+ // `deltaEventTails` above) — never inside `properties`, so it is
1443
+ // read directly off `event`, not `props`.
1444
+ store.applyPartDelta(props.sessionID, props.messageID, props.partID, props.field, props.delta, event.id);
1106
1445
  if (props.field === "text") {
1107
1446
  const updated = get().parts[props.messageID]?.find((p) => p.id === props.partID);
1108
1447
  if (updated && isTextLikePart(updated) && updated.text.length > 0) {
@@ -1128,6 +1467,11 @@ export const useSyncStore = create()((set, get) => ({
1128
1467
  // still be streaming (see comment above deltaActiveParts).
1129
1468
  if (sessionID)
1130
1469
  deltaActiveParts.delete(sessionID);
1470
+ // Same reasoning for the delta event-id tails (T14): a new
1471
+ // turn's deltas use brand-new part ids anyway, so nothing realistic
1472
+ // is lost by dropping this session's tracking here.
1473
+ if (sessionID)
1474
+ deltaEventTails.delete(sessionID);
1131
1475
  return;
1132
1476
  }
1133
1477
  case "session.error": {
@@ -1141,6 +1485,7 @@ export const useSyncStore = create()((set, get) => ({
1141
1485
  // Clear only this session's delta tracking — see the idle handler
1142
1486
  // above and the comment above deltaActiveParts.
1143
1487
  deltaActiveParts.delete(sid);
1488
+ deltaEventTails.delete(sid);
1144
1489
  // Patch the error onto the last assistant message in the sync store.
1145
1490
  // If no assistant message exists yet, create a temporary one so the
1146
1491
  // error is visible immediately. The event handler in
@@ -1164,8 +1509,15 @@ export const useSyncStore = create()((set, get) => ({
1164
1509
  }
1165
1510
  }
1166
1511
  // No assistant message yet — create a stub so the error shows.
1167
- // Mark it as a client-side stub so hydrate can replace it.
1512
+ // Tracked in `stubAssistantIds` (T16) so `hydrate` can
1513
+ // reconcile it away once a real assistant message for this
1514
+ // session lands — see that map's doc comment and the
1515
+ // reconciliation in `hydrate` below. `ascendingId('msg')` sorts
1516
+ // BELOW every server id, which is fine as a placeholder position
1517
+ // but wrong once real data exists; that is exactly what the
1518
+ // reconciliation fixes.
1168
1519
  const stubId = ascendingId("msg");
1520
+ trackId(stubAssistantIds, sid, stubId);
1169
1521
  const stubMsg = {
1170
1522
  id: stubId,
1171
1523
  sessionID: sid,
@@ -1178,6 +1530,43 @@ export const useSyncStore = create()((set, get) => ({
1178
1530
  });
1179
1531
  return;
1180
1532
  }
1533
+ case "session.next.revert.staged": {
1534
+ const props = event.properties;
1535
+ if (props.sessionID && props.revert?.messageID) {
1536
+ store.stageSessionRevert(props.sessionID, props.revert.messageID);
1537
+ }
1538
+ return;
1539
+ }
1540
+ case "session.next.revert.cleared": {
1541
+ const props = event.properties;
1542
+ if (props.sessionID)
1543
+ store.clearSessionRevert(props.sessionID);
1544
+ return;
1545
+ }
1546
+ case "session.next.revert.committed": {
1547
+ const props = event.properties;
1548
+ if (props.sessionID && props.messageID) {
1549
+ const tracked = get().sessionRevert[props.sessionID];
1550
+ if (tracked) {
1551
+ store.applyCommittedRevert(props.sessionID, props.messageID, tracked.watermark);
1552
+ }
1553
+ else {
1554
+ // F2 — no tracked local record (fresh mount / second tab that
1555
+ // never saw `.staged`). The REMOVED fallback guessed a
1556
+ // watermark from `newestMessageId(local messages)`, which can
1557
+ // be the user's own REPLACEMENT prompt if its
1558
+ // `message.updated` arrived before this `.committed` event —
1559
+ // deleting the replacement and its answer. Do not guess: flag
1560
+ // this session for a tail reconcile (see
1561
+ // `markSessionRevertNeedsTailReconcile`'s doc comment) and
1562
+ // leave messages alone. The server's actual (already-
1563
+ // truncated) transcript arrives through whatever reads that
1564
+ // flag.
1565
+ store.markSessionRevertNeedsTailReconcile(props.sessionID);
1566
+ }
1567
+ }
1568
+ return;
1569
+ }
1181
1570
  case "session.diff": {
1182
1571
  const props = event.properties;
1183
1572
  if (props.sessionID)