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