@kortix/sdk 0.13.0 → 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 (60) hide show
  1. package/dist/browser/session-sync/session-transcript-cache.d.ts +9 -2
  2. package/dist/browser/session-sync/session-transcript-cache.d.ts.map +1 -1
  3. package/dist/browser/session-sync/session-transcript-cache.js +15 -5
  4. package/dist/browser/session-sync/session-transcript-cache.js.map +1 -1
  5. package/dist/browser/stores/sync-store.d.ts +39 -1
  6. package/dist/browser/stores/sync-store.d.ts.map +1 -1
  7. package/dist/browser/stores/sync-store.js +393 -70
  8. package/dist/browser/stores/sync-store.js.map +1 -1
  9. package/dist/core/client/kortix.d.ts +1 -7
  10. package/dist/core/client/kortix.d.ts.map +1 -1
  11. package/dist/core/http/opencode-errors.d.ts +7 -0
  12. package/dist/core/http/opencode-errors.d.ts.map +1 -1
  13. package/dist/core/http/opencode-errors.js +45 -13
  14. package/dist/core/http/opencode-errors.js.map +1 -1
  15. package/dist/core/rest/projects-client/accounts.d.ts +0 -4
  16. package/dist/core/rest/projects-client/accounts.d.ts.map +1 -1
  17. package/dist/core/rest/projects-client/accounts.js.map +1 -1
  18. package/dist/core/rest/projects-client/connectors.d.ts +67 -1
  19. package/dist/core/rest/projects-client/connectors.d.ts.map +1 -1
  20. package/dist/core/rest/projects-client/connectors.js +17 -0
  21. package/dist/core/rest/projects-client/connectors.js.map +1 -1
  22. package/dist/core/rest/projects-client/projects.d.ts +1 -1
  23. package/dist/core/rest/projects-client/projects.d.ts.map +1 -1
  24. package/dist/core/rest/projects-client/projects.js +0 -1
  25. package/dist/core/rest/projects-client/projects.js.map +1 -1
  26. package/dist/core/rest/projects-client/secrets.d.ts +12 -5
  27. package/dist/core/rest/projects-client/secrets.d.ts.map +1 -1
  28. package/dist/core/rest/projects-client/secrets.js.map +1 -1
  29. package/dist/core/rest/projects-client/sessions.d.ts +38 -3
  30. package/dist/core/rest/projects-client/sessions.d.ts.map +1 -1
  31. package/dist/core/rest/projects-client/sessions.js +7 -2
  32. package/dist/core/rest/projects-client/sessions.js.map +1 -1
  33. package/dist/core/turns/grouping.d.ts +0 -14
  34. package/dist/core/turns/grouping.d.ts.map +1 -1
  35. package/dist/core/turns/grouping.js +25 -11
  36. package/dist/core/turns/grouping.js.map +1 -1
  37. package/dist/kortix.esm.min.js +22 -22
  38. package/dist/kortix.global.js +21 -21
  39. package/dist/react/query-contracts.d.ts +2 -0
  40. package/dist/react/query-contracts.d.ts.map +1 -1
  41. package/dist/react/query-contracts.js +2 -0
  42. package/dist/react/query-contracts.js.map +1 -1
  43. package/dist/react/query-keys.d.ts +5 -0
  44. package/dist/react/query-keys.d.ts.map +1 -1
  45. package/dist/react/query-keys.js +5 -0
  46. package/dist/react/query-keys.js.map +1 -1
  47. package/dist/react/use-session-prompts.d.ts.map +1 -1
  48. package/dist/react/use-session-prompts.js +8 -0
  49. package/dist/react/use-session-prompts.js.map +1 -1
  50. package/dist/react/use-session-send.d.ts.map +1 -1
  51. package/dist/react/use-session-send.js +5 -1
  52. package/dist/react/use-session-send.js.map +1 -1
  53. package/dist/react/use-session-sync.d.ts.map +1 -1
  54. package/dist/react/use-session-sync.js +3 -2
  55. package/dist/react/use-session-sync.js.map +1 -1
  56. package/dist/react/use-session.d.ts +9 -0
  57. package/dist/react/use-session.d.ts.map +1 -1
  58. package/dist/react/use-session.js +16 -1
  59. package/dist/react/use-session.js.map +1 -1
  60. package/package.json +2 -2
@@ -62,17 +62,45 @@ const inboxBackedOptimisticIds = new Map();
62
62
  // per session; released with the session.
63
63
  const optimisticEchoes = new Map();
64
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();
65
87
  function recordOptimisticEcho(sessionID, optimisticID, echoID) {
66
88
  if (optimisticID === echoID)
67
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;
68
94
  let fwd = optimisticEchoes.get(sessionID);
69
95
  if (!fwd)
70
96
  optimisticEchoes.set(sessionID, (fwd = new Map()));
71
- fwd.set(optimisticID, echoID);
97
+ fwd.set(origin, echoID);
98
+ if (origin !== optimisticID)
99
+ fwd.set(optimisticID, echoID);
72
100
  let rev = optimisticOrigins.get(sessionID);
73
101
  if (!rev)
74
102
  optimisticOrigins.set(sessionID, (rev = new Map()));
75
- rev.set(echoID, optimisticID);
103
+ rev.set(echoID, origin);
76
104
  }
77
105
  /** Forget every optimistic mark for one id — confirmed, superseded, or removed. */
78
106
  function releaseOptimisticId(sessionID, id) {
@@ -80,6 +108,14 @@ function releaseOptimisticId(sessionID, id) {
80
108
  untrackId(dispatchedOptimisticIds, sessionID, id);
81
109
  untrackId(inboxBackedOptimisticIds, sessionID, id);
82
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
+ }
83
119
  function trackId(store, sessionID, id) {
84
120
  const bucket = store.get(sessionID);
85
121
  if (bucket)
@@ -106,6 +142,9 @@ function forgetSessionIds(sessionID) {
106
142
  inboxBackedOptimisticIds.delete(sessionID);
107
143
  optimisticEchoes.delete(sessionID);
108
144
  optimisticOrigins.delete(sessionID);
145
+ controlPlaneOwnedIds.delete(sessionID);
146
+ cacheSourcedIds.delete(sessionID);
147
+ cancelledMessageIds.delete(sessionID);
109
148
  // The joined rows hold the very message and part arrays this session's
110
149
  // data is being dropped from. Left behind, they keep the transcript
111
150
  // reachable and the drop achieves nothing.
@@ -198,15 +237,95 @@ const deltaActiveParts = new Map();
198
237
  // wholesale by `forgetSessionIds`/`reset()`, matching `deltaActiveParts`.
199
238
  const deltaEventTails = new Map();
200
239
  /** Session-scoped tracking for `session.error`'s stub assistant message (see
201
- * the handler below) — same shape/reason as the maps above. A stub only
202
- * ever stands in for a real assistant message that has not arrived yet;
203
- * `hydrate` reconciles (drops) it the moment the server's own transcript
204
- * contains a real one for this session, which is the promise the stub's own
205
- * creation comment made but that nothing previously fulfilled. Released
206
- * wholesale by `forgetSessionIds`/`reset()`; NOT cleared on idle/error the
207
- * stub message it names still lives in `messages[sessionID]` until hydrate
208
- * reconciles it or the session itself ends. */
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. */
209
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
+ }
210
329
  // ---------------------------------------------------------------------------
211
330
  // Session retention — how a transcript ever LEAVES memory again.
212
331
  //
@@ -756,6 +875,38 @@ export const useSyncStore = create()((set, get) => ({
756
875
  },
757
876
  optimisticEchoOf: (sessionID, optimisticID) => optimisticEchoes.get(sessionID)?.get(optimisticID),
758
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
+ },
759
910
  optimisticRemove: (sessionID, messageID) => {
760
911
  // Only an OPTIMISTIC message can be removed this way. One the runtime has
761
912
  // confirmed (same-id echo, or the store never tracked it) is the
@@ -904,35 +1055,76 @@ export const useSyncStore = create()((set, get) => ({
904
1055
  touchSessionMessageRows(sessionID, { msgs, partRefs, result });
905
1056
  return result;
906
1057
  },
907
- hydrate: (sessionID, msgs) => set((s) => {
1058
+ hydrate: (sessionID, msgs, opts) => set((s) => {
908
1059
  // An authoritative load — the disk repaint itself, or a reconcile —
909
1060
  // re-establishes the session, so its entry is no longer a fragment.
910
1061
  evictedSessions.delete(sessionID);
911
1062
  const cmp = (a, b) => a < b ? -1 : a > b ? 1 : 0;
1063
+ const tombstones = cancelledMessageIds.get(sessionID);
912
1064
  const incoming = msgs
913
- .filter((m) => !!m?.info?.id)
1065
+ .filter((m) => !!m?.info?.id && !tombstones?.has(m.info.id))
914
1066
  .map((m) => m.info)
915
1067
  .sort((a, b) => cmp(a.id, b.id));
916
- // T16 reconcile a `session.error` stub assistant message
917
- // (see `stubAssistantIds` and the `session.error` handler above). The
918
- // stub only ever stood in for a real assistant message that had not
919
- // arrived yet, sorted BELOW every server id (`ascendingId('msg')`) —
920
- // wrong once real data exists. The moment the server's OWN transcript
921
- // contains a real assistant message for this session, every
922
- // currently-tracked stub for it is stale and is dropped below rather
923
- // than kept alongside the real one at the wrong position. If the
924
- // incoming snapshot has no assistant message at all yet, nothing has
925
- // arrived to reconcile against, so the stub is left untouched.
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
+ }
926
1116
  const trackedStubIds = stubAssistantIds.get(sessionID);
927
- const reconcileStubs = !!trackedStubIds &&
928
- trackedStubIds.size > 0 &&
929
- incoming.some((m) => m.role === "assistant");
930
- if (reconcileStubs)
931
- stubAssistantIds.delete(sessionID);
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 = [];
932
1121
  // Merge incoming messages with existing ones — never delete messages
933
1122
  // that exist in the sync store but are missing from the fetch (they may
934
1123
  // be from a newer turn that the server hasn't persisted yet).
935
- 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;
936
1128
  const merged = [];
937
1129
  const seen = new Set();
938
1130
  // Which optimistic user messages has the server actually echoed?
@@ -957,7 +1149,7 @@ export const useSyncStore = create()((set, get) => ({
957
1149
  for (const m of incoming) {
958
1150
  if (m.role !== "user" || !isOptimistic(sessionID, m.id))
959
1151
  continue;
960
- releaseOptimisticId(sessionID, m.id);
1152
+ releaseConfirmedOptimisticId(sessionID, m.id, m.id);
961
1153
  const optimisticParts = s.parts[m.id];
962
1154
  const entry = msgs.find((x) => x.info.id === m.id);
963
1155
  if ((entry?.parts?.length ?? 0) === 0 && optimisticParts?.length) {
@@ -999,10 +1191,11 @@ export const useSyncStore = create()((set, get) => ({
999
1191
  const unmatchedOptimisticUsers = [];
1000
1192
  for (const m of existing) {
1001
1193
  if (!seen.has(m.id)) {
1002
- if (reconcileStubs && trackedStubIds.has(m.id)) {
1003
- // Superseded by a real assistant message this same hydrate
1004
- // snapshot introduced drop it, don't reinsert it below the
1005
- // real data at its stale `ascendingId` position.
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);
1006
1199
  continue;
1007
1200
  }
1008
1201
  if (isOptimistic(sessionID, m.id)) {
@@ -1036,10 +1229,24 @@ export const useSyncStore = create()((set, get) => ({
1036
1229
  // duplicate of anything the server holds, so it is never eligible.
1037
1230
  // That restriction is what keeps a message sent from another tab from
1038
1231
  // consuming this tab's in-flight bubble.
1039
- 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));
1040
1243
  let next = 0;
1041
1244
  for (const m of unmatchedOptimisticUsers) {
1042
- 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)
1043
1250
  ? claimable[next]
1044
1251
  : undefined;
1045
1252
  if (echo) {
@@ -1054,21 +1261,78 @@ export const useSyncStore = create()((set, get) => ({
1054
1261
  // Clean up superseded optimistic IDs, remembering which runtime id
1055
1262
  // each one became.
1056
1263
  for (const id of supersededOptimistic) {
1057
- releaseOptimisticId(sessionID, id);
1058
1264
  const echoId = supersededBy.get(id);
1059
- if (echoId)
1265
+ if (echoId) {
1266
+ releaseConfirmedOptimisticId(sessionID, id, echoId);
1060
1267
  recordOptimisticEcho(sessionID, id, echoId);
1268
+ }
1269
+ else {
1270
+ releaseOptimisticId(sessionID, id);
1271
+ }
1061
1272
  }
1062
1273
  // Append surviving optimistic messages at the end
1063
1274
  for (const m of deferredOptimistic) {
1064
1275
  merged.push(m);
1065
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
+ }
1066
1327
  // Merge parts: for each message, reconcile by part ID.
1067
1328
  // If a message is optimistic (still in optimisticIds), keep existing
1068
1329
  // parts entirely — they're from the client and shouldn't be overwritten.
1069
1330
  // Otherwise, incoming parts win (server is authoritative), but keep
1070
1331
  // any extra parts from SSE that aren't in the fetch response.
1071
1332
  const newParts = { ...s.parts };
1333
+ if (droppedPhantoms)
1334
+ for (const id of droppedPhantoms)
1335
+ delete newParts[id];
1072
1336
  // When superseding an optimistic user message, bridge its parts to
1073
1337
  // the real user message ID if the server hasn't sent parts yet.
1074
1338
  // Mirrors the message.updated SSE handler (see above) — without
@@ -1230,6 +1494,9 @@ export const useSyncStore = create()((set, get) => ({
1230
1494
  const info = event.properties.info;
1231
1495
  if (!info?.sessionID)
1232
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;
1233
1500
  // When a real user message arrives from the server, swap out the
1234
1501
  // optimistic message(s) in a SINGLE atomic set() call.
1235
1502
  // This prevents the intermediate render where the user bubble
@@ -1292,13 +1559,29 @@ export const useSyncStore = create()((set, get) => ({
1292
1559
  // matching: at `message.updated` time the confirmed message
1293
1560
  // usually has no parts in the store yet (they arrive separately
1294
1561
  // via `message.part.updated`).
1295
- 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;
1296
1579
  const optIds = matched ? [matched.id] : [];
1297
1580
  if (optIds.length > 0) {
1298
1581
  // Clean up optimistic tracking, remembering the runtime id
1299
1582
  // each superseded message became.
1300
1583
  for (const id of optIds) {
1301
- releaseOptimisticId(info.sessionID, id);
1584
+ releaseConfirmedOptimisticId(info.sessionID, id, info.id);
1302
1585
  recordOptimisticEcho(info.sessionID, id, info.id);
1303
1586
  }
1304
1587
  // Atomic: remove optimistic + insert real in one set()
@@ -1308,13 +1591,20 @@ export const useSyncStore = create()((set, get) => ({
1308
1591
  const without = list.filter((m) => !optIds.includes(m.id));
1309
1592
  // Insert the real message at sorted position
1310
1593
  const r = Binary.search(without, info.id, (m) => m.id);
1311
- const next = [...without];
1594
+ let next = [...without];
1312
1595
  if (r.found) {
1313
1596
  next[r.index] = info;
1314
1597
  }
1315
1598
  else {
1316
1599
  next.splice(r.index, 0, info);
1317
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
+ }
1318
1608
  // Bridge optimistic parts to the real message ID so
1319
1609
  // the user bubble never flickers empty while waiting
1320
1610
  // for real parts to arrive via message.part.updated.
@@ -1346,6 +1636,8 @@ export const useSyncStore = create()((set, get) => ({
1346
1636
  const props = event.properties;
1347
1637
  if (!props.sessionID || !props.messageID)
1348
1638
  return;
1639
+ if (store.reclaimRemovedMessage(props.sessionID, props.messageID))
1640
+ return;
1349
1641
  store.removeMessage(props.sessionID, props.messageID);
1350
1642
  return;
1351
1643
  }
@@ -1486,47 +1778,78 @@ export const useSyncStore = create()((set, get) => ({
1486
1778
  // above and the comment above deltaActiveParts.
1487
1779
  deltaActiveParts.delete(sid);
1488
1780
  deltaEventTails.delete(sid);
1489
- // Patch the error onto the last assistant message in the sync store.
1490
- // If no assistant message exists yet, create a temporary one so the
1491
- // error is visible immediately. The event handler in
1492
- // use-opencode-events.ts will also fetch real messages from the
1493
- // server which will bring in the authoritative data via hydrate().
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().
1494
1803
  set((s) => {
1495
1804
  const msgs = s.messages[sid] ?? [];
1496
- // Find last assistant message and patch .error onto it
1805
+ // The prompt this error answers.
1806
+ let userIdx = -1;
1497
1807
  for (let i = msgs.length - 1; i >= 0; i--) {
1498
- const msg = msgs[i];
1499
- if (msg.role === "assistant") {
1500
- if (msg.error)
1501
- return s; // already has error
1502
- const next = [...msgs];
1503
- // `error` may be the client-synthesized `SyntheticAbortError`
1504
- // (see `MessageError`), which the SDK's own `AssistantMessage.error`
1505
- // union doesn't declare — the assertion is the documented, narrow
1506
- // exception for that one extra shape.
1507
- next[i] = { ...msg, error };
1508
- return { messages: { ...s.messages, [sid]: next } };
1808
+ if (msgs[i].role === "user") {
1809
+ userIdx = i;
1810
+ break;
1509
1811
  }
1510
1812
  }
1511
- // No assistant message yet create a stub so the error shows.
1512
- // Tracked in `stubAssistantIds` (T16) so `hydrate` can
1513
- // reconcile it away once a real assistant message for this
1514
- // session lands see that map's doc comment and the
1515
- // reconciliation in `hydrate` below. `ascendingId('msg')` sorts
1516
- // BELOW every server id, which is fine as a placeholder position
1517
- // but wrong once real data exists; that is exactly what the
1518
- // reconciliation fixes.
1519
- const stubId = ascendingId("msg");
1520
- trackId(stubAssistantIds, sid, stubId);
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);
1521
1843
  const stubMsg = {
1522
1844
  id: stubId,
1523
1845
  sessionID: sid,
1524
1846
  role: "assistant",
1847
+ ...(userId ? { parentID: userId } : {}),
1525
1848
  error,
1526
1849
  };
1527
- return {
1528
- messages: { ...s.messages, [sid]: [...msgs, stubMsg] },
1529
- };
1850
+ const next = [...msgs];
1851
+ next.splice(userIdx === -1 ? next.length : userIdx + 1, 0, stubMsg);
1852
+ return { messages: { ...s.messages, [sid]: next } };
1530
1853
  });
1531
1854
  return;
1532
1855
  }