@kortix/sdk 0.13.0 → 0.13.2
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.
- package/dist/browser/session-sync/session-transcript-cache.d.ts +9 -2
- package/dist/browser/session-sync/session-transcript-cache.d.ts.map +1 -1
- package/dist/browser/session-sync/session-transcript-cache.js +15 -5
- package/dist/browser/session-sync/session-transcript-cache.js.map +1 -1
- package/dist/browser/stores/sync-store.d.ts +39 -1
- package/dist/browser/stores/sync-store.d.ts.map +1 -1
- package/dist/browser/stores/sync-store.js +393 -70
- package/dist/browser/stores/sync-store.js.map +1 -1
- package/dist/core/client/kortix.d.ts +1 -7
- package/dist/core/client/kortix.d.ts.map +1 -1
- package/dist/core/http/opencode-errors.d.ts +7 -0
- package/dist/core/http/opencode-errors.d.ts.map +1 -1
- package/dist/core/http/opencode-errors.js +45 -13
- package/dist/core/http/opencode-errors.js.map +1 -1
- package/dist/core/rest/projects-client/accounts.d.ts +0 -4
- package/dist/core/rest/projects-client/accounts.d.ts.map +1 -1
- package/dist/core/rest/projects-client/accounts.js.map +1 -1
- package/dist/core/rest/projects-client/connectors.d.ts +67 -1
- package/dist/core/rest/projects-client/connectors.d.ts.map +1 -1
- package/dist/core/rest/projects-client/connectors.js +17 -0
- package/dist/core/rest/projects-client/connectors.js.map +1 -1
- package/dist/core/rest/projects-client/projects.d.ts +1 -1
- package/dist/core/rest/projects-client/projects.d.ts.map +1 -1
- package/dist/core/rest/projects-client/projects.js +0 -1
- package/dist/core/rest/projects-client/projects.js.map +1 -1
- package/dist/core/rest/projects-client/secrets.d.ts +12 -5
- package/dist/core/rest/projects-client/secrets.d.ts.map +1 -1
- package/dist/core/rest/projects-client/secrets.js.map +1 -1
- package/dist/core/rest/projects-client/sessions.d.ts +38 -3
- package/dist/core/rest/projects-client/sessions.d.ts.map +1 -1
- package/dist/core/rest/projects-client/sessions.js +7 -2
- package/dist/core/rest/projects-client/sessions.js.map +1 -1
- package/dist/core/turns/grouping.d.ts +0 -14
- package/dist/core/turns/grouping.d.ts.map +1 -1
- package/dist/core/turns/grouping.js +25 -11
- package/dist/core/turns/grouping.js.map +1 -1
- package/dist/kortix.esm.min.js +22 -22
- package/dist/kortix.global.js +21 -21
- package/dist/react/query-contracts.d.ts +2 -0
- package/dist/react/query-contracts.d.ts.map +1 -1
- package/dist/react/query-contracts.js +2 -0
- package/dist/react/query-contracts.js.map +1 -1
- package/dist/react/query-keys.d.ts +5 -0
- package/dist/react/query-keys.d.ts.map +1 -1
- package/dist/react/query-keys.js +5 -0
- package/dist/react/query-keys.js.map +1 -1
- package/dist/react/use-session-prompts.d.ts.map +1 -1
- package/dist/react/use-session-prompts.js +8 -0
- package/dist/react/use-session-prompts.js.map +1 -1
- package/dist/react/use-session-send.d.ts.map +1 -1
- package/dist/react/use-session-send.js +5 -1
- package/dist/react/use-session-send.js.map +1 -1
- package/dist/react/use-session-sync.d.ts.map +1 -1
- package/dist/react/use-session-sync.js +3 -2
- package/dist/react/use-session-sync.js.map +1 -1
- package/dist/react/use-session.d.ts +9 -0
- package/dist/react/use-session.d.ts.map +1 -1
- package/dist/react/use-session.js +16 -1
- package/dist/react/use-session.js.map +1 -1
- 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(
|
|
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,
|
|
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
|
|
202
|
-
*
|
|
203
|
-
* `
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
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
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
//
|
|
922
|
-
//
|
|
923
|
-
//
|
|
924
|
-
//
|
|
925
|
-
//
|
|
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
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
1003
|
-
//
|
|
1004
|
-
//
|
|
1005
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1490
|
-
//
|
|
1491
|
-
//
|
|
1492
|
-
//
|
|
1493
|
-
//
|
|
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
|
-
//
|
|
1805
|
+
// The prompt this error answers.
|
|
1806
|
+
let userIdx = -1;
|
|
1497
1807
|
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
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
|
-
|
|
1512
|
-
//
|
|
1513
|
-
//
|
|
1514
|
-
//
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
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
|
-
|
|
1528
|
-
|
|
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
|
}
|