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