@kortix/sdk 0.13.5 → 0.13.9

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 (253) hide show
  1. package/README.md +69 -6
  2. package/dist/browser/session-sync/server-transcript-mirror.d.ts +82 -0
  3. package/dist/browser/session-sync/server-transcript-mirror.d.ts.map +1 -0
  4. package/dist/browser/session-sync/server-transcript-mirror.js +143 -0
  5. package/dist/browser/session-sync/server-transcript-mirror.js.map +1 -0
  6. package/dist/browser/session-sync/session-sync-registry.d.ts +11 -21
  7. package/dist/browser/session-sync/session-sync-registry.d.ts.map +1 -1
  8. package/dist/browser/session-sync/session-sync-registry.js +48 -3
  9. package/dist/browser/session-sync/session-sync-registry.js.map +1 -1
  10. package/dist/browser/stores/sandbox-connection-store.d.ts +13 -1
  11. package/dist/browser/stores/sandbox-connection-store.d.ts.map +1 -1
  12. package/dist/browser/stores/sandbox-connection-store.js +5 -2
  13. package/dist/browser/stores/sandbox-connection-store.js.map +1 -1
  14. package/dist/browser/stores/session-working-store.d.ts +10 -5
  15. package/dist/browser/stores/session-working-store.d.ts.map +1 -1
  16. package/dist/browser/stores/session-working-store.js +32 -8
  17. package/dist/browser/stores/session-working-store.js.map +1 -1
  18. package/dist/browser/stores/sync-store.d.ts +29 -1
  19. package/dist/browser/stores/sync-store.d.ts.map +1 -1
  20. package/dist/browser/stores/sync-store.js +516 -393
  21. package/dist/browser/stores/sync-store.js.map +1 -1
  22. package/dist/core/auth/app-viewer.d.ts +57 -0
  23. package/dist/core/auth/app-viewer.d.ts.map +1 -0
  24. package/dist/core/auth/app-viewer.js +82 -0
  25. package/dist/core/auth/app-viewer.js.map +1 -0
  26. package/dist/core/auth/session.d.ts +45 -0
  27. package/dist/core/auth/session.d.ts.map +1 -0
  28. package/dist/core/auth/session.js +113 -0
  29. package/dist/core/auth/session.js.map +1 -0
  30. package/dist/core/client/kortix.d.ts +37 -18
  31. package/dist/core/client/kortix.d.ts.map +1 -1
  32. package/dist/core/client/kortix.js +47 -0
  33. package/dist/core/client/kortix.js.map +1 -1
  34. package/dist/core/http/config.d.ts +4 -2
  35. package/dist/core/http/config.d.ts.map +1 -1
  36. package/dist/core/http/config.js.map +1 -1
  37. package/dist/core/http/opencode-errors.d.ts +17 -0
  38. package/dist/core/http/opencode-errors.d.ts.map +1 -1
  39. package/dist/core/http/opencode-errors.js +25 -0
  40. package/dist/core/http/opencode-errors.js.map +1 -1
  41. package/dist/core/rest/platform-client/auth.d.ts +120 -0
  42. package/dist/core/rest/platform-client/auth.d.ts.map +1 -0
  43. package/dist/core/rest/platform-client/auth.js +98 -0
  44. package/dist/core/rest/platform-client/auth.js.map +1 -0
  45. package/dist/core/rest/platform-client/host-boundary.d.ts +5 -0
  46. package/dist/core/rest/platform-client/host-boundary.d.ts.map +1 -1
  47. package/dist/core/rest/platform-client/host-boundary.js.map +1 -1
  48. package/dist/core/rest/projects-client/accounts.d.ts +57 -0
  49. package/dist/core/rest/projects-client/accounts.d.ts.map +1 -1
  50. package/dist/core/rest/projects-client/accounts.js +31 -0
  51. package/dist/core/rest/projects-client/accounts.js.map +1 -1
  52. package/dist/core/rest/projects-client/apps.d.ts +9 -0
  53. package/dist/core/rest/projects-client/apps.d.ts.map +1 -1
  54. package/dist/core/rest/projects-client/apps.js.map +1 -1
  55. package/dist/core/rest/projects-client/billing.d.ts +2 -0
  56. package/dist/core/rest/projects-client/billing.d.ts.map +1 -1
  57. package/dist/core/rest/projects-client/billing.js.map +1 -1
  58. package/dist/core/rest/projects-client/connectors.d.ts +104 -19
  59. package/dist/core/rest/projects-client/connectors.d.ts.map +1 -1
  60. package/dist/core/rest/projects-client/connectors.js +56 -2
  61. package/dist/core/rest/projects-client/connectors.js.map +1 -1
  62. package/dist/core/rest/projects-client/index.d.ts +1 -0
  63. package/dist/core/rest/projects-client/index.d.ts.map +1 -1
  64. package/dist/core/rest/projects-client/index.js +1 -0
  65. package/dist/core/rest/projects-client/index.js.map +1 -1
  66. package/dist/core/rest/projects-client/oauth-clients.d.ts +47 -0
  67. package/dist/core/rest/projects-client/oauth-clients.d.ts.map +1 -0
  68. package/dist/core/rest/projects-client/oauth-clients.js +33 -0
  69. package/dist/core/rest/projects-client/oauth-clients.js.map +1 -0
  70. package/dist/core/rest/projects-client/projects.d.ts +23 -6
  71. package/dist/core/rest/projects-client/projects.d.ts.map +1 -1
  72. package/dist/core/rest/projects-client/projects.js +1 -0
  73. package/dist/core/rest/projects-client/projects.js.map +1 -1
  74. package/dist/core/rest/projects-client/session-sandbox.d.ts +52 -0
  75. package/dist/core/rest/projects-client/session-sandbox.d.ts.map +1 -1
  76. package/dist/core/rest/projects-client/session-sandbox.js.map +1 -1
  77. package/dist/core/rest/projects-client/sessions.d.ts +134 -1
  78. package/dist/core/rest/projects-client/sessions.d.ts.map +1 -1
  79. package/dist/core/rest/projects-client/sessions.js +47 -1
  80. package/dist/core/rest/projects-client/sessions.js.map +1 -1
  81. package/dist/core/session/index.d.ts +1 -0
  82. package/dist/core/session/index.d.ts.map +1 -1
  83. package/dist/core/session/index.js +1 -0
  84. package/dist/core/session/index.js.map +1 -1
  85. package/dist/core/session/open-bundle.d.ts +110 -0
  86. package/dist/core/session/open-bundle.d.ts.map +1 -0
  87. package/dist/core/session/open-bundle.js +183 -0
  88. package/dist/core/session/open-bundle.js.map +1 -0
  89. package/dist/core/session/poll-owner.d.ts +35 -0
  90. package/dist/core/session/poll-owner.d.ts.map +1 -0
  91. package/dist/core/session/poll-owner.js +90 -0
  92. package/dist/core/session/poll-owner.js.map +1 -0
  93. package/dist/core/session/static-file-preview.d.ts +50 -0
  94. package/dist/core/session/static-file-preview.d.ts.map +1 -0
  95. package/dist/core/session/static-file-preview.js +91 -0
  96. package/dist/core/session/static-file-preview.js.map +1 -0
  97. package/dist/core/session/wake-escalation.d.ts +188 -0
  98. package/dist/core/session/wake-escalation.d.ts.map +1 -0
  99. package/dist/core/session/wake-escalation.js +241 -0
  100. package/dist/core/session/wake-escalation.js.map +1 -0
  101. package/dist/core/session/working.d.ts +63 -2
  102. package/dist/core/session/working.d.ts.map +1 -1
  103. package/dist/core/session/working.js +66 -9
  104. package/dist/core/session/working.js.map +1 -1
  105. package/dist/core/session-sync/session-sync-controller.d.ts +34 -2
  106. package/dist/core/session-sync/session-sync-controller.d.ts.map +1 -1
  107. package/dist/core/session-sync/session-sync-controller.js +97 -11
  108. package/dist/core/session-sync/session-sync-controller.js.map +1 -1
  109. package/dist/core/stream/keepalive.d.ts +17 -0
  110. package/dist/core/stream/keepalive.d.ts.map +1 -0
  111. package/dist/core/stream/keepalive.js +19 -0
  112. package/dist/core/stream/keepalive.js.map +1 -0
  113. package/dist/core/turns/grouping.d.ts.map +1 -1
  114. package/dist/core/turns/grouping.js +13 -0
  115. package/dist/core/turns/grouping.js.map +1 -1
  116. package/dist/core/turns/types.d.ts +5 -0
  117. package/dist/core/turns/types.d.ts.map +1 -1
  118. package/dist/index.d.ts +15 -1
  119. package/dist/index.d.ts.map +1 -1
  120. package/dist/index.js +18 -0
  121. package/dist/index.js.map +1 -1
  122. package/dist/kortix.esm.min.js +25 -25
  123. package/dist/kortix.global.js +25 -25
  124. package/dist/node/app-viewer.d.ts +41 -0
  125. package/dist/node/app-viewer.d.ts.map +1 -0
  126. package/dist/node/app-viewer.js +150 -0
  127. package/dist/node/app-viewer.js.map +1 -0
  128. package/dist/node/auth.d.ts +84 -0
  129. package/dist/node/auth.d.ts.map +1 -0
  130. package/dist/node/auth.js +414 -0
  131. package/dist/node/auth.js.map +1 -0
  132. package/dist/node/server.d.ts +19 -0
  133. package/dist/node/server.d.ts.map +1 -1
  134. package/dist/node/server.js +13 -1
  135. package/dist/node/server.js.map +1 -1
  136. package/dist/react/bedrock-invokable.d.ts +58 -0
  137. package/dist/react/bedrock-invokable.d.ts.map +1 -0
  138. package/dist/react/bedrock-invokable.js +83 -0
  139. package/dist/react/bedrock-invokable.js.map +1 -0
  140. package/dist/react/index.d.ts +3 -0
  141. package/dist/react/index.d.ts.map +1 -1
  142. package/dist/react/index.js +7 -0
  143. package/dist/react/index.js.map +1 -1
  144. package/dist/react/model-flatten.d.ts +14 -1
  145. package/dist/react/model-flatten.d.ts.map +1 -1
  146. package/dist/react/model-flatten.js +3 -4
  147. package/dist/react/model-flatten.js.map +1 -1
  148. package/dist/react/opencode.d.ts +2 -1
  149. package/dist/react/opencode.d.ts.map +1 -1
  150. package/dist/react/opencode.js +2 -1
  151. package/dist/react/opencode.js.map +1 -1
  152. package/dist/react/provider-selection.d.ts +58 -0
  153. package/dist/react/provider-selection.d.ts.map +1 -1
  154. package/dist/react/provider-selection.js +253 -2
  155. package/dist/react/provider-selection.js.map +1 -1
  156. package/dist/react/session-start-giveup.d.ts +12 -0
  157. package/dist/react/session-start-giveup.d.ts.map +1 -0
  158. package/dist/react/session-start-giveup.js +13 -0
  159. package/dist/react/session-start-giveup.js.map +1 -0
  160. package/dist/react/session-title-sync.d.ts.map +1 -1
  161. package/dist/react/session-title-sync.js +15 -5
  162. package/dist/react/session-title-sync.js.map +1 -1
  163. package/dist/react/sign-in-with-kortix.d.ts +346 -0
  164. package/dist/react/sign-in-with-kortix.d.ts.map +1 -0
  165. package/dist/react/sign-in-with-kortix.js +65 -0
  166. package/dist/react/sign-in-with-kortix.js.map +1 -0
  167. package/dist/react/use-admin-accounts.d.ts +1 -1
  168. package/dist/react/use-admin-accounts.d.ts.map +1 -1
  169. package/dist/react/use-admin-accounts.js +1 -0
  170. package/dist/react/use-admin-accounts.js.map +1 -1
  171. package/dist/react/use-gateway-routing-policy.d.ts +1 -0
  172. package/dist/react/use-gateway-routing-policy.d.ts.map +1 -1
  173. package/dist/react/use-gateway-routing-policy.js +12 -1
  174. package/dist/react/use-gateway-routing-policy.js.map +1 -1
  175. package/dist/react/use-kortix-app-viewer.d.ts +13 -0
  176. package/dist/react/use-kortix-app-viewer.d.ts.map +1 -0
  177. package/dist/react/use-kortix-app-viewer.js +29 -0
  178. package/dist/react/use-kortix-app-viewer.js.map +1 -0
  179. package/dist/react/use-model-defaults.d.ts +7 -0
  180. package/dist/react/use-model-defaults.d.ts.map +1 -1
  181. package/dist/react/use-model-defaults.js +7 -1
  182. package/dist/react/use-model-defaults.js.map +1 -1
  183. package/dist/react/use-model-enablement.d.ts.map +1 -1
  184. package/dist/react/use-model-enablement.js +5 -1
  185. package/dist/react/use-model-enablement.js.map +1 -1
  186. package/dist/react/use-model-store.d.ts +31 -0
  187. package/dist/react/use-model-store.d.ts.map +1 -1
  188. package/dist/react/use-model-store.js +31 -2
  189. package/dist/react/use-model-store.js.map +1 -1
  190. package/dist/react/use-opencode-events/helpers.d.ts +49 -5
  191. package/dist/react/use-opencode-events/helpers.d.ts.map +1 -1
  192. package/dist/react/use-opencode-events/helpers.js +54 -6
  193. package/dist/react/use-opencode-events/helpers.js.map +1 -1
  194. package/dist/react/use-opencode-events/index.d.ts.map +1 -1
  195. package/dist/react/use-opencode-events/index.js +22 -3
  196. package/dist/react/use-opencode-events/index.js.map +1 -1
  197. package/dist/react/use-opencode-events/use-event-stream-refs.d.ts.map +1 -1
  198. package/dist/react/use-opencode-events/use-event-stream-refs.js +14 -2
  199. package/dist/react/use-opencode-events/use-event-stream-refs.js.map +1 -1
  200. package/dist/react/use-opencode-local.d.ts.map +1 -1
  201. package/dist/react/use-opencode-local.js +27 -8
  202. package/dist/react/use-opencode-local.js.map +1 -1
  203. package/dist/react/use-opencode-sessions/providers.d.ts.map +1 -1
  204. package/dist/react/use-opencode-sessions/providers.js +44 -3
  205. package/dist/react/use-opencode-sessions/providers.js.map +1 -1
  206. package/dist/react/use-poll-owner.d.ts +14 -0
  207. package/dist/react/use-poll-owner.d.ts.map +1 -0
  208. package/dist/react/use-poll-owner.js +32 -0
  209. package/dist/react/use-poll-owner.js.map +1 -0
  210. package/dist/react/use-project-llm-gateway.d.ts +27 -0
  211. package/dist/react/use-project-llm-gateway.d.ts.map +1 -0
  212. package/dist/react/use-project-llm-gateway.js +41 -0
  213. package/dist/react/use-project-llm-gateway.js.map +1 -0
  214. package/dist/react/use-project-models.d.ts +9 -0
  215. package/dist/react/use-project-models.d.ts.map +1 -1
  216. package/dist/react/use-project-models.js +24 -1
  217. package/dist/react/use-project-models.js.map +1 -1
  218. package/dist/react/use-runtime-reconnect.d.ts +11 -0
  219. package/dist/react/use-runtime-reconnect.d.ts.map +1 -1
  220. package/dist/react/use-runtime-reconnect.js +34 -3
  221. package/dist/react/use-runtime-reconnect.js.map +1 -1
  222. package/dist/react/use-session-prompts.d.ts +16 -1
  223. package/dist/react/use-session-prompts.d.ts.map +1 -1
  224. package/dist/react/use-session-prompts.js +76 -11
  225. package/dist/react/use-session-prompts.js.map +1 -1
  226. package/dist/react/use-session-send.d.ts.map +1 -1
  227. package/dist/react/use-session-send.js +14 -1
  228. package/dist/react/use-session-send.js.map +1 -1
  229. package/dist/react/use-session-start-give-up.d.ts +20 -0
  230. package/dist/react/use-session-start-give-up.d.ts.map +1 -0
  231. package/dist/react/use-session-start-give-up.js +60 -0
  232. package/dist/react/use-session-start-give-up.js.map +1 -0
  233. package/dist/react/use-session-sync.d.ts +22 -0
  234. package/dist/react/use-session-sync.d.ts.map +1 -1
  235. package/dist/react/use-session-sync.js +61 -5
  236. package/dist/react/use-session-sync.js.map +1 -1
  237. package/dist/react/use-session-working.d.ts +38 -0
  238. package/dist/react/use-session-working.d.ts.map +1 -1
  239. package/dist/react/use-session-working.js +81 -10
  240. package/dist/react/use-session-working.js.map +1 -1
  241. package/dist/react/use-session.d.ts +13 -13
  242. package/dist/react/use-session.d.ts.map +1 -1
  243. package/dist/react/use-session.js +74 -53
  244. package/dist/react/use-session.js.map +1 -1
  245. package/dist/react/use-static-file-preview.d.ts +12 -0
  246. package/dist/react/use-static-file-preview.d.ts.map +1 -0
  247. package/dist/react/use-static-file-preview.js +119 -0
  248. package/dist/react/use-static-file-preview.js.map +1 -0
  249. package/dist/react/use-wake-escalation.d.ts +51 -0
  250. package/dist/react/use-wake-escalation.d.ts.map +1 -0
  251. package/dist/react/use-wake-escalation.js +62 -0
  252. package/dist/react/use-wake-escalation.js.map +1 -0
  253. package/package.json +6 -4
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
  import { create } from "zustand";
3
3
  import { commitSessionRewind, isWithinRewindWindow, stageSessionRewind, } from "../../core/session/rewind.js";
4
+ import { isRetryableTurnError } from "../../core/turns/open-turn.js";
4
5
  import { ascendingId } from "./sync-store/ascending-id.js";
5
6
  import { Binary } from "./sync-store/binary.js";
6
7
  import { writeStreamCache } from "./sync-store/stream-cache.js";
@@ -32,6 +33,19 @@ export function sameSessionStatus(a, b) {
32
33
  return true;
33
34
  return a.attempt === b.attempt && a.message === b.message && a.next === b.next;
34
35
  }
36
+ /**
37
+ * WHO minted a status-bearing event handed to `applyEvent`. A tab-synthesized
38
+ * event (`markSessionIdleLocally`, `markSessionAbortedLocally`, the hydrate
39
+ * snapshot fill) carries `synthetic: true` — a field no wire `Event` has — and
40
+ * its status write lands with `'local'` origin so it can never contradict the
41
+ * control plane's turn authority (`WorkingStreamInput.origin`). Everything off
42
+ * the wire stays `'wire'`.
43
+ */
44
+ function syntheticEventOrigin(event) {
45
+ return event?.synthetic === true
46
+ ? "local"
47
+ : "wire";
48
+ }
35
49
  function isTextLikePart(part) {
36
50
  return part.type === "text" || part.type === "reasoning";
37
51
  }
@@ -631,6 +645,8 @@ export const useSyncStore = create()((set, get) => ({
631
645
  messages: {},
632
646
  parts: {},
633
647
  sessionStatus: {},
648
+ sessionStatusOrigin: {},
649
+ sessionStatusAt: {},
634
650
  sessionActivityAt: {},
635
651
  diffs: {},
636
652
  todos: {},
@@ -834,16 +850,32 @@ export const useSyncStore = create()((set, get) => ({
834
850
  return { parts: { ...s.parts, [messageID]: next } };
835
851
  });
836
852
  },
837
- setStatus: (sessionID, status) => set((s) => {
853
+ setStatus: (sessionID, status, origin = "wire") => set((s) => {
838
854
  // A value that did not change is not news. `useSessionWorking` stamps
839
855
  // its stream observation from this object's IDENTITY, so an
840
856
  // equal-valued rewrite that minted a new object re-started
841
857
  // `STREAM_OBSERVATION_MAX_MS` from zero on every write — the bound
842
858
  // that stops a dead stream from deciding was never reached. See
843
859
  // `sameSessionStatus`.
844
- if (sameSessionStatus(s.sessionStatus[sessionID], status))
845
- return s;
846
- return { sessionStatus: { ...s.sessionStatus, [sessionID]: status } };
860
+ if (sameSessionStatus(s.sessionStatus[sessionID], status)) {
861
+ // The VALUE is not news, but who said it can be: a wire frame
862
+ // landing over a fabricated one (or the reverse) changes what the
863
+ // frame is allowed to decide. Update only the origin — the status
864
+ // object keeps its identity so nothing re-stamps a stale value.
865
+ // The arrival stamp DOES move: an origin flip is a new observation
866
+ // (`use-session-working` re-stamps on it for the same reason).
867
+ if ((s.sessionStatusOrigin[sessionID] ?? "wire") === origin)
868
+ return s;
869
+ return {
870
+ sessionStatusOrigin: { ...s.sessionStatusOrigin, [sessionID]: origin },
871
+ sessionStatusAt: { ...s.sessionStatusAt, [sessionID]: Date.now() },
872
+ };
873
+ }
874
+ return {
875
+ sessionStatus: { ...s.sessionStatus, [sessionID]: status },
876
+ sessionStatusOrigin: { ...s.sessionStatusOrigin, [sessionID]: origin },
877
+ sessionStatusAt: { ...s.sessionStatusAt, [sessionID]: Date.now() },
878
+ };
847
879
  }),
848
880
  setDiff: (sessionID, diffs) => set((s) => ({
849
881
  diffs: { ...s.diffs, [sessionID]: diffs },
@@ -986,11 +1018,27 @@ export const useSyncStore = create()((set, get) => ({
986
1018
  get().removeMessage(sessionID, messageID);
987
1019
  },
988
1020
  registerOptimisticEcho: (sessionID, optimisticID, echoID) => {
989
- if (!isOptimistic(sessionID, optimisticID))
990
- return;
991
1021
  if (optimisticID === echoID)
992
1022
  return;
993
- recordOptimisticEcho(sessionID, optimisticID, echoID);
1023
+ // The caller always names the id the ROW reports as this prompt's origin
1024
+ // — its `wire_message_id`, the id this tab minted and painted. That id
1025
+ // stops being optimistic the moment the FIRST echo supersedes it, and
1026
+ // after a Stop takes the prompt back out of the runtime the bubble on
1027
+ // screen is that echo (`reclaimRemovedMessage` re-marks it optimistic),
1028
+ // not the wire id. Registering against the wire id alone therefore did
1029
+ // nothing on every second and later re-mint, and the drain re-mints on
1030
+ // every one of them: the re-delivery's echo then matched no alias, and
1031
+ // with more than one send in flight the ordinal fallback correctly
1032
+ // refuses to guess — so the echo was dropped and the prompt stayed on
1033
+ // screen as a bubble nothing would ever confirm.
1034
+ //
1035
+ // Follow the chain to the id that IS on screen and register from there.
1036
+ const live = isOptimistic(sessionID, optimisticID)
1037
+ ? optimisticID
1038
+ : optimisticEchoes.get(sessionID)?.get(optimisticID);
1039
+ if (!live || live === echoID || !isOptimistic(sessionID, live))
1040
+ return;
1041
+ recordOptimisticEcho(sessionID, live, echoID);
994
1042
  },
995
1043
  reclaimRemovedMessage: (sessionID, messageID) => {
996
1044
  // See `controlPlaneOwnedIds`: a removal of a message the control plane
@@ -1092,6 +1140,11 @@ export const useSyncStore = create()((set, get) => ({
1092
1140
  parts: nextParts,
1093
1141
  sessionActivityAt: nextActivity,
1094
1142
  sessionStatus: { ...s.sessionStatus, [sessionID]: { type: "idle" } },
1143
+ // Fabricated, not observed — a cache handoff says nothing about
1144
+ // whether the runtime is running. Marked local so it can never
1145
+ // veto the control plane's open turn (`WorkingStreamInput.origin`).
1146
+ sessionStatusOrigin: { ...s.sessionStatusOrigin, [sessionID]: "local" },
1147
+ sessionStatusAt: { ...s.sessionStatusAt, [sessionID]: Date.now() },
1095
1148
  diffs: { ...s.diffs, [sessionID]: [] },
1096
1149
  todos: { ...s.todos, [sessionID]: [] },
1097
1150
  sessionRevert: { ...s.sessionRevert, [sessionID]: null },
@@ -1173,412 +1226,464 @@ export const useSyncStore = create()((set, get) => ({
1173
1226
  touchSessionMessageRows(sessionID, { msgs, partRefs, result });
1174
1227
  return result;
1175
1228
  },
1176
- hydrate: (sessionID, msgs, opts) => set((s) => {
1177
- // An authoritative load the disk repaint itself, or a reconcile
1178
- // re-establishes the session, so its entry is no longer a fragment.
1179
- evictedSessions.delete(sessionID);
1180
- const tombstones = cancelledMessageIds.get(sessionID);
1181
- // NOT re-sorted. `MessageV2.page()` orders by `time_created`
1182
- // server-side and always has, so the snapshot arrives in the only
1183
- // order that is true. Re-deriving one from id strings was wrong the
1184
- // moment OpenCode 1.18.15 retired "ids ascend with time" — and the
1185
- // mobile store did the same re-sort with `localeCompare`, which is
1186
- // not even byte order, so the two hosts disagreed on identical data.
1187
- const incoming = msgs
1188
- .filter((m) => !!m?.info?.id && !tombstones?.has(m.info.id))
1189
- .map((m) => m.info);
1190
- const fromCache = opts?.source === "cache";
1191
- if (fromCache) {
1192
- for (const m of incoming)
1193
- trackId(cacheSourcedIds, sessionID, m.id);
1194
- }
1195
- // A RUNTIME read settles the cache's provisional messages: the ones
1196
- // it contains are real; the ones it lacks but whose position it
1197
- // COVERS (at or above the oldest message it returned) never existed
1198
- // there and are dropped. Older ones are history the bounded tail did
1199
- // not reach — kept, still provisional.
1200
- //
1201
- // The bound is derived by VALUE, not by position: `incoming` is now the
1202
- // server's own page order, which is not an id order, so `incoming[0].id`
1203
- // is no longer the smallest id in the page. (That this comparison is an
1204
- // id comparison at all is a separate open question see B-note in
1205
- // PROGRESS.md — deliberately left as-is here.)
1206
- let droppedPhantoms = null;
1207
- const provisional = fromCache ? undefined : cacheSourcedIds.get(sessionID);
1208
- if (provisional && provisional.size > 0 && incoming.length > 0) {
1209
- let oldestIncoming = incoming[0].id;
1210
- for (const m of incoming)
1211
- if (m.id < oldestIncoming)
1212
- oldestIncoming = m.id;
1213
- const incomingIds = new Set(incoming.map((m) => m.id));
1214
- for (const id of [...provisional]) {
1215
- if (incomingIds.has(id)) {
1216
- untrackId(cacheSourcedIds, sessionID, id);
1217
- continue;
1218
- }
1219
- if (id >= oldestIncoming && !isOptimistic(sessionID, id)) {
1220
- untrackId(cacheSourcedIds, sessionID, id);
1221
- (droppedPhantoms ??= new Set()).add(id);
1229
+ hydrate: (sessionID, msgs, opts) => {
1230
+ // Set inside the updater below when a RUNTIME read shows the transcript
1231
+ // moved while its tail is still open the runtime's own output reaching
1232
+ // this tab by pull instead of push. Stamped after the set() commits.
1233
+ let stampRuntimeActivity = false;
1234
+ set((s) => {
1235
+ // An authoritative load the disk repaint itself, or a reconcile —
1236
+ // re-establishes the session, so its entry is no longer a fragment.
1237
+ evictedSessions.delete(sessionID);
1238
+ const tombstones = cancelledMessageIds.get(sessionID);
1239
+ // NOT re-sorted. `MessageV2.page()` orders by `time_created`
1240
+ // server-side and always has, so the snapshot arrives in the only
1241
+ // order that is true. Re-deriving one from id strings was wrong the
1242
+ // moment OpenCode 1.18.15 retired "ids ascend with time" — and the
1243
+ // mobile store did the same re-sort with `localeCompare`, which is
1244
+ // not even byte order, so the two hosts disagreed on identical data.
1245
+ const incoming = msgs
1246
+ .filter((m) => !!m?.info?.id && !tombstones?.has(m.info.id))
1247
+ .map((m) => m.info);
1248
+ const fromCache = opts?.source === "cache";
1249
+ if (fromCache) {
1250
+ for (const m of incoming)
1251
+ trackId(cacheSourcedIds, sessionID, m.id);
1252
+ }
1253
+ // A RUNTIME read settles the cache's provisional messages: the ones
1254
+ // it contains are real; the ones it lacks but whose position it
1255
+ // COVERS (at or above the oldest message it returned) never existed
1256
+ // there and are dropped. Older ones are history the bounded tail did
1257
+ // not reachkept, still provisional.
1258
+ //
1259
+ // The bound is derived by VALUE, not by position: `incoming` is now the
1260
+ // server's own page order, which is not an id order, so `incoming[0].id`
1261
+ // is no longer the smallest id in the page. (That this comparison is an
1262
+ // id comparison at all is a separate open question — see B-note in
1263
+ // deliberately left as-is here.)
1264
+ let droppedPhantoms = null;
1265
+ const provisional = fromCache ? undefined : cacheSourcedIds.get(sessionID);
1266
+ if (provisional && provisional.size > 0 && incoming.length > 0) {
1267
+ let oldestIncoming = incoming[0].id;
1268
+ for (const m of incoming)
1269
+ if (m.id < oldestIncoming)
1270
+ oldestIncoming = m.id;
1271
+ const incomingIds = new Set(incoming.map((m) => m.id));
1272
+ for (const id of [...provisional]) {
1273
+ if (incomingIds.has(id)) {
1274
+ untrackId(cacheSourcedIds, sessionID, id);
1275
+ continue;
1276
+ }
1277
+ if (id >= oldestIncoming && !isOptimistic(sessionID, id)) {
1278
+ untrackId(cacheSourcedIds, sessionID, id);
1279
+ (droppedPhantoms ??= new Set()).add(id);
1280
+ }
1222
1281
  }
1223
1282
  }
1224
- }
1225
- // T16 a `session.error` stub assistant message is reconciled
1226
- // PER TURN, after the optimistic correlation below has run (see
1227
- // `pendingStubs`). It only ever stood in for the assistant message
1228
- // its own turn never produced, so the question is not "does this
1229
- // session have an assistant message" an earlier turn's answer is
1230
- // no answer at all but "does the server now answer THAT turn".
1231
- // Re-adopt a stub that arrives IN the snapshot. The transcript cache
1232
- // mirrors `messages[sessionID]` to IndexedDB, stubs included, so the
1233
- // first paint after a reload brings one back — while
1234
- // `stubAssistantIds` (module state) did not survive the reload. An
1235
- // unadopted stub is unreconcilable: it would sit beside the server's
1236
- // own reply forever. Only the runtime ever produces these ids, so
1237
- // nothing else can be mistaken for one.
1238
- for (const info of incoming) {
1239
- if (info.role !== "assistant" || !isStubShaped(info.id))
1240
- continue;
1241
- if (!info.error)
1242
- continue;
1243
- if (stubAssistantIds.get(sessionID)?.has(info.id))
1244
- continue;
1245
- trackStub(sessionID, info.id, info.parentID ?? null);
1246
- }
1247
- const trackedStubIds = stubAssistantIds.get(sessionID);
1248
- /** Stubs held back from the merge below until their parent turn is
1249
- * known (an optimistic parent may be superseded by the server's own
1250
- * copy in this very snapshot). */
1251
- const pendingStubs = [];
1252
- // Merge incoming messages with existing ones — never delete messages
1253
- // that exist in the sync store but are missing from the fetch (they may
1254
- // be from a newer turn that the server hasn't persisted yet).
1255
- const existingAll = s.messages[sessionID] ?? [];
1256
- const existing = droppedPhantoms
1257
- ? existingAll.filter((m) => !droppedPhantoms.has(m.id))
1258
- : existingAll;
1259
- const merged = [];
1260
- const seen = new Set();
1261
- // Which optimistic user messages has the server actually echoed?
1262
- //
1263
- // This test used to be EXISTENTIAL: "if this page holds any real user
1264
- // message at all, every optimistic user message is a duplicate". In an
1265
- // ongoing conversation that is always true — the page is full of
1266
- // earlier turns — so a message sent one second ago, which the server
1267
- // had provably never received, was deleted along with its text. The
1268
- // window was wide open by construction: the rehydrate that calls this
1269
- // only runs for sessions whose status is `busy`, and the optimistic
1270
- // add is what sets `busy`. Sending armed the thing that deleted the
1271
- // send.
1272
- //
1273
- // The rule is now identity-based, in two passes.
1274
- const existingIds = new Set(existing.map((m) => m.id));
1275
- // The echo may arrive under the optimistic message's OWN id: the host
1276
- // minted the wire id and painted the bubble with it. That is a
1277
- // confirmation, not a duplicate — release the marks so the message is
1278
- // ordinary from here (the sweep leaves it alone, real parts win), and
1279
- // bridge its optimistic parts until real ones land.
1280
- for (const m of incoming) {
1281
- if (m.role !== "user" || !isOptimistic(sessionID, m.id))
1282
- continue;
1283
- releaseConfirmedOptimisticId(sessionID, m.id, m.id);
1284
- const optimisticParts = s.parts[m.id];
1285
- const entry = msgs.find((x) => x.info.id === m.id);
1286
- if ((entry?.parts?.length ?? 0) === 0 && optimisticParts?.length) {
1287
- trackId(bridgedPartIds, sessionID, m.id);
1283
+ // T16 — a `session.error` stub assistant message is reconciled
1284
+ // PER TURN, after the optimistic correlation below has run (see
1285
+ // `pendingStubs`). It only ever stood in for the assistant message
1286
+ // its own turn never produced, so the question is not "does this
1287
+ // session have an assistant message" an earlier turn's answer is
1288
+ // no answer at all — but "does the server now answer THAT turn".
1289
+ // Re-adopt a stub that arrives IN the snapshot. The transcript cache
1290
+ // mirrors `messages[sessionID]` to IndexedDB, stubs included, so the
1291
+ // first paint after a reload brings one back — while
1292
+ // `stubAssistantIds` (module state) did not survive the reload. An
1293
+ // unadopted stub is unreconcilable: it would sit beside the server's
1294
+ // own reply forever. Only the runtime ever produces these ids, so
1295
+ // nothing else can be mistaken for one.
1296
+ for (const info of incoming) {
1297
+ if (info.role !== "assistant" || !isStubShaped(info.id))
1298
+ continue;
1299
+ if (!info.error)
1300
+ continue;
1301
+ if (stubAssistantIds.get(sessionID)?.has(info.id))
1302
+ continue;
1303
+ trackStub(sessionID, info.id, info.parentID ?? null);
1288
1304
  }
1289
- }
1290
- // Pass 0 candidate echoes. A real user message we have NEVER seen
1291
- // before may be the server's copy of something still in flight. One we
1292
- // already held is history, and history cannot supersede the present.
1293
- // This single condition is what fixes the reported bug.
1294
- const candidateEchoes = incoming.filter((m) => m.role === "user" &&
1295
- !isOptimistic(sessionID, m.id) &&
1296
- !existingIds.has(m.id));
1297
- const unclaimedEchoes = new Set(candidateEchoes.map((m) => m.id));
1298
- // Pass 1 — exact correlation by part id. Hosts generate the text part
1299
- // id up front and send it WITH the prompt precisely so the echo
1300
- // updates the same part, which makes this an identity match rather
1301
- // than a guess.
1302
- const echoByPartId = new Map();
1303
- for (const echo of candidateEchoes) {
1304
- const entry = msgs.find((m) => m.info.id === echo.id);
1305
- for (const p of entry?.parts ?? []) {
1306
- if (p?.id)
1307
- echoByPartId.set(p.id, echo.id);
1305
+ const trackedStubIds = stubAssistantIds.get(sessionID);
1306
+ /** Stubs held back from the merge below until their parent turn is
1307
+ * known (an optimistic parent may be superseded by the server's own
1308
+ * copy in this very snapshot). */
1309
+ const pendingStubs = [];
1310
+ // Merge incoming messages with existing ones — never delete messages
1311
+ // that exist in the sync store but are missing from the fetch (they may
1312
+ // be from a newer turn that the server hasn't persisted yet).
1313
+ const existingAll = s.messages[sessionID] ?? [];
1314
+ const existing = droppedPhantoms
1315
+ ? existingAll.filter((m) => !droppedPhantoms.has(m.id))
1316
+ : existingAll;
1317
+ const merged = [];
1318
+ const seen = new Set();
1319
+ // Which optimistic user messages has the server actually echoed?
1320
+ //
1321
+ // This test used to be EXISTENTIAL: "if this page holds any real user
1322
+ // message at all, every optimistic user message is a duplicate". In an
1323
+ // ongoing conversation that is always true — the page is full of
1324
+ // earlier turns — so a message sent one second ago, which the server
1325
+ // had provably never received, was deleted along with its text. The
1326
+ // window was wide open by construction: the rehydrate that calls this
1327
+ // only runs for sessions whose status is `busy`, and the optimistic
1328
+ // add is what sets `busy`. Sending armed the thing that deleted the
1329
+ // send.
1330
+ //
1331
+ // The rule is now identity-based, in two passes.
1332
+ const existingIds = new Set(existing.map((m) => m.id));
1333
+ // ---- Runtime-read activity evidence. ----
1334
+ // Movement on a transcript this tab ALREADY held, with the tail still
1335
+ // open, observed by a runtime read: the runtime is producing output
1336
+ // right now, whatever the stream's last status frame claimed. Guards,
1337
+ // each load-bearing (asserted in the test file): an initial fill is
1338
+ // history, not movement; a completed tail is a finished turn and must
1339
+ // not paint busy on a returning tab; a cache repaint is this tab's own
1340
+ // disk, not the runtime speaking.
1341
+ if (!fromCache && existingAll.length > 0) {
1342
+ const tail = incoming[incoming.length - 1];
1343
+ const tailOpen = !!tail &&
1344
+ tail.role === "assistant" &&
1345
+ !tail.time?.completed &&
1346
+ !tail.error;
1347
+ if (tailOpen) {
1348
+ stampRuntimeActivity =
1349
+ incoming.some((m) => !existingIds.has(m.id)) ||
1350
+ msgs.some((entry) => {
1351
+ const mid = entry?.info?.id;
1352
+ if (!mid || isOptimistic(sessionID, mid) || !existingIds.has(mid)) {
1353
+ return false;
1354
+ }
1355
+ const prev = s.parts[mid];
1356
+ if (!prev || prev.length === 0)
1357
+ return (entry.parts?.length ?? 0) > 0;
1358
+ const prevById = new Map(prev.map((p) => [p.id, p]));
1359
+ return (entry.parts ?? []).some((p) => {
1360
+ if (!p?.id)
1361
+ return false;
1362
+ const prevPart = prevById.get(p.id);
1363
+ if (!prevPart)
1364
+ return true;
1365
+ return (isTextLikePart(p) &&
1366
+ isTextLikePart(prevPart) &&
1367
+ (p.text?.length ?? 0) > (prevPart.text?.length ?? 0));
1368
+ });
1369
+ });
1370
+ }
1308
1371
  }
1309
- }
1310
- // Start with all incoming messages
1311
- for (const m of incoming) {
1312
- merged.push(m);
1313
- seen.add(m.id);
1314
- }
1315
- // Add any existing messages not in incoming (optimistic or from live SSE).
1316
- // Optimistic messages go at the end to avoid clock-skew sorting issues;
1317
- // non-optimistic ones are inserted at their sorted position.
1318
- const deferredOptimistic = [];
1319
- const supersededOptimistic = [];
1320
- /** optimistic id → the incoming message that superseded it. */
1321
- const supersededBy = new Map();
1322
- const unmatchedOptimisticUsers = [];
1323
- for (const m of existing) {
1324
- if (!seen.has(m.id)) {
1325
- if (trackedStubIds?.has(m.id)) {
1326
- // Placed (or dropped) after the correlation passes below,
1327
- // because where it belongs depends on what happened to the
1328
- // user message it is parented to.
1329
- pendingStubs.push(m);
1372
+ // The echo may arrive under the optimistic message's OWN id: the host
1373
+ // minted the wire id and painted the bubble with it. That is a
1374
+ // confirmation, not a duplicate — release the marks so the message is
1375
+ // ordinary from here (the sweep leaves it alone, real parts win), and
1376
+ // bridge its optimistic parts until real ones land.
1377
+ for (const m of incoming) {
1378
+ if (m.role !== "user" || !isOptimistic(sessionID, m.id))
1330
1379
  continue;
1380
+ releaseConfirmedOptimisticId(sessionID, m.id, m.id);
1381
+ const optimisticParts = s.parts[m.id];
1382
+ const entry = msgs.find((x) => x.info.id === m.id);
1383
+ if ((entry?.parts?.length ?? 0) === 0 && optimisticParts?.length) {
1384
+ trackId(bridgedPartIds, sessionID, m.id);
1331
1385
  }
1332
- if (isOptimistic(sessionID, m.id)) {
1333
- if (m.role !== "user") {
1334
- deferredOptimistic.push(m);
1386
+ }
1387
+ // Pass 0 — candidate echoes. A real user message we have NEVER seen
1388
+ // before may be the server's copy of something still in flight. One we
1389
+ // already held is history, and history cannot supersede the present.
1390
+ // This single condition is what fixes the reported bug.
1391
+ const candidateEchoes = incoming.filter((m) => m.role === "user" &&
1392
+ !isOptimistic(sessionID, m.id) &&
1393
+ !existingIds.has(m.id));
1394
+ const unclaimedEchoes = new Set(candidateEchoes.map((m) => m.id));
1395
+ // Pass 1 — exact correlation by part id. Hosts generate the text part
1396
+ // id up front and send it WITH the prompt precisely so the echo
1397
+ // updates the same part, which makes this an identity match rather
1398
+ // than a guess.
1399
+ const echoByPartId = new Map();
1400
+ for (const echo of candidateEchoes) {
1401
+ const entry = msgs.find((m) => m.info.id === echo.id);
1402
+ for (const p of entry?.parts ?? []) {
1403
+ if (p?.id)
1404
+ echoByPartId.set(p.id, echo.id);
1405
+ }
1406
+ }
1407
+ // Start with all incoming messages
1408
+ for (const m of incoming) {
1409
+ merged.push(m);
1410
+ seen.add(m.id);
1411
+ }
1412
+ // Add any existing messages not in incoming (optimistic or from live SSE).
1413
+ // Optimistic messages go at the end to avoid clock-skew sorting issues;
1414
+ // non-optimistic ones are inserted at their sorted position.
1415
+ const deferredOptimistic = [];
1416
+ const supersededOptimistic = [];
1417
+ /** optimistic id → the incoming message that superseded it. */
1418
+ const supersededBy = new Map();
1419
+ const unmatchedOptimisticUsers = [];
1420
+ for (const m of existing) {
1421
+ if (!seen.has(m.id)) {
1422
+ if (trackedStubIds?.has(m.id)) {
1423
+ // Placed (or dropped) after the correlation passes below,
1424
+ // because where it belongs depends on what happened to the
1425
+ // user message it is parented to.
1426
+ pendingStubs.push(m);
1335
1427
  continue;
1336
1428
  }
1337
- const echoId = (s.parts[m.id] ?? [])
1338
- .map((p) => echoByPartId.get(p.id))
1339
- .find((id) => !!id);
1340
- if (echoId) {
1341
- supersededOptimistic.push(m.id);
1342
- supersededBy.set(m.id, echoId);
1343
- unclaimedEchoes.delete(echoId);
1429
+ if (isOptimistic(sessionID, m.id)) {
1430
+ if (m.role !== "user") {
1431
+ deferredOptimistic.push(m);
1432
+ continue;
1433
+ }
1434
+ const echoId = (s.parts[m.id] ?? [])
1435
+ .map((p) => echoByPartId.get(p.id))
1436
+ .find((id) => !!id);
1437
+ if (echoId) {
1438
+ supersededOptimistic.push(m.id);
1439
+ supersededBy.set(m.id, echoId);
1440
+ unclaimedEchoes.delete(echoId);
1441
+ }
1442
+ else {
1443
+ // Undecided until pass 2 — it may still pair with an
1444
+ // echo whose parts the server has not persisted yet.
1445
+ unmatchedOptimisticUsers.push(m);
1446
+ }
1344
1447
  }
1345
1448
  else {
1346
- // Undecided until pass 2 it may still pair with an
1347
- // echo whose parts the server has not persisted yet.
1348
- unmatchedOptimisticUsers.push(m);
1449
+ // By TIME, not by a binary index over ids the snapshot never
1450
+ // ordered that way see `insertIndexByTime`.
1451
+ merged.splice(insertIndexByTime(merged, m), 0, m);
1349
1452
  }
1350
1453
  }
1351
- else {
1352
- // By TIME, not by a binary index over ids the snapshot never
1353
- // ordered that way — see `insertIndexByTime`.
1354
- merged.splice(insertIndexByTime(merged, m), 0, m);
1355
- }
1356
1454
  }
1357
- }
1358
- // Pass 2 ordinal fallback, for an echo the server accepted but whose
1359
- // parts have not landed yet (no part ids to match on). Restricted to
1360
- // DISPATCHED messages: one that has not been POSTed cannot be a
1361
- // duplicate of anything the server holds, so it is never eligible.
1362
- // That restriction is what keeps a message sent from another tab from
1363
- // consuming this tab's in-flight bubble.
1364
- const claimed = new Set();
1365
- for (const m of unmatchedOptimisticUsers) {
1366
- // Alias first: the inbox row announced this message's echo id.
1367
- const alias = optimisticEchoes.get(sessionID)?.get(m.id);
1368
- if (alias && unclaimedEchoes.has(alias) && !claimed.has(alias)) {
1369
- claimed.add(alias);
1370
- supersededOptimistic.push(m.id);
1371
- supersededBy.set(m.id, alias);
1372
- }
1373
- }
1374
- const claimable = candidateEchoes.filter((m) => unclaimedEchoes.has(m.id) && !claimed.has(m.id));
1375
- let next = 0;
1376
- for (const m of unmatchedOptimisticUsers) {
1377
- if (supersededBy.has(m.id))
1378
- continue;
1379
- const echo = isDispatched(sessionID, m.id) &&
1380
- // Known-different echo → never consume someone else's.
1381
- !optimisticEchoes.get(sessionID)?.get(m.id)
1382
- ? claimable[next]
1383
- : undefined;
1384
- if (echo) {
1385
- next += 1;
1386
- supersededOptimistic.push(m.id);
1387
- supersededBy.set(m.id, echo.id);
1388
- }
1389
- else {
1390
- deferredOptimistic.push(m);
1391
- }
1392
- }
1393
- // Clean up superseded optimistic IDs, remembering which runtime id
1394
- // each one became.
1395
- for (const id of supersededOptimistic) {
1396
- const echoId = supersededBy.get(id);
1397
- if (echoId) {
1398
- releaseConfirmedOptimisticId(sessionID, id, echoId);
1399
- recordOptimisticEcho(sessionID, id, echoId);
1455
+ // Pass 2 — ordinal fallback, for an echo the server accepted but whose
1456
+ // parts have not landed yet (no part ids to match on). Restricted to
1457
+ // DISPATCHED messages: one that has not been POSTed cannot be a
1458
+ // duplicate of anything the server holds, so it is never eligible.
1459
+ // That restriction is what keeps a message sent from another tab from
1460
+ // consuming this tab's in-flight bubble.
1461
+ const claimed = new Set();
1462
+ for (const m of unmatchedOptimisticUsers) {
1463
+ // Alias first: the inbox row announced this message's echo id.
1464
+ const alias = optimisticEchoes.get(sessionID)?.get(m.id);
1465
+ if (alias && unclaimedEchoes.has(alias) && !claimed.has(alias)) {
1466
+ claimed.add(alias);
1467
+ supersededOptimistic.push(m.id);
1468
+ supersededBy.set(m.id, alias);
1469
+ }
1400
1470
  }
1401
- else {
1402
- releaseOptimisticId(sessionID, id);
1471
+ const claimable = candidateEchoes.filter((m) => unclaimedEchoes.has(m.id) && !claimed.has(m.id));
1472
+ let next = 0;
1473
+ for (const m of unmatchedOptimisticUsers) {
1474
+ if (supersededBy.has(m.id))
1475
+ continue;
1476
+ const echo = isDispatched(sessionID, m.id) &&
1477
+ // Known-different echo → never consume someone else's.
1478
+ !optimisticEchoes.get(sessionID)?.get(m.id)
1479
+ ? claimable[next]
1480
+ : undefined;
1481
+ if (echo) {
1482
+ next += 1;
1483
+ supersededOptimistic.push(m.id);
1484
+ supersededBy.set(m.id, echo.id);
1485
+ }
1486
+ else {
1487
+ deferredOptimistic.push(m);
1488
+ }
1403
1489
  }
1404
- }
1405
- // Append surviving optimistic messages at the end
1406
- for (const m of deferredOptimistic) {
1407
- merged.push(m);
1408
- }
1409
- // Now place (or retire) each `session.error` stub, with its turn
1410
- // finally settled. Three outcomes, in order:
1411
- //
1412
- // - its user message was superseded by the server's own copy in this
1413
- // snapshot → the stub follows it, re-keyed onto the real id;
1414
- // - the server now holds a real assistant message for that turn →
1415
- // the stub was a stand-in for exactly that message, so it goes;
1416
- // - otherwise → it stays, directly after its user message. This is
1417
- // the case the reported bug turned on: the `reconcileTail` that
1418
- // every `session.error` triggers returns a transcript with NO
1419
- // reply for the failed turn, and dropping the stub there left the
1420
- // user staring at their own prompt and nothing else.
1421
- for (const stub of pendingStubs) {
1422
- const trackedParent = trackedStubIds?.get(stub.id) ?? null;
1423
- const parentId = (trackedParent && supersededBy.get(trackedParent)) || trackedParent;
1424
- untrackStub(sessionID, stub.id);
1425
- // The server DOES hold a reply for this turn — the stub stood in
1426
- // for exactly that message, so it goes. Its error moves onto the
1427
- // real message first, unless the server's copy carries one of its
1428
- // own: `session.error` is the runtime's report of THIS turn
1429
- // failing, and the transcript read that follows it does not always
1430
- // carry the failure back (the same race
1431
- // `use-opencode-events`'s cache patch exists for). Dropping the
1432
- // stub against an error-free server copy would erase the only
1433
- // evidence the turn failed.
1434
- if (hasAssistantForTurn(incoming, parentId)) {
1435
- const replyIdx = findAssistantForTurn(merged, parentId);
1436
- const reply = replyIdx === -1 ? undefined : merged[replyIdx];
1437
- const stubError = stub.error;
1438
- if (reply && !reply.error && stubError) {
1439
- merged[replyIdx] = { ...reply, error: stubError };
1490
+ // Clean up superseded optimistic IDs, remembering which runtime id
1491
+ // each one became.
1492
+ for (const id of supersededOptimistic) {
1493
+ const echoId = supersededBy.get(id);
1494
+ if (echoId) {
1495
+ releaseConfirmedOptimisticId(sessionID, id, echoId);
1496
+ recordOptimisticEcho(sessionID, id, echoId);
1497
+ }
1498
+ else {
1499
+ releaseOptimisticId(sessionID, id);
1440
1500
  }
1441
- continue;
1442
1501
  }
1443
- const stubId = parentId ? stubIdFor(parentId) : stub.id;
1444
- const placed = stubId === stub.id && stub.parentID === (parentId ?? undefined)
1445
- ? stub
1446
- : { ...stub, id: stubId, ...(parentId ? { parentID: parentId } : {}) };
1447
- trackStub(sessionID, stubId, parentId);
1448
- const parentIdx = parentId ? merged.findIndex((m) => m.id === parentId) : -1;
1449
- if (parentIdx !== -1) {
1450
- merged.splice(parentIdx + 1, 0, placed);
1451
- continue;
1502
+ // Append surviving optimistic messages at the end
1503
+ for (const m of deferredOptimistic) {
1504
+ merged.push(m);
1452
1505
  }
1453
- // Its user message is not in the transcript at all. Linear on a
1454
- // miss so a re-keyed stub never lands twice (`stubIdFor(parentId)`
1455
- // can name a stub already merged), and by time otherwise — a stub
1456
- // carries no `time`, so it goes last.
1457
- if (indexOfId(merged, placed.id, (x) => x.id) === -1) {
1458
- merged.splice(insertIndexByTime(merged, placed), 0, placed);
1506
+ // Now place (or retire) each `session.error` stub, with its turn
1507
+ // finally settled. Three outcomes, in order:
1508
+ //
1509
+ // - its user message was superseded by the server's own copy in this
1510
+ // snapshot the stub follows it, re-keyed onto the real id;
1511
+ // - the server now holds a real assistant message for that turn →
1512
+ // the stub was a stand-in for exactly that message, so it goes;
1513
+ // - otherwise → it stays, directly after its user message. This is
1514
+ // the case the reported bug turned on: the `reconcileTail` that
1515
+ // every `session.error` triggers returns a transcript with NO
1516
+ // reply for the failed turn, and dropping the stub there left the
1517
+ // user staring at their own prompt and nothing else.
1518
+ for (const stub of pendingStubs) {
1519
+ const trackedParent = trackedStubIds?.get(stub.id) ?? null;
1520
+ const parentId = (trackedParent && supersededBy.get(trackedParent)) || trackedParent;
1521
+ untrackStub(sessionID, stub.id);
1522
+ // The server DOES hold a reply for this turn — the stub stood in
1523
+ // for exactly that message, so it goes. Its error moves onto the
1524
+ // real message first, unless the server's copy carries one of its
1525
+ // own: `session.error` is the runtime's report of THIS turn
1526
+ // failing, and the transcript read that follows it does not always
1527
+ // carry the failure back (the same race
1528
+ // `use-opencode-events`'s cache patch exists for). Dropping the
1529
+ // stub against an error-free server copy would erase the only
1530
+ // evidence the turn failed.
1531
+ if (hasAssistantForTurn(incoming, parentId)) {
1532
+ const replyIdx = findAssistantForTurn(merged, parentId);
1533
+ const reply = replyIdx === -1 ? undefined : merged[replyIdx];
1534
+ const stubError = stub.error;
1535
+ if (reply && !reply.error && stubError) {
1536
+ merged[replyIdx] = { ...reply, error: stubError };
1537
+ }
1538
+ continue;
1539
+ }
1540
+ const stubId = parentId ? stubIdFor(parentId) : stub.id;
1541
+ const placed = stubId === stub.id && stub.parentID === (parentId ?? undefined)
1542
+ ? stub
1543
+ : { ...stub, id: stubId, ...(parentId ? { parentID: parentId } : {}) };
1544
+ trackStub(sessionID, stubId, parentId);
1545
+ const parentIdx = parentId ? merged.findIndex((m) => m.id === parentId) : -1;
1546
+ if (parentIdx !== -1) {
1547
+ merged.splice(parentIdx + 1, 0, placed);
1548
+ continue;
1549
+ }
1550
+ // Its user message is not in the transcript at all. Linear on a
1551
+ // miss so a re-keyed stub never lands twice (`stubIdFor(parentId)`
1552
+ // can name a stub already merged), and by time otherwise — a stub
1553
+ // carries no `time`, so it goes last.
1554
+ if (indexOfId(merged, placed.id, (x) => x.id) === -1) {
1555
+ merged.splice(insertIndexByTime(merged, placed), 0, placed);
1556
+ }
1459
1557
  }
1460
- }
1461
- // Merge parts: for each message, reconcile by part ID.
1462
- // If a message is optimistic (still in optimisticIds), keep existing
1463
- // parts entirely they're from the client and shouldn't be overwritten.
1464
- // Otherwise, incoming parts win (server is authoritative), but keep
1465
- // any extra parts from SSE that aren't in the fetch response.
1466
- const newParts = { ...s.parts };
1467
- if (droppedPhantoms)
1468
- for (const id of droppedPhantoms)
1558
+ // Merge parts: for each message, reconcile by part ID.
1559
+ // If a message is optimistic (still in optimisticIds), keep existing
1560
+ // parts entirely they're from the client and shouldn't be overwritten.
1561
+ // Otherwise, incoming parts win (server is authoritative), but keep
1562
+ // any extra parts from SSE that aren't in the fetch response.
1563
+ const newParts = { ...s.parts };
1564
+ if (droppedPhantoms)
1565
+ for (const id of droppedPhantoms)
1566
+ delete newParts[id];
1567
+ // When superseding an optimistic user message, bridge its parts to
1568
+ // the real user message ID if the server hasn't sent parts yet.
1569
+ // Mirrors the message.updated SSE handler (see above) — without
1570
+ // this, a fetch/hydrate that races ahead of parts persistence
1571
+ // would drop the user's text and render an empty bubble.
1572
+ // Bridge along the pairing established above, not to "whichever real
1573
+ // user message came first". With more than one message in flight the
1574
+ // first-match version could graft one message's text onto another's
1575
+ // bubble; `supersededBy` names the exact echo each optimistic message
1576
+ // lost to.
1577
+ for (const id of supersededOptimistic) {
1578
+ const echoId = supersededBy.get(id);
1579
+ const bridge = newParts[id];
1469
1580
  delete newParts[id];
1470
- // When superseding an optimistic user message, bridge its parts to
1471
- // the real user message ID if the server hasn't sent parts yet.
1472
- // Mirrors the message.updated SSE handler (see above) — without
1473
- // this, a fetch/hydrate that races ahead of parts persistence
1474
- // would drop the user's text and render an empty bubble.
1475
- // Bridge along the pairing established above, not to "whichever real
1476
- // user message came first". With more than one message in flight the
1477
- // first-match version could graft one message's text onto another's
1478
- // bubble; `supersededBy` names the exact echo each optimistic message
1479
- // lost to.
1480
- for (const id of supersededOptimistic) {
1481
- const echoId = supersededBy.get(id);
1482
- const bridge = newParts[id];
1483
- delete newParts[id];
1484
- if (!echoId || !bridge?.length)
1485
- continue;
1486
- const echoEntry = msgs.find((m) => m.info.id === echoId);
1487
- const serverHasParts = (echoEntry?.parts?.length ?? 0) > 0;
1488
- if (serverHasParts || newParts[echoId]?.length)
1489
- continue;
1490
- newParts[echoId] = bridge;
1491
- trackId(bridgedPartIds, sessionID, echoId);
1492
- }
1493
- for (const m of msgs) {
1494
- if (!m?.info?.id)
1495
- continue;
1496
- const mid = m.info.id;
1497
- if (isOptimistic(sessionID, mid))
1498
- continue; // Don't touch optimistic parts
1499
- // Parts, not messages: this sort is untouched by the message-order
1500
- // work and keeps its own byte-order comparison.
1501
- const inParts = m.parts
1502
- .filter((p) => !!p?.id)
1503
- .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
1504
- // If this message still carries bridged optimistic parts, a hydrate
1505
- // snapshot with real parts should replace them immediately. Otherwise
1506
- // reconcile-by-extras can keep both copies and duplicate user text.
1507
- if (hasTrackedId(bridgedPartIds, sessionID, mid) && inParts.length > 0) {
1508
- untrackId(bridgedPartIds, sessionID, mid);
1509
- newParts[mid] = inParts;
1510
- continue;
1511
- }
1512
- const exParts = newParts[mid];
1513
- if (!exParts || exParts.length === 0) {
1514
- newParts[mid] = inParts;
1515
- continue;
1581
+ if (!echoId || !bridge?.length)
1582
+ continue;
1583
+ const echoEntry = msgs.find((m) => m.info.id === echoId);
1584
+ const serverHasParts = (echoEntry?.parts?.length ?? 0) > 0;
1585
+ if (serverHasParts || newParts[echoId]?.length)
1586
+ continue;
1587
+ newParts[echoId] = bridge;
1588
+ trackId(bridgedPartIds, sessionID, echoId);
1516
1589
  }
1517
- // Reconcile by key: incoming parts are generally authoritative,
1518
- // but for text/reasoning parts during active streaming, SSE-accumulated
1519
- // parts may have MORE content than the server snapshot (the
1520
- // server may return empty/stale text for in-progress parts).
1521
- // In that case, prefer the existing (SSE) version.
1522
- const exById = new Map(exParts.map((p) => [p.id, p]));
1523
- const inIds = new Set(inParts.map((p) => p.id));
1524
- const extras = exParts.filter((p) => !inIds.has(p.id));
1525
- const reconciled = inParts.map((inP) => {
1526
- const exP = exById.get(inP.id);
1527
- if (!exP)
1590
+ for (const m of msgs) {
1591
+ if (!m?.info?.id)
1592
+ continue;
1593
+ const mid = m.info.id;
1594
+ if (isOptimistic(sessionID, mid))
1595
+ continue; // Don't touch optimistic parts
1596
+ // Parts, not messages: this sort is untouched by the message-order
1597
+ // work and keeps its own byte-order comparison.
1598
+ const inParts = m.parts
1599
+ .filter((p) => !!p?.id)
1600
+ .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
1601
+ // If this message still carries bridged optimistic parts, a hydrate
1602
+ // snapshot with real parts should replace them immediately. Otherwise
1603
+ // reconcile-by-extras can keep both copies and duplicate user text.
1604
+ if (hasTrackedId(bridgedPartIds, sessionID, mid) && inParts.length > 0) {
1605
+ untrackId(bridgedPartIds, sessionID, mid);
1606
+ newParts[mid] = inParts;
1607
+ continue;
1608
+ }
1609
+ const exParts = newParts[mid];
1610
+ if (!exParts || exParts.length === 0) {
1611
+ newParts[mid] = inParts;
1612
+ continue;
1613
+ }
1614
+ // Reconcile by key: incoming parts are generally authoritative,
1615
+ // but for text/reasoning parts during active streaming, SSE-accumulated
1616
+ // parts may have MORE content than the server snapshot (the
1617
+ // server may return empty/stale text for in-progress parts).
1618
+ // In that case, prefer the existing (SSE) version.
1619
+ const exById = new Map(exParts.map((p) => [p.id, p]));
1620
+ const inIds = new Set(inParts.map((p) => p.id));
1621
+ const extras = exParts.filter((p) => !inIds.has(p.id));
1622
+ const reconciled = inParts.map((inP) => {
1623
+ const exP = exById.get(inP.id);
1624
+ if (!exP)
1625
+ return inP;
1626
+ // For text/reasoning parts: prefer whichever has more text content.
1627
+ // This prevents hydrate from clobbering SSE-streamed content
1628
+ // with an empty/stale server snapshot during active streaming.
1629
+ if (isTextLikePart(inP) &&
1630
+ isTextLikePart(exP) &&
1631
+ exP.text.length > inP.text.length) {
1632
+ return exP;
1633
+ }
1528
1634
  return inP;
1529
- // For text/reasoning parts: prefer whichever has more text content.
1530
- // This prevents hydrate from clobbering SSE-streamed content
1531
- // with an empty/stale server snapshot during active streaming.
1532
- if (isTextLikePart(inP) &&
1533
- isTextLikePart(exP) &&
1534
- exP.text.length > inP.text.length) {
1535
- return exP;
1635
+ });
1636
+ // T16 dedupe extras by content identity. An "extra" is an
1637
+ // existing part whose id the incoming snapshot no longer has — the
1638
+ // server may simply not have persisted it yet (kept, as before), OR
1639
+ // the server RE-ISSUED the same content under a NEW part id (a real
1640
+ // defect: the old SSE-accumulated twin stayed in `exParts` forever,
1641
+ // duplicating the text inside this one message). Distinguish the two
1642
+ // conservatively: drop an extra only when it is text-like AND its own
1643
+ // accumulated text is a PREFIX of (or equal to) some incoming
1644
+ // text-like part of the SAME type — i.e. the incoming copy confidently
1645
+ // re-issues it, not merely resembles it. Non-text-like extras (tool,
1646
+ // permission, file, step, …) are never dropped by content — they carry
1647
+ // distinct identity per id and a coincidental text match doesn't apply
1648
+ // to them at all.
1649
+ //
1650
+ // F1 review finding: an extra still tracked in `deltaActiveParts` (this
1651
+ // session is actively applying deltas to it right now) is EXEMPT from
1652
+ // this filter regardless of what it prefixes. The heuristic above
1653
+ // assumes a text-prefix match means the server re-issued the SAME
1654
+ // content under a new id and the extra is an abandoned twin — but a
1655
+ // live streaming target is never abandoned, and dropping it here also
1656
+ // permanently blocks its later deltas (their event ids would already
1657
+ // be recorded as applied — see `applyPartDelta`'s not-found path).
1658
+ const survivingExtras = extras.filter((extra) => {
1659
+ if (hasTrackedId(deltaActiveParts, sessionID, extra.id))
1660
+ return true;
1661
+ if (!isTextLikePart(extra) || extra.text.length === 0)
1662
+ return true;
1663
+ return !inParts.some((inP) => inP.type === extra.type &&
1664
+ isTextLikePart(inP) &&
1665
+ inP.text.startsWith(extra.text));
1666
+ });
1667
+ for (const ep of survivingExtras) {
1668
+ const r = Binary.search(reconciled, ep.id, (p) => p.id);
1669
+ if (!r.found)
1670
+ reconciled.splice(r.index, 0, ep);
1536
1671
  }
1537
- return inP;
1538
- });
1539
- // T16 — dedupe extras by content identity. An "extra" is an
1540
- // existing part whose id the incoming snapshot no longer has — the
1541
- // server may simply not have persisted it yet (kept, as before), OR
1542
- // the server RE-ISSUED the same content under a NEW part id (a real
1543
- // defect: the old SSE-accumulated twin stayed in `exParts` forever,
1544
- // duplicating the text inside this one message). Distinguish the two
1545
- // conservatively: drop an extra only when it is text-like AND its own
1546
- // accumulated text is a PREFIX of (or equal to) some incoming
1547
- // text-like part of the SAME type — i.e. the incoming copy confidently
1548
- // re-issues it, not merely resembles it. Non-text-like extras (tool,
1549
- // permission, file, step, …) are never dropped by content — they carry
1550
- // distinct identity per id and a coincidental text match doesn't apply
1551
- // to them at all.
1552
- //
1553
- // F1 review finding: an extra still tracked in `deltaActiveParts` (this
1554
- // session is actively applying deltas to it right now) is EXEMPT from
1555
- // this filter regardless of what it prefixes. The heuristic above
1556
- // assumes a text-prefix match means the server re-issued the SAME
1557
- // content under a new id and the extra is an abandoned twin — but a
1558
- // live streaming target is never abandoned, and dropping it here also
1559
- // permanently blocks its later deltas (their event ids would already
1560
- // be recorded as applied — see `applyPartDelta`'s not-found path).
1561
- const survivingExtras = extras.filter((extra) => {
1562
- if (hasTrackedId(deltaActiveParts, sessionID, extra.id))
1563
- return true;
1564
- if (!isTextLikePart(extra) || extra.text.length === 0)
1565
- return true;
1566
- return !inParts.some((inP) => inP.type === extra.type &&
1567
- isTextLikePart(inP) &&
1568
- inP.text.startsWith(extra.text));
1569
- });
1570
- for (const ep of survivingExtras) {
1571
- const r = Binary.search(reconciled, ep.id, (p) => p.id);
1572
- if (!r.found)
1573
- reconciled.splice(r.index, 0, ep);
1672
+ newParts[mid] = reconciled;
1574
1673
  }
1575
- newParts[mid] = reconciled;
1576
- }
1577
- return {
1578
- messages: { ...s.messages, [sessionID]: merged },
1579
- parts: newParts,
1580
- };
1581
- }),
1674
+ return {
1675
+ messages: { ...s.messages, [sessionID]: merged },
1676
+ parts: newParts,
1677
+ };
1678
+ });
1679
+ // Outside the updater: `noteSessionActivity` runs its own set(), and the
1680
+ // stamp is quantized there. This is the evidence `projectWorking`'s
1681
+ // content-first rule needs to outrank a stale wire idle frame whose veto
1682
+ // would otherwise silence every fresh `/turn` read for the rest of a turn
1683
+ // the SSE stream stopped reporting (prod, 2026-08-26).
1684
+ if (stampRuntimeActivity)
1685
+ get().noteSessionActivity(sessionID);
1686
+ },
1582
1687
  reset: () => {
1583
1688
  optimisticIds.clear();
1584
1689
  dispatchedOptimisticIds.clear();
@@ -1599,6 +1704,9 @@ export const useSyncStore = create()((set, get) => ({
1599
1704
  messages: {},
1600
1705
  parts: {},
1601
1706
  sessionStatus: {},
1707
+ sessionStatusOrigin: {},
1708
+ sessionStatusAt: {},
1709
+ sessionActivityAt: {},
1602
1710
  diffs: {},
1603
1711
  todos: {},
1604
1712
  sessionRevert: {},
@@ -1900,13 +2008,13 @@ export const useSyncStore = create()((set, get) => ({
1900
2008
  case "session.status": {
1901
2009
  const props = event.properties;
1902
2010
  if (props.sessionID && props.status)
1903
- store.setStatus(props.sessionID, props.status);
2011
+ store.setStatus(props.sessionID, props.status, syntheticEventOrigin(event));
1904
2012
  return;
1905
2013
  }
1906
2014
  case "session.idle": {
1907
2015
  const sessionID = event.properties.sessionID;
1908
2016
  if (sessionID)
1909
- store.setStatus(sessionID, { type: "idle" });
2017
+ store.setStatus(sessionID, { type: "idle" }, syntheticEventOrigin(event));
1910
2018
  // Streaming finished for THIS session — clear only its own delta
1911
2019
  // tracking so future message.part.updated snapshots for it are
1912
2020
  // accepted normally. Never the whole map: another session may
@@ -1926,8 +2034,23 @@ export const useSyncStore = create()((set, get) => ({
1926
2034
  return;
1927
2035
  const sid = props.sessionID;
1928
2036
  const error = props.error;
1929
- // Mark session idle — errors terminate the response.
1930
- store.setStatus(sid, { type: "idle" });
2037
+ // Most errors terminate the response. A RETRYABLE one does not:
2038
+ // OpenCode stamps `data.isRetryable === true` and keeps writing the
2039
+ // SAME assistant message, and apps/api reaches the same conclusion
2040
+ // from this same event (`isTerminalTurnEnd`,
2041
+ // apps/api/src/projects/sandbox-deadline-policy.ts:295). Writing
2042
+ // `idle` here made a live turn byte-identical to a finished one, and
2043
+ // `endedByRuntime` (core/session/working.ts) then vetoed the still-open
2044
+ // ledger row with no time bound — the Stop button disappeared mid-turn
2045
+ // and the partial answer read as complete.
2046
+ //
2047
+ // The status slot is LEFT ALONE rather than set to `retry`: the frame
2048
+ // already there is the runtime's own last word, and overwriting it
2049
+ // would re-stamp its arrival time and restart every freshness window
2050
+ // that depends on it.
2051
+ if (!isRetryableTurnError(error)) {
2052
+ store.setStatus(sid, { type: "idle" }, syntheticEventOrigin(event));
2053
+ }
1931
2054
  // Clear only this session's delta tracking — see the idle handler
1932
2055
  // above and the comment above deltaActiveParts.
1933
2056
  deltaActiveParts.delete(sid);