@kortix/sdk 0.12.8 → 0.13.1

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 (274) 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/session-sync/session-transcript-cache.d.ts +9 -2
  7. package/dist/browser/session-sync/session-transcript-cache.d.ts.map +1 -1
  8. package/dist/browser/session-sync/session-transcript-cache.js +15 -5
  9. package/dist/browser/session-sync/session-transcript-cache.js.map +1 -1
  10. package/dist/browser/stores/opencode-compaction-store.d.ts +9 -3
  11. package/dist/browser/stores/opencode-compaction-store.d.ts.map +1 -1
  12. package/dist/browser/stores/opencode-compaction-store.js +5 -12
  13. package/dist/browser/stores/opencode-compaction-store.js.map +1 -1
  14. package/dist/browser/stores/sandbox-connection-store.d.ts +28 -17
  15. package/dist/browser/stores/sandbox-connection-store.d.ts.map +1 -1
  16. package/dist/browser/stores/sandbox-connection-store.js +32 -84
  17. package/dist/browser/stores/sandbox-connection-store.js.map +1 -1
  18. package/dist/browser/stores/session-working-store.d.ts +73 -0
  19. package/dist/browser/stores/session-working-store.d.ts.map +1 -0
  20. package/dist/browser/stores/session-working-store.js +86 -0
  21. package/dist/browser/stores/session-working-store.js.map +1 -0
  22. package/dist/browser/stores/sync-store/types.d.ts +14 -4
  23. package/dist/browser/stores/sync-store/types.d.ts.map +1 -1
  24. package/dist/browser/stores/sync-store.d.ts +179 -4
  25. package/dist/browser/stores/sync-store.d.ts.map +1 -1
  26. package/dist/browser/stores/sync-store.js +777 -65
  27. package/dist/browser/stores/sync-store.js.map +1 -1
  28. package/dist/core/client/kortix.d.ts +95 -0
  29. package/dist/core/client/kortix.d.ts.map +1 -1
  30. package/dist/core/client/kortix.js +78 -0
  31. package/dist/core/client/kortix.js.map +1 -1
  32. package/dist/core/files/client.d.ts +50 -2
  33. package/dist/core/files/client.d.ts.map +1 -1
  34. package/dist/core/files/client.js +253 -44
  35. package/dist/core/files/client.js.map +1 -1
  36. package/dist/core/files/types.d.ts +11 -0
  37. package/dist/core/files/types.d.ts.map +1 -1
  38. package/dist/core/http/abort-error.d.ts +89 -0
  39. package/dist/core/http/abort-error.d.ts.map +1 -0
  40. package/dist/core/http/abort-error.js +190 -0
  41. package/dist/core/http/abort-error.js.map +1 -0
  42. package/dist/core/http/api-client.d.ts.map +1 -1
  43. package/dist/core/http/api-client.js +18 -4
  44. package/dist/core/http/api-client.js.map +1 -1
  45. package/dist/core/http/opencode-errors.d.ts +7 -0
  46. package/dist/core/http/opencode-errors.d.ts.map +1 -1
  47. package/dist/core/http/opencode-errors.js +45 -13
  48. package/dist/core/http/opencode-errors.js.map +1 -1
  49. package/dist/core/rest/platform-client/github-app.d.ts +13 -0
  50. package/dist/core/rest/platform-client/github-app.d.ts.map +1 -1
  51. package/dist/core/rest/platform-client/github-app.js +2 -0
  52. package/dist/core/rest/platform-client/github-app.js.map +1 -1
  53. package/dist/core/rest/platform-client/members.d.ts +10 -64
  54. package/dist/core/rest/platform-client/members.d.ts.map +1 -1
  55. package/dist/core/rest/platform-client/members.js +10 -28
  56. package/dist/core/rest/platform-client/members.js.map +1 -1
  57. package/dist/core/rest/projects-client/accounts.d.ts +30 -5
  58. package/dist/core/rest/projects-client/accounts.d.ts.map +1 -1
  59. package/dist/core/rest/projects-client/accounts.js +1 -1
  60. package/dist/core/rest/projects-client/accounts.js.map +1 -1
  61. package/dist/core/rest/projects-client/apps.d.ts +13 -0
  62. package/dist/core/rest/projects-client/apps.d.ts.map +1 -1
  63. package/dist/core/rest/projects-client/apps.js.map +1 -1
  64. package/dist/core/rest/projects-client/assignments.d.ts +114 -0
  65. package/dist/core/rest/projects-client/assignments.d.ts.map +1 -0
  66. package/dist/core/rest/projects-client/assignments.js +91 -0
  67. package/dist/core/rest/projects-client/assignments.js.map +1 -0
  68. package/dist/core/rest/projects-client/billing.d.ts +10 -1
  69. package/dist/core/rest/projects-client/billing.d.ts.map +1 -1
  70. package/dist/core/rest/projects-client/billing.js +11 -2
  71. package/dist/core/rest/projects-client/billing.js.map +1 -1
  72. package/dist/core/rest/projects-client/connectors.d.ts +69 -1
  73. package/dist/core/rest/projects-client/connectors.d.ts.map +1 -1
  74. package/dist/core/rest/projects-client/connectors.js +17 -0
  75. package/dist/core/rest/projects-client/connectors.js.map +1 -1
  76. package/dist/core/rest/projects-client/files.js +2 -2
  77. package/dist/core/rest/projects-client/files.js.map +1 -1
  78. package/dist/core/rest/projects-client/gateway.d.ts +51 -0
  79. package/dist/core/rest/projects-client/gateway.d.ts.map +1 -1
  80. package/dist/core/rest/projects-client/gateway.js.map +1 -1
  81. package/dist/core/rest/projects-client/iam.d.ts +51 -3
  82. package/dist/core/rest/projects-client/iam.d.ts.map +1 -1
  83. package/dist/core/rest/projects-client/iam.js +18 -1
  84. package/dist/core/rest/projects-client/iam.js.map +1 -1
  85. package/dist/core/rest/projects-client/index.d.ts +1 -0
  86. package/dist/core/rest/projects-client/index.d.ts.map +1 -1
  87. package/dist/core/rest/projects-client/index.js +1 -0
  88. package/dist/core/rest/projects-client/index.js.map +1 -1
  89. package/dist/core/rest/projects-client/projects.d.ts +1 -1
  90. package/dist/core/rest/projects-client/projects.d.ts.map +1 -1
  91. package/dist/core/rest/projects-client/projects.js +2 -0
  92. package/dist/core/rest/projects-client/projects.js.map +1 -1
  93. package/dist/core/rest/projects-client/secrets.d.ts +19 -1
  94. package/dist/core/rest/projects-client/secrets.d.ts.map +1 -1
  95. package/dist/core/rest/projects-client/secrets.js +1 -1
  96. package/dist/core/rest/projects-client/secrets.js.map +1 -1
  97. package/dist/core/rest/projects-client/session-costs.d.ts +33 -0
  98. package/dist/core/rest/projects-client/session-costs.d.ts.map +1 -1
  99. package/dist/core/rest/projects-client/session-costs.js.map +1 -1
  100. package/dist/core/rest/projects-client/sessions.d.ts +261 -2
  101. package/dist/core/rest/projects-client/sessions.d.ts.map +1 -1
  102. package/dist/core/rest/projects-client/sessions.js +106 -4
  103. package/dist/core/rest/projects-client/sessions.js.map +1 -1
  104. package/dist/core/rest/projects-client/shared.d.ts +10 -1
  105. package/dist/core/rest/projects-client/shared.d.ts.map +1 -1
  106. package/dist/core/rest/projects-client/shared.js.map +1 -1
  107. package/dist/core/rest/projects-client/tokens.d.ts +13 -1
  108. package/dist/core/rest/projects-client/tokens.d.ts.map +1 -1
  109. package/dist/core/rest/projects-client/tokens.js +7 -2
  110. package/dist/core/rest/projects-client/tokens.js.map +1 -1
  111. package/dist/core/rest/projects-client/triggers.d.ts +69 -2
  112. package/dist/core/rest/projects-client/triggers.d.ts.map +1 -1
  113. package/dist/core/rest/projects-client/triggers.js +1 -1
  114. package/dist/core/rest/projects-client/triggers.js.map +1 -1
  115. package/dist/core/session/compaction.d.ts +98 -0
  116. package/dist/core/session/compaction.d.ts.map +1 -0
  117. package/dist/core/session/compaction.js +108 -0
  118. package/dist/core/session/compaction.js.map +1 -0
  119. package/dist/core/session/health.d.ts +22 -0
  120. package/dist/core/session/health.d.ts.map +1 -1
  121. package/dist/core/session/health.js +31 -5
  122. package/dist/core/session/health.js.map +1 -1
  123. package/dist/core/session/index.d.ts +2 -0
  124. package/dist/core/session/index.d.ts.map +1 -1
  125. package/dist/core/session/index.js +2 -0
  126. package/dist/core/session/index.js.map +1 -1
  127. package/dist/core/session/message-queue.d.ts +79 -15
  128. package/dist/core/session/message-queue.d.ts.map +1 -1
  129. package/dist/core/session/message-queue.js +73 -27
  130. package/dist/core/session/message-queue.js.map +1 -1
  131. package/dist/core/session/rewind.d.ts +76 -10
  132. package/dist/core/session/rewind.d.ts.map +1 -1
  133. package/dist/core/session/rewind.js +68 -11
  134. package/dist/core/session/rewind.js.map +1 -1
  135. package/dist/core/session/working.d.ts +241 -0
  136. package/dist/core/session/working.d.ts.map +1 -0
  137. package/dist/core/session/working.js +261 -0
  138. package/dist/core/session/working.js.map +1 -0
  139. package/dist/core/session-sync/session-sync-controller.d.ts +35 -20
  140. package/dist/core/session-sync/session-sync-controller.d.ts.map +1 -1
  141. package/dist/core/session-sync/session-sync-controller.js +48 -115
  142. package/dist/core/session-sync/session-sync-controller.js.map +1 -1
  143. package/dist/core/stream/event-stream.d.ts +22 -0
  144. package/dist/core/stream/event-stream.d.ts.map +1 -1
  145. package/dist/core/stream/event-stream.js +132 -36
  146. package/dist/core/stream/event-stream.js.map +1 -1
  147. package/dist/core/turns/grouping.d.ts +18 -2
  148. package/dist/core/turns/grouping.d.ts.map +1 -1
  149. package/dist/core/turns/grouping.js +45 -2
  150. package/dist/core/turns/grouping.js.map +1 -1
  151. package/dist/core/turns/index.d.ts +1 -0
  152. package/dist/core/turns/index.d.ts.map +1 -1
  153. package/dist/core/turns/index.js +1 -0
  154. package/dist/core/turns/index.js.map +1 -1
  155. package/dist/core/turns/open-turn.d.ts +81 -0
  156. package/dist/core/turns/open-turn.d.ts.map +1 -0
  157. package/dist/core/turns/open-turn.js +77 -0
  158. package/dist/core/turns/open-turn.js.map +1 -0
  159. package/dist/core/turns/state.js +26 -26
  160. package/dist/core/turns/state.js.map +1 -1
  161. package/dist/core/turns/types.d.ts +5 -0
  162. package/dist/core/turns/types.d.ts.map +1 -1
  163. package/dist/index.d.ts +2 -1
  164. package/dist/index.d.ts.map +1 -1
  165. package/dist/index.js +2 -1
  166. package/dist/index.js.map +1 -1
  167. package/dist/kortix.esm.min.js +25 -25
  168. package/dist/kortix.global.js +25 -25
  169. package/dist/node/server.d.ts.map +1 -1
  170. package/dist/node/server.js +14 -2
  171. package/dist/node/server.js.map +1 -1
  172. package/dist/react/index.d.ts +2 -1
  173. package/dist/react/index.d.ts.map +1 -1
  174. package/dist/react/index.js +7 -1
  175. package/dist/react/index.js.map +1 -1
  176. package/dist/react/initial-session-pin.d.ts +17 -0
  177. package/dist/react/initial-session-pin.d.ts.map +1 -1
  178. package/dist/react/initial-session-pin.js +58 -0
  179. package/dist/react/initial-session-pin.js.map +1 -1
  180. package/dist/react/opencode.d.ts +6 -2
  181. package/dist/react/opencode.d.ts.map +1 -1
  182. package/dist/react/opencode.js +5 -1
  183. package/dist/react/opencode.js.map +1 -1
  184. package/dist/react/prefetch-session-start.d.ts +10 -4
  185. package/dist/react/prefetch-session-start.d.ts.map +1 -1
  186. package/dist/react/prefetch-session-start.js +10 -4
  187. package/dist/react/prefetch-session-start.js.map +1 -1
  188. package/dist/react/query-contracts.d.ts +2 -0
  189. package/dist/react/query-contracts.d.ts.map +1 -1
  190. package/dist/react/query-contracts.js +2 -0
  191. package/dist/react/query-contracts.js.map +1 -1
  192. package/dist/react/query-keys.d.ts +13 -2
  193. package/dist/react/query-keys.d.ts.map +1 -1
  194. package/dist/react/query-keys.js +13 -2
  195. package/dist/react/query-keys.js.map +1 -1
  196. package/dist/react/session-start-stash.d.ts +25 -0
  197. package/dist/react/session-start-stash.d.ts.map +1 -1
  198. package/dist/react/session-start-stash.js +34 -3
  199. package/dist/react/session-start-stash.js.map +1 -1
  200. package/dist/react/session-title-sync.d.ts +5 -1
  201. package/dist/react/session-title-sync.d.ts.map +1 -1
  202. package/dist/react/session-title-sync.js +16 -4
  203. package/dist/react/session-title-sync.js.map +1 -1
  204. package/dist/react/use-can.d.ts +105 -0
  205. package/dist/react/use-can.d.ts.map +1 -0
  206. package/dist/react/use-can.js +190 -0
  207. package/dist/react/use-can.js.map +1 -0
  208. package/dist/react/use-canonical-opencode-session.d.ts +6 -1
  209. package/dist/react/use-canonical-opencode-session.d.ts.map +1 -1
  210. package/dist/react/use-canonical-opencode-session.js +46 -3
  211. package/dist/react/use-canonical-opencode-session.js.map +1 -1
  212. package/dist/react/use-opencode-events/handle-event.d.ts +10 -0
  213. package/dist/react/use-opencode-events/handle-event.d.ts.map +1 -1
  214. package/dist/react/use-opencode-events/handle-event.js +76 -7
  215. package/dist/react/use-opencode-events/handle-event.js.map +1 -1
  216. package/dist/react/use-opencode-events/helpers.d.ts +21 -8
  217. package/dist/react/use-opencode-events/helpers.d.ts.map +1 -1
  218. package/dist/react/use-opencode-events/helpers.js +25 -26
  219. package/dist/react/use-opencode-events/helpers.js.map +1 -1
  220. package/dist/react/use-opencode-events/index.d.ts.map +1 -1
  221. package/dist/react/use-opencode-events/index.js +43 -26
  222. package/dist/react/use-opencode-events/index.js.map +1 -1
  223. package/dist/react/use-opencode-events/use-event-stream-refs.d.ts.map +1 -1
  224. package/dist/react/use-opencode-events/use-event-stream-refs.js +66 -3
  225. package/dist/react/use-opencode-events/use-event-stream-refs.js.map +1 -1
  226. package/dist/react/use-opencode-local.d.ts +17 -0
  227. package/dist/react/use-opencode-local.d.ts.map +1 -1
  228. package/dist/react/use-opencode-local.js +41 -7
  229. package/dist/react/use-opencode-local.js.map +1 -1
  230. package/dist/react/use-opencode-sessions/messages.d.ts +101 -1
  231. package/dist/react/use-opencode-sessions/messages.d.ts.map +1 -1
  232. package/dist/react/use-opencode-sessions/messages.js +481 -63
  233. package/dist/react/use-opencode-sessions/messages.js.map +1 -1
  234. package/dist/react/use-project-apps.d.ts +24 -1
  235. package/dist/react/use-project-apps.d.ts.map +1 -1
  236. package/dist/react/use-project-apps.js +5 -3
  237. package/dist/react/use-project-apps.js.map +1 -1
  238. package/dist/react/use-runtime-boot-stalled.d.ts +32 -0
  239. package/dist/react/use-runtime-boot-stalled.d.ts.map +1 -0
  240. package/dist/react/use-runtime-boot-stalled.js +48 -0
  241. package/dist/react/use-runtime-boot-stalled.js.map +1 -0
  242. package/dist/react/use-runtime-reconnect.d.ts +67 -1
  243. package/dist/react/use-runtime-reconnect.d.ts.map +1 -1
  244. package/dist/react/use-runtime-reconnect.js +116 -18
  245. package/dist/react/use-runtime-reconnect.js.map +1 -1
  246. package/dist/react/use-session-prompts.d.ts +113 -0
  247. package/dist/react/use-session-prompts.d.ts.map +1 -0
  248. package/dist/react/use-session-prompts.js +250 -0
  249. package/dist/react/use-session-prompts.js.map +1 -0
  250. package/dist/react/use-session-send.d.ts +169 -20
  251. package/dist/react/use-session-send.d.ts.map +1 -1
  252. package/dist/react/use-session-send.js +183 -30
  253. package/dist/react/use-session-send.js.map +1 -1
  254. package/dist/react/use-session-sync.d.ts +35 -2
  255. package/dist/react/use-session-sync.d.ts.map +1 -1
  256. package/dist/react/use-session-sync.js +49 -11
  257. package/dist/react/use-session-sync.js.map +1 -1
  258. package/dist/react/use-session-working.d.ts +57 -0
  259. package/dist/react/use-session-working.d.ts.map +1 -0
  260. package/dist/react/use-session-working.js +178 -0
  261. package/dist/react/use-session-working.js.map +1 -0
  262. package/dist/react/use-session.d.ts +131 -5
  263. package/dist/react/use-session.d.ts.map +1 -1
  264. package/dist/react/use-session.js +358 -69
  265. package/dist/react/use-session.js.map +1 -1
  266. package/package.json +2 -2
  267. package/dist/browser/stores/tab-store.d.ts +0 -91
  268. package/dist/browser/stores/tab-store.d.ts.map +0 -1
  269. package/dist/browser/stores/tab-store.js +0 -369
  270. package/dist/browser/stores/tab-store.js.map +0 -1
  271. package/dist/core/session/send-queue.d.ts +0 -70
  272. package/dist/core/session/send-queue.d.ts.map +0 -1
  273. package/dist/core/session/send-queue.js +0 -116
  274. 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,71 @@ 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
+ // Message ids the CONTROL PLANE still owns after the runtime confirmed them:
66
+ // the echo (same id or re-minted) of an inbox-backed optimistic send. The
67
+ // server can take such a message back out of the runtime and place it again
68
+ // under a new id — a stranded mid-turn prompt being re-placed, a Stop taking
69
+ // an unread prompt back into the queue. A `message.removed` for one of these
70
+ // is not "the message is gone", it is "the message is between two ids": the
71
+ // bubble is re-marked optimistic (dispatched, inbox-backed) and the next echo
72
+ // supersedes it, instead of the bubble blinking out and a new one appearing.
73
+ // Released with the session.
74
+ const controlPlaneOwnedIds = new Map();
75
+ // Message ids the user explicitly CANCELLED (`DELETE .../prompts`). The
76
+ // runtime may keep a part-less husk of the message (a busy loop refuses the
77
+ // whole-message delete; the parts are emptied instead), and every later
78
+ // transcript read would resurrect it as an empty bubble. A tombstoned id is
79
+ // dropped from incoming reads and events. Released with the session.
80
+ const cancelledMessageIds = new Map();
81
+ // Message ids that came from the DISK CACHE and have not yet been seen in a
82
+ // runtime read. The cache is a first-paint accelerator; a message it holds
83
+ // that the runtime's own tail — covering that message's position — does not,
84
+ // never existed there (an optimistic stub mirrored to disk before its echo)
85
+ // and must not outlive the first authoritative read.
86
+ const cacheSourcedIds = new Map();
87
+ function recordOptimisticEcho(sessionID, optimisticID, echoID) {
88
+ if (optimisticID === echoID)
89
+ return;
90
+ // Chain through an earlier swap: a message that itself superseded an
91
+ // optimistic id keeps pointing the host at that ORIGINAL id, so a second
92
+ // re-placement does not change the identity the host keyed its bubble on.
93
+ const origin = optimisticOrigins.get(sessionID)?.get(optimisticID) ?? optimisticID;
94
+ let fwd = optimisticEchoes.get(sessionID);
95
+ if (!fwd)
96
+ optimisticEchoes.set(sessionID, (fwd = new Map()));
97
+ fwd.set(origin, echoID);
98
+ if (origin !== optimisticID)
99
+ fwd.set(optimisticID, echoID);
100
+ let rev = optimisticOrigins.get(sessionID);
101
+ if (!rev)
102
+ optimisticOrigins.set(sessionID, (rev = new Map()));
103
+ rev.set(echoID, origin);
104
+ }
105
+ /** Forget every optimistic mark for one id — confirmed, superseded, or removed. */
106
+ function releaseOptimisticId(sessionID, id) {
107
+ untrackId(optimisticIds, sessionID, id);
108
+ untrackId(dispatchedOptimisticIds, sessionID, id);
109
+ untrackId(inboxBackedOptimisticIds, sessionID, id);
110
+ }
111
+ /** The runtime confirmed an inbox-backed optimistic send under `echoID`
112
+ * (same id, or re-minted): the control plane still owns that message. */
113
+ function releaseConfirmedOptimisticId(sessionID, optimisticID, echoID) {
114
+ if (hasTrackedId(inboxBackedOptimisticIds, sessionID, optimisticID)) {
115
+ trackId(controlPlaneOwnedIds, sessionID, echoID);
116
+ }
117
+ releaseOptimisticId(sessionID, optimisticID);
118
+ }
29
119
  function trackId(store, sessionID, id) {
30
120
  const bucket = store.get(sessionID);
31
121
  if (bucket)
@@ -49,6 +139,12 @@ function hasTrackedId(store, sessionID, id) {
49
139
  function forgetSessionIds(sessionID) {
50
140
  optimisticIds.delete(sessionID);
51
141
  dispatchedOptimisticIds.delete(sessionID);
142
+ inboxBackedOptimisticIds.delete(sessionID);
143
+ optimisticEchoes.delete(sessionID);
144
+ optimisticOrigins.delete(sessionID);
145
+ controlPlaneOwnedIds.delete(sessionID);
146
+ cacheSourcedIds.delete(sessionID);
147
+ cancelledMessageIds.delete(sessionID);
52
148
  // The joined rows hold the very message and part arrays this session's
53
149
  // data is being dropped from. Left behind, they keep the transcript
54
150
  // reachable and the drop achieves nothing.
@@ -65,6 +161,9 @@ function forgetSessionIds(sessionID) {
65
161
  // bridge-clearing branch fired for it too, wiping every part already
66
162
  // stored under it. See bridgedPartIds below.
67
163
  bridgedPartIds.delete(sessionID);
164
+ // Same leak class, on the two T14 + T16 maps above.
165
+ deltaEventTails.delete(sessionID);
166
+ stubAssistantIds.delete(sessionID);
68
167
  }
69
168
  const isOptimistic = (sessionID, messageID) => hasTrackedId(optimisticIds, sessionID, messageID);
70
169
  const isDispatched = (sessionID, messageID) => hasTrackedId(dispatchedOptimisticIds, sessionID, messageID);
@@ -97,6 +196,137 @@ const bridgedPartIds = new Map();
97
196
  // session's bucket fixes it.
98
197
  const deltaActiveParts = new Map();
99
198
  // ---------------------------------------------------------------------------
199
+ // Part-delta idempotency (T14).
200
+ //
201
+ // `applyPartDelta` used to be `existing + delta` with nothing consulted for
202
+ // identity — a duplicate delivery of the SAME `message.part.delta` doubled
203
+ // the streamed text. Realistic duplicate sources: the vendor SSE client's
204
+ // retry loop briefly stacking a second live connection before
205
+ // `event-stream.ts`'s own single-live-stream invariant (or the `sseMaxRetryAttempts:
206
+ // 1` pin) catches up, or a second mounted subscriber replaying the same
207
+ // stream.
208
+ //
209
+ // The wire's own `EventMessagePartDelta` (`@opencode-ai/sdk`'s
210
+ // `v2/gen/types.gen.d.ts`) carries a top-level `id: string` on every event —
211
+ // NOT inside `properties`, so `applyEvent`'s `message.part.delta` case now
212
+ // threads `event.id` through as `applyPartDelta`'s new optional trailing
213
+ // `eventID` param. Two independently-broadcast SSE readers of the SAME
214
+ // published event (a stacked connection, a second subscriber) both receive
215
+ // the identical event object and therefore the identical `id` — which makes
216
+ // event identity the correct dedupe key, not delta CONTENT: content-based
217
+ // dedupe (e.g. "reject a delta whose text matches the last one applied at
218
+ // this length") would false-positive on legitimately-repeated content, such
219
+ // as streaming "..." one identical character at a time.
220
+ //
221
+ // Keyed by session (same shape/reason as the maps above), then by
222
+ // `${messageID}:${partID}:${field}` (a part could in principle accumulate
223
+ // more than one delta-bearing field) to the SET of event ids already applied
224
+ // for that key. A stacked duplicate connection can redeliver a whole tail of
225
+ // the stream, not just the single most-recent event — "replay the last N
226
+ // deltas again" is exactly what a reconnect-and-resume does — so every
227
+ // applied id for the key is kept, not just the last one; a Set lookup stays
228
+ // O(1) either way. Bounded by the per-session clears below, so this never
229
+ // grows across an entire session's lifetime, only within one streaming turn.
230
+ // A delta that arrives with no `eventID` (a caller predating this change, or
231
+ // a hand-built synthetic delta) gets NO protection — identical to this
232
+ // function's behavior before this change — rather than risk a content-based
233
+ // false positive (see the module comment above for why content isn't used).
234
+ //
235
+ // Cleared per-session on `session.idle`/`session.error` (a new turn's deltas
236
+ // use brand-new part ids anyway, so nothing realistic is lost) and released
237
+ // wholesale by `forgetSessionIds`/`reset()`, matching `deltaActiveParts`.
238
+ const deltaEventTails = new Map();
239
+ /** Session-scoped tracking for `session.error`'s stub assistant message (see
240
+ * the handler below) — same shape/reason as the maps above, except the value
241
+ * is a MAP: stub message id → the id of the user message whose turn failed
242
+ * (`null` only when the session held no user message at all).
243
+ *
244
+ * The parent is tracked, not merely implied, because every question asked
245
+ * about a stub afterwards is a question about its TURN. A stub stands in for
246
+ * the assistant message that turn never produced, so `hydrate` may drop it
247
+ * only once the server's transcript answers THAT turn — a session-wide "any
248
+ * assistant message exists" test deleted the only record of a failure the
249
+ * moment an EARLIER turn's reply arrived (the 2026-08-19 report: the first
250
+ * `ModelNotFound` rendered nothing at all).
251
+ *
252
+ * Released wholesale by `forgetSessionIds`/`reset()`; NOT cleared on
253
+ * idle/error — the stub message it names still lives in `messages[sessionID]`
254
+ * until hydrate reconciles it or the session itself ends. */
255
+ const stubAssistantIds = new Map();
256
+ /** Remember `stubId` as this session's stand-in for the turn `parentId` opened. */
257
+ function trackStub(sessionID, stubId, parentId) {
258
+ const bucket = stubAssistantIds.get(sessionID);
259
+ if (bucket)
260
+ bucket.set(stubId, parentId);
261
+ else
262
+ stubAssistantIds.set(sessionID, new Map([[stubId, parentId]]));
263
+ }
264
+ function untrackStub(sessionID, stubId) {
265
+ const bucket = stubAssistantIds.get(sessionID);
266
+ if (!bucket)
267
+ return;
268
+ bucket.delete(stubId);
269
+ if (bucket.size === 0)
270
+ stubAssistantIds.delete(sessionID);
271
+ }
272
+ /** The stub id for the turn `userId` opened.
273
+ *
274
+ * Derived from the user message's own id, for two reasons. It is IDEMPOTENT —
275
+ * a second `session.error` for the same turn finds the stub it already made
276
+ * instead of appending another. And it SORTS immediately after that user
277
+ * message: every id the store handles has the same length, so an id that
278
+ * extends `userId` byte for byte precedes every id greater than `userId` and
279
+ * follows `userId` itself. Position and `parentID` therefore agree, whichever
280
+ * of the two a reader uses. */
281
+ const stubIdFor = (userId) => `${userId}_error`;
282
+ /** A stub id as seen from a list that has no tracking map to hand (`hydrate`'s
283
+ * incoming server snapshot, which can never legitimately contain one). */
284
+ const isStubShaped = (id) => id.endsWith("_error");
285
+ /** Does `messages` hold an assistant message answering the turn `parentId`
286
+ * opened? `parentID` is the linkage `groupMessagesIntoTurns` reads; the id
287
+ * comparison covers a wire assistant message that carries none (ids ascend,
288
+ * so a greater id is a later message). With no parent turn to speak of, any
289
+ * assistant message answers. */
290
+ function hasAssistantForTurn(messages, parentId) {
291
+ return messages.some((m) => m.role === "assistant" &&
292
+ !isStubShaped(m.id) &&
293
+ (!parentId || m.parentID === parentId || (!m.parentID && m.id > parentId)));
294
+ }
295
+ /** Move this session's `session.error` stubs from the optimistic user message
296
+ * `fromId` onto the server's own copy of that prompt, `toId`.
297
+ *
298
+ * Both paths that retire an optimistic user message call this — `hydrate`'s
299
+ * correlation and the `message.updated` echo — because a stub whose parent id
300
+ * no longer names a message in the list has lost the only link that says which
301
+ * turn failed, and `groupMessagesIntoTurns` would fall back to "the last turn
302
+ * seen", i.e. the bottom of the thread. Returns the list unchanged when this
303
+ * session has no stub on `fromId`. */
304
+ function rekeyStubParent(sessionID, list, fromId, toId) {
305
+ const bucket = stubAssistantIds.get(sessionID);
306
+ if (!bucket)
307
+ return list;
308
+ const moved = [...bucket.entries()].filter(([, parentId]) => parentId === fromId);
309
+ if (moved.length === 0)
310
+ return list;
311
+ let next = [...list];
312
+ for (const [stubId] of moved) {
313
+ untrackStub(sessionID, stubId);
314
+ const idx = next.findIndex((m) => m.id === stubId);
315
+ if (idx === -1)
316
+ continue;
317
+ const nextId = stubIdFor(toId);
318
+ trackStub(sessionID, nextId, toId);
319
+ const rekeyed = { ...next[idx], id: nextId, parentID: toId };
320
+ next.splice(idx, 1);
321
+ const parentIdx = next.findIndex((m) => m.id === toId);
322
+ if (parentIdx === -1)
323
+ next = [...next, rekeyed];
324
+ else
325
+ next.splice(parentIdx + 1, 0, rekeyed);
326
+ }
327
+ return next;
328
+ }
329
+ // ---------------------------------------------------------------------------
100
330
  // Session retention — how a transcript ever LEAVES memory again.
101
331
  //
102
332
  // `messages` and `parts` are keyed by session and nothing removed a key.
@@ -315,6 +545,8 @@ export const useSyncStore = create()((set, get) => ({
315
545
  sessionStatus: {},
316
546
  diffs: {},
317
547
  todos: {},
548
+ sessionRevert: {},
549
+ sessionRevertNeedsTailReconcile: {},
318
550
  // Compat alias
319
551
  get statuses() {
320
552
  return get().sessionStatus;
@@ -372,22 +604,18 @@ export const useSyncStore = create()((set, get) => ({
372
604
  let list;
373
605
  let bridgeCleared = false;
374
606
  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
- }
607
+ // T16 retire the bridge on ANY real part arrival for this
608
+ // message: text, non-text (tool/file/step/…), or an empty-text
609
+ // snapshot. This used to retire ONLY on a non-empty text part, so a
610
+ // message whose first real part was a tool call (or an empty text
611
+ // snapshot before content streamed in) kept the optimistic bridge
612
+ // alongside the real parts — duplicating the user's text bubble.
613
+ // The server has started sending this message's real parts either
614
+ // way, so the bridge's job — standing in until real data exists —
615
+ // is done the moment ANY of them arrives.
616
+ untrackId(bridgedPartIds, partSessionID, messageID);
617
+ list = [];
618
+ bridgeCleared = true;
391
619
  }
392
620
  else {
393
621
  list = s.parts[messageID] ?? [];
@@ -467,7 +695,7 @@ export const useSyncStore = create()((set, get) => ({
467
695
  }
468
696
  return { parts: { ...s.parts, [messageID]: next } };
469
697
  }),
470
- applyPartDelta: (sessionID, messageID, partID, field, delta) => {
698
+ applyPartDelta: (sessionID, messageID, partID, field, delta, eventID) => {
471
699
  trackId(deltaActiveParts, sessionID, partID);
472
700
  set((s) => {
473
701
  const list = s.parts[messageID];
@@ -476,6 +704,33 @@ export const useSyncStore = create()((set, get) => ({
476
704
  const result = Binary.search(list, partID, (p) => p.id);
477
705
  if (!result.found)
478
706
  return s;
707
+ // Duplicate-delivery no-op — see `deltaEventTails` above. Only acts
708
+ // when the caller supplied an event id (every real wire delta does);
709
+ // a delta with no id gets no protection here, same as before this
710
+ // change. Checked and RECORDED here, inside the actual-apply
711
+ // branch (after the list/found checks above), not before `set()`
712
+ // runs at all: recording it earlier marked an event id "applied"
713
+ // even on the not-found path — a delta whose target part doesn't
714
+ // exist yet (or was dropped) — which then permanently blocked a
715
+ // later LEGITIMATE redelivery of that same event once the part
716
+ // did exist. See "a delta that finds no target part does not
717
+ // consume the event id" in the test file.
718
+ if (eventID) {
719
+ const tailKey = `${messageID}:${partID}:${field}`;
720
+ let sessionTails = deltaEventTails.get(sessionID);
721
+ if (!sessionTails) {
722
+ sessionTails = new Map();
723
+ deltaEventTails.set(sessionID, sessionTails);
724
+ }
725
+ let appliedIds = sessionTails.get(tailKey);
726
+ if (appliedIds?.has(eventID))
727
+ return s;
728
+ if (!appliedIds) {
729
+ appliedIds = new Set();
730
+ sessionTails.set(tailKey, appliedIds);
731
+ }
732
+ appliedIds.add(eventID);
733
+ }
479
734
  const next = [...list];
480
735
  const part = { ...next[result.index] };
481
736
  const existing = part[field];
@@ -484,15 +739,106 @@ export const useSyncStore = create()((set, get) => ({
484
739
  return { parts: { ...s.parts, [messageID]: next } };
485
740
  });
486
741
  },
487
- setStatus: (sessionID, status) => set((s) => ({
488
- sessionStatus: { ...s.sessionStatus, [sessionID]: status },
489
- })),
742
+ setStatus: (sessionID, status) => set((s) => {
743
+ // A value that did not change is not news. `useSessionWorking` stamps
744
+ // its stream observation from this object's IDENTITY, so an
745
+ // equal-valued rewrite that minted a new object re-started
746
+ // `STREAM_OBSERVATION_MAX_MS` from zero on every write — the bound
747
+ // that stops a dead stream from deciding was never reached. See
748
+ // `sameSessionStatus`.
749
+ if (sameSessionStatus(s.sessionStatus[sessionID], status))
750
+ return s;
751
+ return { sessionStatus: { ...s.sessionStatus, [sessionID]: status } };
752
+ }),
490
753
  setDiff: (sessionID, diffs) => set((s) => ({
491
754
  diffs: { ...s.diffs, [sessionID]: diffs },
492
755
  })),
493
756
  setTodo: (sessionID, todos) => set((s) => ({
494
757
  todos: { ...s.todos, [sessionID]: todos },
495
758
  })),
759
+ stageSessionRevert: (sessionId, messageId) => set((s) => {
760
+ const current = s.sessionRevert[sessionId];
761
+ if (current?.staged && current.messageId === messageId)
762
+ return s;
763
+ return {
764
+ sessionRevert: {
765
+ ...s.sessionRevert,
766
+ [sessionId]: stageSessionRewind(get().getMessages(sessionId), messageId),
767
+ },
768
+ };
769
+ }),
770
+ commitSessionRevert: (sessionId) => set((s) => {
771
+ const current = s.sessionRevert[sessionId] ?? null;
772
+ const next = commitSessionRewind(current);
773
+ if (next === current)
774
+ return s;
775
+ return { sessionRevert: { ...s.sessionRevert, [sessionId]: next } };
776
+ }),
777
+ clearSessionRevert: (sessionId) => set((s) => {
778
+ if (!s.sessionRevert[sessionId])
779
+ return s;
780
+ return { sessionRevert: { ...s.sessionRevert, [sessionId]: null } };
781
+ }),
782
+ applyCommittedRevert: (sessionId, boundaryId, watermark) => set((s) => {
783
+ const list = s.messages[sessionId];
784
+ if (!list || list.length === 0) {
785
+ return { sessionRevert: { ...s.sessionRevert, [sessionId]: null } };
786
+ }
787
+ const rewind = { messageId: boundaryId, watermark, staged: false };
788
+ const removedIds = [];
789
+ const kept = list.filter((m) => {
790
+ if (isWithinRewindWindow(m.id, rewind)) {
791
+ removedIds.push(m.id);
792
+ return false;
793
+ }
794
+ return true;
795
+ });
796
+ const nextParts = { ...s.parts };
797
+ for (const id of removedIds)
798
+ delete nextParts[id];
799
+ return {
800
+ messages: { ...s.messages, [sessionId]: kept },
801
+ parts: nextParts,
802
+ sessionRevert: { ...s.sessionRevert, [sessionId]: null },
803
+ };
804
+ }),
805
+ markSessionRevertNeedsTailReconcile: (sessionId) => set((s) => {
806
+ if (s.sessionRevertNeedsTailReconcile[sessionId])
807
+ return s;
808
+ return {
809
+ sessionRevertNeedsTailReconcile: {
810
+ ...s.sessionRevertNeedsTailReconcile,
811
+ [sessionId]: true,
812
+ },
813
+ };
814
+ }),
815
+ clearSessionRevertNeedsTailReconcile: (sessionId) => set((s) => {
816
+ if (!s.sessionRevertNeedsTailReconcile[sessionId])
817
+ return s;
818
+ const { [sessionId]: _, ...rest } = s.sessionRevertNeedsTailReconcile;
819
+ return { sessionRevertNeedsTailReconcile: rest };
820
+ }),
821
+ syncSessionRevertFromInfo: (sessionId, revert) => {
822
+ if (!revert?.messageID)
823
+ return;
824
+ const current = get().sessionRevert[sessionId];
825
+ // F3 review finding: `null` is a deliberate tombstone — this store's
826
+ // own record of "committed or cleared, on purpose" (set by
827
+ // `applyCommittedRevert`/`clearSessionRevert`) — distinct from
828
+ // `undefined` ("nothing ever tracked here"). A `session.updated` that
829
+ // raced one of those two events can still carry the OLD `info.revert`
830
+ // pointer; re-staging off it here would hide every post-rewind
831
+ // message behind a phantom Restore. The tombstone wins until a
832
+ // genuinely fresh revert arrives through the real `.staged` wire
833
+ // event (`stageSessionRevert`, called directly, which overwrites
834
+ // unconditionally and so clears the tombstone normally) — never
835
+ // through this best-effort info-snapshot recovery path.
836
+ if (current === null)
837
+ return;
838
+ if (current?.messageId === revert.messageID)
839
+ return;
840
+ get().stageSessionRevert(sessionId, revert.messageID);
841
+ },
496
842
  optimisticAdd: (sessionID, message, messageParts) => {
497
843
  trackId(optimisticIds, sessionID, message.id);
498
844
  // The user has typed into this session, so the disk repaint stands down.
@@ -523,9 +869,52 @@ export const useSyncStore = create()((set, get) => ({
523
869
  if (isOptimistic(sessionID, messageID))
524
870
  trackId(dispatchedOptimisticIds, sessionID, messageID);
525
871
  },
872
+ markOptimisticInboxBacked: (sessionID, messageID) => {
873
+ if (isOptimistic(sessionID, messageID))
874
+ trackId(inboxBackedOptimisticIds, sessionID, messageID);
875
+ },
876
+ optimisticEchoOf: (sessionID, optimisticID) => optimisticEchoes.get(sessionID)?.get(optimisticID),
877
+ optimisticOriginOf: (sessionID, echoID) => optimisticOrigins.get(sessionID)?.get(echoID),
878
+ isOptimisticMessage: (sessionID, messageID) => isOptimistic(sessionID, messageID),
879
+ forgetControlPlaneMessage: (sessionID, messageID) => {
880
+ untrackId(controlPlaneOwnedIds, sessionID, messageID);
881
+ releaseOptimisticId(sessionID, messageID);
882
+ trackId(cancelledMessageIds, sessionID, messageID);
883
+ get().removeMessage(sessionID, messageID);
884
+ },
885
+ registerOptimisticEcho: (sessionID, optimisticID, echoID) => {
886
+ if (!isOptimistic(sessionID, optimisticID))
887
+ return;
888
+ if (optimisticID === echoID)
889
+ return;
890
+ recordOptimisticEcho(sessionID, optimisticID, echoID);
891
+ },
892
+ reclaimRemovedMessage: (sessionID, messageID) => {
893
+ // See `controlPlaneOwnedIds`: a removal of a message the control plane
894
+ // still owns is a re-placement in progress, not a deletion. Keep the
895
+ // message and its parts; put the optimistic marks back (dispatched, so
896
+ // the ordinal echo match may claim it; inbox-backed, so the idle sweep
897
+ // leaves it) and let the next echo supersede it.
898
+ if (!hasTrackedId(controlPlaneOwnedIds, sessionID, messageID))
899
+ return false;
900
+ const list = get().messages[sessionID];
901
+ const message = list?.find((m) => m.id === messageID);
902
+ if (!message || message.role !== "user")
903
+ return false;
904
+ untrackId(controlPlaneOwnedIds, sessionID, messageID);
905
+ trackId(optimisticIds, sessionID, messageID);
906
+ trackId(dispatchedOptimisticIds, sessionID, messageID);
907
+ trackId(inboxBackedOptimisticIds, sessionID, messageID);
908
+ return true;
909
+ },
526
910
  optimisticRemove: (sessionID, messageID) => {
527
- untrackId(optimisticIds, sessionID, messageID);
528
- untrackId(dispatchedOptimisticIds, sessionID, messageID);
911
+ // Only an OPTIMISTIC message can be removed this way. One the runtime has
912
+ // confirmed (same-id echo, or the store never tracked it) is the
913
+ // transcript's; a host that removes its inbox row must not take the
914
+ // persisted message with it.
915
+ if (!isOptimistic(sessionID, messageID))
916
+ return;
917
+ releaseOptimisticId(sessionID, messageID);
529
918
  set((s) => {
530
919
  const list = s.messages[sessionID];
531
920
  if (!list)
@@ -549,15 +938,16 @@ export const useSyncStore = create()((set, get) => ({
549
938
  const list = s.messages[sessionID];
550
939
  if (!list)
551
940
  return s;
941
+ // An inbox-backed message is held by the control plane and will be
942
+ // delivered; a local idle is not evidence it was lost. It stays.
552
943
  const optIds = list
553
- .filter((m) => isOptimistic(sessionID, m.id))
944
+ .filter((m) => isOptimistic(sessionID, m.id) &&
945
+ !hasTrackedId(inboxBackedOptimisticIds, sessionID, m.id))
554
946
  .map((m) => m.id);
555
947
  if (optIds.length === 0)
556
948
  return s;
557
- for (const id of optIds) {
558
- untrackId(optimisticIds, sessionID, id);
559
- untrackId(dispatchedOptimisticIds, sessionID, id);
560
- }
949
+ for (const id of optIds)
950
+ releaseOptimisticId(sessionID, id);
561
951
  const filtered = list.filter((m) => !optIds.includes(m.id));
562
952
  const newParts = { ...s.parts };
563
953
  for (const id of optIds)
@@ -586,6 +976,11 @@ export const useSyncStore = create()((set, get) => ({
586
976
  sessionStatus: { ...s.sessionStatus, [sessionID]: { type: "idle" } },
587
977
  diffs: { ...s.diffs, [sessionID]: [] },
588
978
  todos: { ...s.todos, [sessionID]: [] },
979
+ sessionRevert: { ...s.sessionRevert, [sessionID]: null },
980
+ sessionRevertNeedsTailReconcile: (() => {
981
+ const { [sessionID]: _, ...rest } = s.sessionRevertNeedsTailReconcile;
982
+ return rest;
983
+ })(),
589
984
  };
590
985
  }),
591
986
  retainSession: (sessionID) => {
@@ -660,19 +1055,76 @@ export const useSyncStore = create()((set, get) => ({
660
1055
  touchSessionMessageRows(sessionID, { msgs, partRefs, result });
661
1056
  return result;
662
1057
  },
663
- hydrate: (sessionID, msgs) => set((s) => {
1058
+ hydrate: (sessionID, msgs, opts) => set((s) => {
664
1059
  // An authoritative load — the disk repaint itself, or a reconcile —
665
1060
  // re-establishes the session, so its entry is no longer a fragment.
666
1061
  evictedSessions.delete(sessionID);
667
1062
  const cmp = (a, b) => a < b ? -1 : a > b ? 1 : 0;
1063
+ const tombstones = cancelledMessageIds.get(sessionID);
668
1064
  const incoming = msgs
669
- .filter((m) => !!m?.info?.id)
1065
+ .filter((m) => !!m?.info?.id && !tombstones?.has(m.info.id))
670
1066
  .map((m) => m.info)
671
1067
  .sort((a, b) => cmp(a.id, b.id));
1068
+ const fromCache = opts?.source === "cache";
1069
+ if (fromCache) {
1070
+ for (const m of incoming)
1071
+ trackId(cacheSourcedIds, sessionID, m.id);
1072
+ }
1073
+ // A RUNTIME read settles the cache's provisional messages: the ones
1074
+ // it contains are real; the ones it lacks but whose position it
1075
+ // COVERS (at or above the oldest message it returned) never existed
1076
+ // there and are dropped. Older ones are history the bounded tail did
1077
+ // not reach — kept, still provisional.
1078
+ let droppedPhantoms = null;
1079
+ const provisional = fromCache ? undefined : cacheSourcedIds.get(sessionID);
1080
+ if (provisional && provisional.size > 0 && incoming.length > 0) {
1081
+ const oldestIncoming = incoming[0].id;
1082
+ const incomingIds = new Set(incoming.map((m) => m.id));
1083
+ for (const id of [...provisional]) {
1084
+ if (incomingIds.has(id)) {
1085
+ untrackId(cacheSourcedIds, sessionID, id);
1086
+ continue;
1087
+ }
1088
+ if (cmp(id, oldestIncoming) >= 0 && !isOptimistic(sessionID, id)) {
1089
+ untrackId(cacheSourcedIds, sessionID, id);
1090
+ (droppedPhantoms ??= new Set()).add(id);
1091
+ }
1092
+ }
1093
+ }
1094
+ // T16 — a `session.error` stub assistant message is reconciled
1095
+ // PER TURN, after the optimistic correlation below has run (see
1096
+ // `pendingStubs`). It only ever stood in for the assistant message
1097
+ // its own turn never produced, so the question is not "does this
1098
+ // session have an assistant message" — an earlier turn's answer is
1099
+ // no answer at all — but "does the server now answer THAT turn".
1100
+ // Re-adopt a stub that arrives IN the snapshot. The transcript cache
1101
+ // mirrors `messages[sessionID]` to IndexedDB, stubs included, so the
1102
+ // first paint after a reload brings one back — while
1103
+ // `stubAssistantIds` (module state) did not survive the reload. An
1104
+ // unadopted stub is unreconcilable: it would sit beside the server's
1105
+ // own reply forever. Only the runtime ever produces these ids, so
1106
+ // nothing else can be mistaken for one.
1107
+ for (const info of incoming) {
1108
+ if (info.role !== "assistant" || !isStubShaped(info.id))
1109
+ continue;
1110
+ if (!info.error)
1111
+ continue;
1112
+ if (stubAssistantIds.get(sessionID)?.has(info.id))
1113
+ continue;
1114
+ trackStub(sessionID, info.id, info.parentID ?? null);
1115
+ }
1116
+ const trackedStubIds = stubAssistantIds.get(sessionID);
1117
+ /** Stubs held back from the merge below until their parent turn is
1118
+ * known (an optimistic parent may be superseded by the server's own
1119
+ * copy in this very snapshot). */
1120
+ const pendingStubs = [];
672
1121
  // Merge incoming messages with existing ones — never delete messages
673
1122
  // that exist in the sync store but are missing from the fetch (they may
674
1123
  // be from a newer turn that the server hasn't persisted yet).
675
- const existing = s.messages[sessionID] ?? [];
1124
+ const existingAll = s.messages[sessionID] ?? [];
1125
+ const existing = droppedPhantoms
1126
+ ? existingAll.filter((m) => !droppedPhantoms.has(m.id))
1127
+ : existingAll;
676
1128
  const merged = [];
677
1129
  const seen = new Set();
678
1130
  // Which optimistic user messages has the server actually echoed?
@@ -689,6 +1141,21 @@ export const useSyncStore = create()((set, get) => ({
689
1141
  //
690
1142
  // The rule is now identity-based, in two passes.
691
1143
  const existingIds = new Set(existing.map((m) => m.id));
1144
+ // The echo may arrive under the optimistic message's OWN id: the host
1145
+ // minted the wire id and painted the bubble with it. That is a
1146
+ // confirmation, not a duplicate — release the marks so the message is
1147
+ // ordinary from here (the sweep leaves it alone, real parts win), and
1148
+ // bridge its optimistic parts until real ones land.
1149
+ for (const m of incoming) {
1150
+ if (m.role !== "user" || !isOptimistic(sessionID, m.id))
1151
+ continue;
1152
+ releaseConfirmedOptimisticId(sessionID, m.id, m.id);
1153
+ const optimisticParts = s.parts[m.id];
1154
+ const entry = msgs.find((x) => x.info.id === m.id);
1155
+ if ((entry?.parts?.length ?? 0) === 0 && optimisticParts?.length) {
1156
+ trackId(bridgedPartIds, sessionID, m.id);
1157
+ }
1158
+ }
692
1159
  // Pass 0 — candidate echoes. A real user message we have NEVER seen
693
1160
  // before may be the server's copy of something still in flight. One we
694
1161
  // already held is history, and history cannot supersede the present.
@@ -724,6 +1191,13 @@ export const useSyncStore = create()((set, get) => ({
724
1191
  const unmatchedOptimisticUsers = [];
725
1192
  for (const m of existing) {
726
1193
  if (!seen.has(m.id)) {
1194
+ if (trackedStubIds?.has(m.id)) {
1195
+ // Placed (or dropped) after the correlation passes below,
1196
+ // because where it belongs depends on what happened to the
1197
+ // user message it is parented to.
1198
+ pendingStubs.push(m);
1199
+ continue;
1200
+ }
727
1201
  if (isOptimistic(sessionID, m.id)) {
728
1202
  if (m.role !== "user") {
729
1203
  deferredOptimistic.push(m);
@@ -755,10 +1229,24 @@ export const useSyncStore = create()((set, get) => ({
755
1229
  // duplicate of anything the server holds, so it is never eligible.
756
1230
  // That restriction is what keeps a message sent from another tab from
757
1231
  // consuming this tab's in-flight bubble.
758
- const claimable = candidateEchoes.filter((m) => unclaimedEchoes.has(m.id));
1232
+ const claimed = new Set();
1233
+ for (const m of unmatchedOptimisticUsers) {
1234
+ // Alias first: the inbox row announced this message's echo id.
1235
+ const alias = optimisticEchoes.get(sessionID)?.get(m.id);
1236
+ if (alias && unclaimedEchoes.has(alias) && !claimed.has(alias)) {
1237
+ claimed.add(alias);
1238
+ supersededOptimistic.push(m.id);
1239
+ supersededBy.set(m.id, alias);
1240
+ }
1241
+ }
1242
+ const claimable = candidateEchoes.filter((m) => unclaimedEchoes.has(m.id) && !claimed.has(m.id));
759
1243
  let next = 0;
760
1244
  for (const m of unmatchedOptimisticUsers) {
761
- const echo = isDispatched(sessionID, m.id)
1245
+ if (supersededBy.has(m.id))
1246
+ continue;
1247
+ const echo = isDispatched(sessionID, m.id) &&
1248
+ // Known-different echo → never consume someone else's.
1249
+ !optimisticEchoes.get(sessionID)?.get(m.id)
762
1250
  ? claimable[next]
763
1251
  : undefined;
764
1252
  if (echo) {
@@ -770,21 +1258,81 @@ export const useSyncStore = create()((set, get) => ({
770
1258
  deferredOptimistic.push(m);
771
1259
  }
772
1260
  }
773
- // Clean up superseded optimistic IDs
1261
+ // Clean up superseded optimistic IDs, remembering which runtime id
1262
+ // each one became.
774
1263
  for (const id of supersededOptimistic) {
775
- untrackId(optimisticIds, sessionID, id);
776
- untrackId(dispatchedOptimisticIds, sessionID, id);
1264
+ const echoId = supersededBy.get(id);
1265
+ if (echoId) {
1266
+ releaseConfirmedOptimisticId(sessionID, id, echoId);
1267
+ recordOptimisticEcho(sessionID, id, echoId);
1268
+ }
1269
+ else {
1270
+ releaseOptimisticId(sessionID, id);
1271
+ }
777
1272
  }
778
1273
  // Append surviving optimistic messages at the end
779
1274
  for (const m of deferredOptimistic) {
780
1275
  merged.push(m);
781
1276
  }
1277
+ // Now place (or retire) each `session.error` stub, with its turn
1278
+ // finally settled. Three outcomes, in order:
1279
+ //
1280
+ // - its user message was superseded by the server's own copy in this
1281
+ // snapshot → the stub follows it, re-keyed onto the real id;
1282
+ // - the server now holds a real assistant message for that turn →
1283
+ // the stub was a stand-in for exactly that message, so it goes;
1284
+ // - otherwise → it stays, directly after its user message. This is
1285
+ // the case the reported bug turned on: the `reconcileTail` that
1286
+ // every `session.error` triggers returns a transcript with NO
1287
+ // reply for the failed turn, and dropping the stub there left the
1288
+ // user staring at their own prompt and nothing else.
1289
+ for (const stub of pendingStubs) {
1290
+ const trackedParent = trackedStubIds?.get(stub.id) ?? null;
1291
+ const parentId = (trackedParent && supersededBy.get(trackedParent)) || trackedParent;
1292
+ untrackStub(sessionID, stub.id);
1293
+ // The server DOES hold a reply for this turn — the stub stood in
1294
+ // for exactly that message, so it goes. Its error moves onto the
1295
+ // real message first, unless the server's copy carries one of its
1296
+ // own: `session.error` is the runtime's report of THIS turn
1297
+ // failing, and the transcript read that follows it does not always
1298
+ // carry the failure back (the same race
1299
+ // `use-opencode-events`'s cache patch exists for). Dropping the
1300
+ // stub against an error-free server copy would erase the only
1301
+ // evidence the turn failed.
1302
+ if (hasAssistantForTurn(incoming, parentId)) {
1303
+ const replyIdx = merged.findIndex((m) => m.role === "assistant" &&
1304
+ !isStubShaped(m.id) &&
1305
+ (!parentId || m.parentID === parentId || (!m.parentID && m.id > parentId)));
1306
+ const reply = replyIdx === -1 ? undefined : merged[replyIdx];
1307
+ const stubError = stub.error;
1308
+ if (reply && !reply.error && stubError) {
1309
+ merged[replyIdx] = { ...reply, error: stubError };
1310
+ }
1311
+ continue;
1312
+ }
1313
+ const stubId = parentId ? stubIdFor(parentId) : stub.id;
1314
+ const placed = stubId === stub.id && stub.parentID === (parentId ?? undefined)
1315
+ ? stub
1316
+ : { ...stub, id: stubId, ...(parentId ? { parentID: parentId } : {}) };
1317
+ trackStub(sessionID, stubId, parentId);
1318
+ const parentIdx = parentId ? merged.findIndex((m) => m.id === parentId) : -1;
1319
+ if (parentIdx !== -1) {
1320
+ merged.splice(parentIdx + 1, 0, placed);
1321
+ continue;
1322
+ }
1323
+ const r = Binary.search(merged, placed.id, (x) => x.id);
1324
+ if (!r.found)
1325
+ merged.splice(r.index, 0, placed);
1326
+ }
782
1327
  // Merge parts: for each message, reconcile by part ID.
783
1328
  // If a message is optimistic (still in optimisticIds), keep existing
784
1329
  // parts entirely — they're from the client and shouldn't be overwritten.
785
1330
  // Otherwise, incoming parts win (server is authoritative), but keep
786
1331
  // any extra parts from SSE that aren't in the fetch response.
787
1332
  const newParts = { ...s.parts };
1333
+ if (droppedPhantoms)
1334
+ for (const id of droppedPhantoms)
1335
+ delete newParts[id];
788
1336
  // When superseding an optimistic user message, bridge its parts to
789
1337
  // the real user message ID if the server hasn't sent parts yet.
790
1338
  // Mirrors the message.updated SSE handler (see above) — without
@@ -852,7 +1400,38 @@ export const useSyncStore = create()((set, get) => ({
852
1400
  }
853
1401
  return inP;
854
1402
  });
855
- for (const ep of extras) {
1403
+ // T16 dedupe extras by content identity. An "extra" is an
1404
+ // existing part whose id the incoming snapshot no longer has — the
1405
+ // server may simply not have persisted it yet (kept, as before), OR
1406
+ // the server RE-ISSUED the same content under a NEW part id (a real
1407
+ // defect: the old SSE-accumulated twin stayed in `exParts` forever,
1408
+ // duplicating the text inside this one message). Distinguish the two
1409
+ // conservatively: drop an extra only when it is text-like AND its own
1410
+ // accumulated text is a PREFIX of (or equal to) some incoming
1411
+ // text-like part of the SAME type — i.e. the incoming copy confidently
1412
+ // re-issues it, not merely resembles it. Non-text-like extras (tool,
1413
+ // permission, file, step, …) are never dropped by content — they carry
1414
+ // distinct identity per id and a coincidental text match doesn't apply
1415
+ // to them at all.
1416
+ //
1417
+ // F1 review finding: an extra still tracked in `deltaActiveParts` (this
1418
+ // session is actively applying deltas to it right now) is EXEMPT from
1419
+ // this filter regardless of what it prefixes. The heuristic above
1420
+ // assumes a text-prefix match means the server re-issued the SAME
1421
+ // content under a new id and the extra is an abandoned twin — but a
1422
+ // live streaming target is never abandoned, and dropping it here also
1423
+ // permanently blocks its later deltas (their event ids would already
1424
+ // be recorded as applied — see `applyPartDelta`'s not-found path).
1425
+ const survivingExtras = extras.filter((extra) => {
1426
+ if (hasTrackedId(deltaActiveParts, sessionID, extra.id))
1427
+ return true;
1428
+ if (!isTextLikePart(extra) || extra.text.length === 0)
1429
+ return true;
1430
+ return !inParts.some((inP) => inP.type === extra.type &&
1431
+ isTextLikePart(inP) &&
1432
+ inP.text.startsWith(extra.text));
1433
+ });
1434
+ for (const ep of survivingExtras) {
856
1435
  const r = Binary.search(reconciled, ep.id, (p) => p.id);
857
1436
  if (!r.found)
858
1437
  reconciled.splice(r.index, 0, ep);
@@ -867,8 +1446,13 @@ export const useSyncStore = create()((set, get) => ({
867
1446
  reset: () => {
868
1447
  optimisticIds.clear();
869
1448
  dispatchedOptimisticIds.clear();
1449
+ inboxBackedOptimisticIds.clear();
1450
+ optimisticEchoes.clear();
1451
+ optimisticOrigins.clear();
870
1452
  bridgedPartIds.clear();
871
1453
  deltaActiveParts.clear();
1454
+ deltaEventTails.clear();
1455
+ stubAssistantIds.clear();
872
1456
  // The data these holds protected is gone, so the holds are too —
873
1457
  // otherwise a late unmount could free a session the next page opened.
874
1458
  sessionConsumers.clear();
@@ -881,6 +1465,8 @@ export const useSyncStore = create()((set, get) => ({
881
1465
  sessionStatus: {},
882
1466
  diffs: {},
883
1467
  todos: {},
1468
+ sessionRevert: {},
1469
+ sessionRevertNeedsTailReconcile: {},
884
1470
  });
885
1471
  },
886
1472
  // ---- Selector: join messages + parts into MessageWithParts[] ----
@@ -908,10 +1494,26 @@ export const useSyncStore = create()((set, get) => ({
908
1494
  const info = event.properties.info;
909
1495
  if (!info?.sessionID)
910
1496
  return;
1497
+ // The user cancelled this message; the runtime's husk stays dead.
1498
+ if (cancelledMessageIds.get(info.sessionID)?.has(info.id))
1499
+ return;
911
1500
  // When a real user message arrives from the server, swap out the
912
1501
  // optimistic message(s) in a SINGLE atomic set() call.
913
1502
  // This prevents the intermediate render where the user bubble
914
1503
  // vanishes (optimistic removed) before the real one appears.
1504
+ if (info.role === "user" && isOptimistic(info.sessionID, info.id)) {
1505
+ // The echo under the optimistic message's OWN id (the host minted
1506
+ // the wire id and painted with it): confirmed in place. Release the
1507
+ // marks — it is an ordinary message from here — and bridge the
1508
+ // optimistic parts until the real ones arrive, so the bubble never
1509
+ // blinks empty and never shows both.
1510
+ releaseOptimisticId(info.sessionID, info.id);
1511
+ if (get().parts[info.id]?.length) {
1512
+ trackId(bridgedPartIds, info.sessionID, info.id);
1513
+ }
1514
+ store.upsertMessage(info.sessionID, info);
1515
+ return;
1516
+ }
915
1517
  if (info.role === "user" && !isOptimistic(info.sessionID, info.id)) {
916
1518
  const msgs = get().messages[info.sessionID];
917
1519
  if (msgs) {
@@ -957,13 +1559,30 @@ export const useSyncStore = create()((set, get) => ({
957
1559
  // matching: at `message.updated` time the confirmed message
958
1560
  // usually has no parts in the store yet (they arrive separately
959
1561
  // via `message.part.updated`).
960
- const matched = byPartId ?? optimisticUsers.find((m) => isDispatched(info.sessionID, m.id));
1562
+ // A pre-registered alias (`registerOptimisticEcho`) is an identity
1563
+ // match: the row named this echo id before it arrived.
1564
+ const byAlias = optimisticUsers.find((m) => optimisticEchoes.get(info.sessionID)?.get(m.id) === info.id);
1565
+ // The ordinal guess is only safe when there is exactly ONE
1566
+ // in-flight send it could be. With a burst in flight, a
1567
+ // part-less echo that matches neither a part id nor a
1568
+ // registered alias WAITS: the hydrate that follows carries
1569
+ // the parts (identity match), and consuming the oldest
1570
+ // bubble here handed one message's echo another message's
1571
+ // text (measured: the first bubble of a burst vanished).
1572
+ const eligible = optimisticUsers.filter((m) => isDispatched(info.sessionID, m.id) &&
1573
+ // An optimistic message whose OWN echo is known to be a
1574
+ // DIFFERENT id must not be consumed by someone else's.
1575
+ !optimisticEchoes.get(info.sessionID)?.get(m.id));
1576
+ const matched = byPartId ?? byAlias ?? (eligible.length === 1 ? eligible[0] : undefined);
1577
+ if (!matched && eligible.length > 1)
1578
+ return;
961
1579
  const optIds = matched ? [matched.id] : [];
962
1580
  if (optIds.length > 0) {
963
- // Clean up optimistic tracking
1581
+ // Clean up optimistic tracking, remembering the runtime id
1582
+ // each superseded message became.
964
1583
  for (const id of optIds) {
965
- untrackId(optimisticIds, info.sessionID, id);
966
- untrackId(dispatchedOptimisticIds, info.sessionID, id);
1584
+ releaseConfirmedOptimisticId(info.sessionID, id, info.id);
1585
+ recordOptimisticEcho(info.sessionID, id, info.id);
967
1586
  }
968
1587
  // Atomic: remove optimistic + insert real in one set()
969
1588
  set((s) => {
@@ -972,13 +1591,20 @@ export const useSyncStore = create()((set, get) => ({
972
1591
  const without = list.filter((m) => !optIds.includes(m.id));
973
1592
  // Insert the real message at sorted position
974
1593
  const r = Binary.search(without, info.id, (m) => m.id);
975
- const next = [...without];
1594
+ let next = [...without];
976
1595
  if (r.found) {
977
1596
  next[r.index] = info;
978
1597
  }
979
1598
  else {
980
1599
  next.splice(r.index, 0, info);
981
1600
  }
1601
+ // A turn that failed before this confirmation arrived
1602
+ // has a `session.error` stub keyed to the optimistic id
1603
+ // being retired here. Re-key it, or its error detaches
1604
+ // from the turn and drifts to the bottom of the thread.
1605
+ for (const id of optIds) {
1606
+ next = rekeyStubParent(info.sessionID, next, id, info.id);
1607
+ }
982
1608
  // Bridge optimistic parts to the real message ID so
983
1609
  // the user bubble never flickers empty while waiting
984
1610
  // for real parts to arrive via message.part.updated.
@@ -1010,6 +1636,8 @@ export const useSyncStore = create()((set, get) => ({
1010
1636
  const props = event.properties;
1011
1637
  if (!props.sessionID || !props.messageID)
1012
1638
  return;
1639
+ if (store.reclaimRemovedMessage(props.sessionID, props.messageID))
1640
+ return;
1013
1641
  store.removeMessage(props.sessionID, props.messageID);
1014
1642
  return;
1015
1643
  }
@@ -1102,7 +1730,10 @@ export const useSyncStore = create()((set, get) => ({
1102
1730
  [props.field]: "",
1103
1731
  });
1104
1732
  }
1105
- store.applyPartDelta(props.sessionID, props.messageID, props.partID, props.field, props.delta);
1733
+ // `event.id` is a top-level field of every wire event (see
1734
+ // `deltaEventTails` above) — never inside `properties`, so it is
1735
+ // read directly off `event`, not `props`.
1736
+ store.applyPartDelta(props.sessionID, props.messageID, props.partID, props.field, props.delta, event.id);
1106
1737
  if (props.field === "text") {
1107
1738
  const updated = get().parts[props.messageID]?.find((p) => p.id === props.partID);
1108
1739
  if (updated && isTextLikePart(updated) && updated.text.length > 0) {
@@ -1128,6 +1759,11 @@ export const useSyncStore = create()((set, get) => ({
1128
1759
  // still be streaming (see comment above deltaActiveParts).
1129
1760
  if (sessionID)
1130
1761
  deltaActiveParts.delete(sessionID);
1762
+ // Same reasoning for the delta event-id tails (T14): a new
1763
+ // turn's deltas use brand-new part ids anyway, so nothing realistic
1764
+ // is lost by dropping this session's tracking here.
1765
+ if (sessionID)
1766
+ deltaEventTails.delete(sessionID);
1131
1767
  return;
1132
1768
  }
1133
1769
  case "session.error": {
@@ -1141,43 +1777,119 @@ export const useSyncStore = create()((set, get) => ({
1141
1777
  // Clear only this session's delta tracking — see the idle handler
1142
1778
  // above and the comment above deltaActiveParts.
1143
1779
  deltaActiveParts.delete(sid);
1144
- // Patch the error onto the last assistant message in the sync store.
1145
- // If no assistant message exists yet, create a temporary one so the
1146
- // error is visible immediately. The event handler in
1147
- // use-opencode-events.ts will also fetch real messages from the
1148
- // server which will bring in the authoritative data via hydrate().
1780
+ deltaEventTails.delete(sid);
1781
+ // Attach the error to the TURN THAT FAILED, which is the turn the
1782
+ // last user message opened: `session.error` terminates the turn the
1783
+ // runtime was running, and the runtime runs one at a time.
1784
+ //
1785
+ // When that turn already produced an assistant message the error is
1786
+ // patched onto it, as before. When it produced none — the whole
1787
+ // class of failures that die before generation starts, e.g.
1788
+ // `ModelNotFound`, which arrives ~2ms after the prompt — a stub
1789
+ // assistant message stands in for it, parented to that user message
1790
+ // and positioned directly after it.
1791
+ //
1792
+ // It used to be "the last assistant message ANYWHERE, else append a
1793
+ // stub at the end", and both halves put the error in the wrong turn:
1794
+ // the patch landed on the previous turn's answer (where the
1795
+ // `reconcileTail` hydrate that follows every `session.error` then
1796
+ // overwrote it with the server's error-free copy, so the failure
1797
+ // rendered NOTHING), and the appended stub rode the bottom of the
1798
+ // thread, reappearing under whichever prompt came next.
1799
+ //
1800
+ // The event handler in use-opencode-events.ts also fetches real
1801
+ // messages from the server, which brings in the authoritative data
1802
+ // via hydrate().
1149
1803
  set((s) => {
1150
1804
  const msgs = s.messages[sid] ?? [];
1151
- // Find last assistant message and patch .error onto it
1805
+ // The prompt this error answers.
1806
+ let userIdx = -1;
1152
1807
  for (let i = msgs.length - 1; i >= 0; i--) {
1153
- const msg = msgs[i];
1154
- if (msg.role === "assistant") {
1155
- if (msg.error)
1156
- return s; // already has error
1157
- const next = [...msgs];
1158
- // `error` may be the client-synthesized `SyntheticAbortError`
1159
- // (see `MessageError`), which the SDK's own `AssistantMessage.error`
1160
- // union doesn't declare — the assertion is the documented, narrow
1161
- // exception for that one extra shape.
1162
- next[i] = { ...msg, error };
1163
- return { messages: { ...s.messages, [sid]: next } };
1808
+ if (msgs[i].role === "user") {
1809
+ userIdx = i;
1810
+ break;
1164
1811
  }
1165
1812
  }
1166
- // No assistant message yet create a stub so the error shows.
1167
- // Mark it as a client-side stub so hydrate can replace it.
1168
- const stubId = ascendingId("msg");
1813
+ const userId = userIdx === -1 ? null : msgs[userIdx].id;
1814
+ // An assistant message that already belongs to THAT turn takes
1815
+ // the error. Scanning back only as far as the user message is
1816
+ // what keeps an earlier turn's answer out of it.
1817
+ for (let i = msgs.length - 1; i > userIdx; i--) {
1818
+ const msg = msgs[i];
1819
+ if (msg.role !== "assistant")
1820
+ continue;
1821
+ if (userId && msg.parentID && msg.parentID !== userId)
1822
+ continue;
1823
+ if (msg.error)
1824
+ return s; // already has error
1825
+ const next = [...msgs];
1826
+ // `error` may be the client-synthesized `SyntheticAbortError`
1827
+ // (see `MessageError`), which the SDK's own `AssistantMessage.error`
1828
+ // union doesn't declare — the assertion is the documented, narrow
1829
+ // exception for that one extra shape.
1830
+ next[i] = { ...msg, error };
1831
+ return { messages: { ...s.messages, [sid]: next } };
1832
+ }
1833
+ // No assistant message for this turn yet — stand one in so the
1834
+ // error renders under the prompt it answers. Tracked in
1835
+ // `stubAssistantIds` (T16) with its parent so `hydrate` can
1836
+ // reconcile it away once the server's own transcript answers
1837
+ // THAT turn — see that map's doc comment and the reconciliation
1838
+ // in `hydrate` below.
1839
+ const stubId = userId ? stubIdFor(userId) : ascendingId("msg");
1840
+ if (msgs.some((m) => m.id === stubId))
1841
+ return s;
1842
+ trackStub(sid, stubId, userId);
1169
1843
  const stubMsg = {
1170
1844
  id: stubId,
1171
1845
  sessionID: sid,
1172
1846
  role: "assistant",
1847
+ ...(userId ? { parentID: userId } : {}),
1173
1848
  error,
1174
1849
  };
1175
- return {
1176
- messages: { ...s.messages, [sid]: [...msgs, stubMsg] },
1177
- };
1850
+ const next = [...msgs];
1851
+ next.splice(userIdx === -1 ? next.length : userIdx + 1, 0, stubMsg);
1852
+ return { messages: { ...s.messages, [sid]: next } };
1178
1853
  });
1179
1854
  return;
1180
1855
  }
1856
+ case "session.next.revert.staged": {
1857
+ const props = event.properties;
1858
+ if (props.sessionID && props.revert?.messageID) {
1859
+ store.stageSessionRevert(props.sessionID, props.revert.messageID);
1860
+ }
1861
+ return;
1862
+ }
1863
+ case "session.next.revert.cleared": {
1864
+ const props = event.properties;
1865
+ if (props.sessionID)
1866
+ store.clearSessionRevert(props.sessionID);
1867
+ return;
1868
+ }
1869
+ case "session.next.revert.committed": {
1870
+ const props = event.properties;
1871
+ if (props.sessionID && props.messageID) {
1872
+ const tracked = get().sessionRevert[props.sessionID];
1873
+ if (tracked) {
1874
+ store.applyCommittedRevert(props.sessionID, props.messageID, tracked.watermark);
1875
+ }
1876
+ else {
1877
+ // F2 — no tracked local record (fresh mount / second tab that
1878
+ // never saw `.staged`). The REMOVED fallback guessed a
1879
+ // watermark from `newestMessageId(local messages)`, which can
1880
+ // be the user's own REPLACEMENT prompt if its
1881
+ // `message.updated` arrived before this `.committed` event —
1882
+ // deleting the replacement and its answer. Do not guess: flag
1883
+ // this session for a tail reconcile (see
1884
+ // `markSessionRevertNeedsTailReconcile`'s doc comment) and
1885
+ // leave messages alone. The server's actual (already-
1886
+ // truncated) transcript arrives through whatever reads that
1887
+ // flag.
1888
+ store.markSessionRevertNeedsTailReconcile(props.sessionID);
1889
+ }
1890
+ }
1891
+ return;
1892
+ }
1181
1893
  case "session.diff": {
1182
1894
  const props = event.properties;
1183
1895
  if (props.sessionID)