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