@kortix/sdk 0.13.4 → 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/cache/idb-sync-cache.d.ts +19 -1
- package/dist/browser/cache/idb-sync-cache.d.ts.map +1 -1
- package/dist/browser/cache/idb-sync-cache.js +39 -4
- package/dist/browser/cache/idb-sync-cache.js.map +1 -1
- 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 -1
- package/dist/browser/session-sync/session-sync-registry.d.ts.map +1 -1
- package/dist/browser/session-sync/session-sync-registry.js +117 -9
- package/dist/browser/session-sync/session-sync-registry.js.map +1 -1
- package/dist/browser/session-sync/visibility.d.ts +19 -0
- package/dist/browser/session-sync/visibility.d.ts.map +1 -0
- package/dist/browser/session-sync/visibility.js +16 -0
- package/dist/browser/session-sync/visibility.js.map +1 -0
- 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 +6 -3
- 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 +56 -2
- package/dist/browser/stores/sync-store.d.ts.map +1 -1
- package/dist/browser/stores/sync-store.js +682 -402
- 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 -29
- package/dist/core/client/kortix.d.ts.map +1 -1
- package/dist/core/client/kortix.js +61 -8
- 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/channels.d.ts +0 -3
- package/dist/core/rest/projects-client/channels.d.ts.map +1 -1
- package/dist/core/rest/projects-client/channels.js +0 -6
- package/dist/core/rest/projects-client/channels.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 -2
- package/dist/core/rest/projects-client/index.d.ts.map +1 -1
- package/dist/core/rest/projects-client/index.js +1 -2
- 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 +6 -3
- 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 +142 -34
- package/dist/core/rest/projects-client/sessions.d.ts.map +1 -1
- package/dist/core/rest/projects-client/sessions.js +48 -19
- package/dist/core/rest/projects-client/sessions.js.map +1 -1
- package/dist/core/runtime/attachment-part.d.ts +26 -0
- package/dist/core/runtime/attachment-part.d.ts.map +1 -0
- package/dist/core/runtime/attachment-part.js +41 -0
- package/dist/core/runtime/attachment-part.js.map +1 -0
- package/dist/core/runtime/client.d.ts +4 -2
- package/dist/core/runtime/client.d.ts.map +1 -1
- package/dist/core/runtime/client.js +3 -2
- package/dist/core/runtime/client.js.map +1 -1
- package/dist/core/runtime/opencode-v2-event-aliases.d.ts +184 -0
- package/dist/core/runtime/opencode-v2-event-aliases.d.ts.map +1 -0
- package/dist/core/runtime/opencode-v2-event-aliases.js +2 -0
- package/dist/core/runtime/opencode-v2-event-aliases.js.map +1 -0
- package/dist/core/runtime/pty.d.ts +11 -1
- package/dist/core/runtime/pty.d.ts.map +1 -1
- package/dist/core/runtime/pty.js +19 -6
- package/dist/core/runtime/pty.js.map +1 -1
- package/dist/core/session/connection.d.ts +64 -0
- package/dist/core/session/connection.d.ts.map +1 -0
- package/dist/core/session/connection.js +36 -0
- package/dist/core/session/connection.js.map +1 -0
- 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/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/preview-auth.d.ts +25 -0
- package/dist/core/session/preview-auth.d.ts.map +1 -1
- package/dist/core/session/preview-auth.js +31 -1
- package/dist/core/session/preview-auth.js.map +1 -1
- package/dist/core/session/preview-config.d.ts +26 -0
- package/dist/core/session/preview-config.d.ts.map +1 -0
- package/dist/core/session/preview-config.js +95 -0
- package/dist/core/session/preview-config.js.map +1 -0
- package/dist/core/session/preview-options.d.ts +25 -0
- package/dist/core/session/preview-options.d.ts.map +1 -0
- package/dist/core/session/preview-options.js +45 -0
- package/dist/core/session/preview-options.js.map +1 -0
- package/dist/core/session/preview.d.ts +8 -3
- package/dist/core/session/preview.d.ts.map +1 -1
- package/dist/core/session/preview.js +11 -4
- package/dist/core/session/preview.js.map +1 -1
- package/dist/core/session/rewind.d.ts +67 -24
- package/dist/core/session/rewind.d.ts.map +1 -1
- package/dist/core/session/rewind.js +74 -26
- package/dist/core/session/rewind.js.map +1 -1
- package/dist/core/session/server-store/active.d.ts +10 -12
- package/dist/core/session/server-store/active.d.ts.map +1 -1
- package/dist/core/session/server-store/active.js +11 -9
- package/dist/core/session/server-store/active.js.map +1 -1
- package/dist/core/session/server-store/url-helpers.d.ts +11 -2
- package/dist/core/session/server-store/url-helpers.d.ts.map +1 -1
- package/dist/core/session/server-store/url-helpers.js +11 -2
- package/dist/core/session/server-store/url-helpers.js.map +1 -1
- 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/url.d.ts +25 -2
- package/dist/core/session/url.d.ts.map +1 -1
- package/dist/core/session/url.js +87 -11
- package/dist/core/session/url.js.map +1 -1
- 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 +107 -2
- package/dist/core/session/working.d.ts.map +1 -1
- package/dist/core/session/working.js +206 -7
- package/dist/core/session/working.js.map +1 -1
- package/dist/core/session-sync/durable-log.d.ts +96 -0
- package/dist/core/session-sync/durable-log.d.ts.map +1 -0
- package/dist/core/session-sync/durable-log.js +94 -0
- package/dist/core/session-sync/durable-log.js.map +1 -0
- package/dist/core/session-sync/fragment.d.ts +27 -0
- package/dist/core/session-sync/fragment.d.ts.map +1 -0
- package/dist/core/session-sync/fragment.js +24 -0
- package/dist/core/session-sync/fragment.js.map +1 -0
- package/dist/core/session-sync/session-sync-controller.d.ts +117 -11
- package/dist/core/session-sync/session-sync-controller.d.ts.map +1 -1
- package/dist/core/session-sync/session-sync-controller.js +278 -26
- 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 +73 -23
- 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 +16 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +19 -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/platform/auth-core.d.ts +13 -1
- package/dist/platform/auth-core.d.ts.map +1 -1
- package/dist/platform/auth-core.js +13 -1
- package/dist/platform/auth-core.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 +3 -1
- 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/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 +36 -1
- package/dist/react/use-admin-accounts.d.ts.map +1 -1
- package/dist/react/use-admin-accounts.js +19 -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/handle-event.d.ts.map +1 -1
- package/dist/react/use-opencode-events/handle-event.js +26 -0
- package/dist/react/use-opencode-events/handle-event.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 +80 -8
- package/dist/react/use-opencode-events/index.js.map +1 -1
- package/dist/react/use-opencode-events/rehydrate-targets.d.ts +18 -0
- package/dist/react/use-opencode-events/rehydrate-targets.d.ts.map +1 -0
- package/dist/react/use-opencode-events/rehydrate-targets.js +20 -0
- package/dist/react/use-opencode-events/rehydrate-targets.js.map +1 -0
- package/dist/react/use-opencode-events/stream-revival.d.ts +37 -0
- package/dist/react/use-opencode-events/stream-revival.d.ts.map +1 -0
- package/dist/react/use-opencode-events/stream-revival.js +53 -0
- package/dist/react/use-opencode-events/stream-revival.js.map +1 -0
- 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-pty.d.ts +7 -1
- package/dist/react/use-opencode-pty.d.ts.map +1 -1
- package/dist/react/use-opencode-pty.js +6 -2
- package/dist/react/use-opencode-pty.js.map +1 -1
- package/dist/react/use-opencode-sessions/agents.d.ts.map +1 -1
- package/dist/react/use-opencode-sessions/agents.js +13 -2
- package/dist/react/use-opencode-sessions/agents.js.map +1 -1
- package/dist/react/use-opencode-sessions/commands.d.ts +12 -0
- package/dist/react/use-opencode-sessions/commands.d.ts.map +1 -1
- package/dist/react/use-opencode-sessions/commands.js +15 -3
- package/dist/react/use-opencode-sessions/commands.js.map +1 -1
- package/dist/react/use-opencode-sessions/index.d.ts +1 -0
- package/dist/react/use-opencode-sessions/index.d.ts.map +1 -1
- package/dist/react/use-opencode-sessions/index.js +1 -0
- package/dist/react/use-opencode-sessions/index.js.map +1 -1
- package/dist/react/use-opencode-sessions/keys.d.ts +8 -0
- package/dist/react/use-opencode-sessions/keys.d.ts.map +1 -1
- package/dist/react/use-opencode-sessions/keys.js +8 -0
- package/dist/react/use-opencode-sessions/keys.js.map +1 -1
- package/dist/react/use-opencode-sessions/providers.d.ts.map +1 -1
- package/dist/react/use-opencode-sessions/providers.js +56 -5
- package/dist/react/use-opencode-sessions/providers.js.map +1 -1
- package/dist/react/use-opencode-sessions/shared.d.ts +23 -0
- package/dist/react/use-opencode-sessions/shared.d.ts.map +1 -1
- package/dist/react/use-opencode-sessions/shared.js +31 -0
- package/dist/react/use-opencode-sessions/shared.js.map +1 -1
- package/dist/react/use-opencode-sessions/vcs.d.ts +28 -0
- package/dist/react/use-opencode-sessions/vcs.d.ts.map +1 -0
- package/dist/react/use-opencode-sessions/vcs.js +42 -0
- package/dist/react/use-opencode-sessions/vcs.js.map +1 -0
- 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 +53 -3
- package/dist/react/use-session-prompts.d.ts.map +1 -1
- package/dist/react/use-session-prompts.js +133 -21
- package/dist/react/use-session-prompts.js.map +1 -1
- package/dist/react/use-session-send.d.ts +17 -0
- package/dist/react/use-session-send.d.ts.map +1 -1
- package/dist/react/use-session-send.js +54 -5
- 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 +35 -1
- package/dist/react/use-session-sync.d.ts.map +1 -1
- package/dist/react/use-session-sync.js +135 -53
- package/dist/react/use-session-sync.js.map +1 -1
- package/dist/react/use-session-working.d.ts +55 -0
- package/dist/react/use-session-working.d.ts.map +1 -1
- package/dist/react/use-session-working.js +114 -13
- 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
- package/dist/browser/session-sync/session-transcript-cache.d.ts +0 -79
- package/dist/browser/session-sync/session-transcript-cache.d.ts.map +0 -1
- package/dist/browser/session-sync/session-transcript-cache.js +0 -91
- package/dist/browser/session-sync/session-transcript-cache.js.map +0 -1
- package/dist/core/rest/projects-client/public-voice-join.d.ts +0 -59
- package/dist/core/rest/projects-client/public-voice-join.d.ts.map +0 -1
- package/dist/core/rest/projects-client/public-voice-join.js +0 -64
- package/dist/core/rest/projects-client/public-voice-join.js.map +0 -1
- package/dist/core/rest/projects-client/transcription.d.ts +0 -5
- package/dist/core/rest/projects-client/transcription.d.ts.map +0 -1
- package/dist/core/rest/projects-client/transcription.js +0 -14
- package/dist/core/rest/projects-client/transcription.js.map +0 -1
|
@@ -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,10 +33,85 @@ 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
|
}
|
|
38
52
|
// ============================================================================
|
|
53
|
+
// Locating things in a list that is NOT id-sorted.
|
|
54
|
+
//
|
|
55
|
+
// `Binary.search` requires ascending ids. None of this store's lists are
|
|
56
|
+
// reliably ascending, for two independent reasons:
|
|
57
|
+
//
|
|
58
|
+
// 1. optimistic messages are appended out of order on purpose (see
|
|
59
|
+
// `optimisticRemove`, which says so), and
|
|
60
|
+
// 2. OpenCode 1.18.15 retired the invariant that ids ascend with time
|
|
61
|
+
// altogether — `MessageV2.latest()` orders by `time.created`, with the id
|
|
62
|
+
// only as a tie-break, and `MessageV2.page()` (every transcript read this
|
|
63
|
+
// store performs) has ALWAYS ordered by `time_created`.
|
|
64
|
+
//
|
|
65
|
+
// A binary HIT is still trustworthy: `Binary.search` reports `found` only on an
|
|
66
|
+
// exact id match. A binary MISS is not — on an unsorted list it is a coin
|
|
67
|
+
// flip, and treating it as proof of absence is how `upsertPart` spliced a
|
|
68
|
+
// duplicate part (restarting the visible stream), `removePart` no-opped (a
|
|
69
|
+
// cancelled tool call stayed on screen), and `applyPartDelta` dropped deltas
|
|
70
|
+
// (the assistant appeared to hang mid-sentence).
|
|
71
|
+
// ============================================================================
|
|
72
|
+
/**
|
|
73
|
+
* How coarsely `sessionActivityAt` is stamped.
|
|
74
|
+
*
|
|
75
|
+
* One second: far finer than any consumer needs (the projection compares it
|
|
76
|
+
* against observations bounded in tens of seconds) and coarse enough that a
|
|
77
|
+
* runtime streaming at ~140ms per frame updates it once, not seven times.
|
|
78
|
+
*/
|
|
79
|
+
const ACTIVITY_STAMP_RESOLUTION_MS = 1_000;
|
|
80
|
+
/** The index of `id` in `list`, or `-1`. Binary first, linear on a miss. */
|
|
81
|
+
function indexOfId(list, id, idOf) {
|
|
82
|
+
const result = Binary.search(list, id, idOf);
|
|
83
|
+
if (result.found && list[result.index] !== undefined && idOf(list[result.index]) === id) {
|
|
84
|
+
return result.index;
|
|
85
|
+
}
|
|
86
|
+
return list.findIndex((item) => idOf(item) === id);
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Where a message the current snapshot did not contain belongs: the first
|
|
90
|
+
* position holding a strictly newer message, or the end.
|
|
91
|
+
*
|
|
92
|
+
* Ordered by `time.created` with the id as the ONLY tie-break — the same order
|
|
93
|
+
* the server's own `MessageV2.latest()` uses, and the key `MessageV2.page()`
|
|
94
|
+
* pages by. That keeps a locally-known message (an SSE arrival, a re-keyed
|
|
95
|
+
* stub) in the sequence the next page read will produce. A message with no
|
|
96
|
+
* readable `time` — every `session.error` stub this store mints — cannot be
|
|
97
|
+
* dated and goes last, which is where the newest thing we know about belongs.
|
|
98
|
+
*/
|
|
99
|
+
function insertIndexByTime(list, message) {
|
|
100
|
+
const created = message.time?.created;
|
|
101
|
+
if (created === undefined)
|
|
102
|
+
return list.length;
|
|
103
|
+
for (let index = 0; index < list.length; index++) {
|
|
104
|
+
const other = list[index].time?.created;
|
|
105
|
+
if (other === undefined)
|
|
106
|
+
continue;
|
|
107
|
+
if (other > created)
|
|
108
|
+
return index;
|
|
109
|
+
if (other === created && list[index].id > message.id)
|
|
110
|
+
return index;
|
|
111
|
+
}
|
|
112
|
+
return list.length;
|
|
113
|
+
}
|
|
114
|
+
// ============================================================================
|
|
39
115
|
// Store Implementation
|
|
40
116
|
// Track optimistic message IDs so we can remove them when the server sends
|
|
41
117
|
// the real user message (which has a different, server-generated ID).
|
|
@@ -271,26 +347,52 @@ function untrackStub(sessionID, stubId) {
|
|
|
271
347
|
}
|
|
272
348
|
/** The stub id for the turn `userId` opened.
|
|
273
349
|
*
|
|
274
|
-
* Derived from the user message's own id
|
|
275
|
-
*
|
|
276
|
-
*
|
|
277
|
-
* message
|
|
278
|
-
*
|
|
279
|
-
* follows `userId` itself. Position and `parentID` therefore agree, whichever
|
|
280
|
-
* of the two a reader uses. */
|
|
350
|
+
* Derived from the user message's own id so it is IDEMPOTENT — a second
|
|
351
|
+
* `session.error` for the same turn finds the stub it already made instead of
|
|
352
|
+
* appending another. The stub is always PLACED directly after its user
|
|
353
|
+
* message (see `rekeyStubParent` and `hydrate`), and carries `parentID`;
|
|
354
|
+
* neither its position nor its linkage is inferred from the id. */
|
|
281
355
|
const stubIdFor = (userId) => `${userId}_error`;
|
|
282
356
|
/** A stub id as seen from a list that has no tracking map to hand (`hydrate`'s
|
|
283
357
|
* incoming server snapshot, which can never legitimately contain one). */
|
|
284
358
|
const isStubShaped = (id) => id.endsWith("_error");
|
|
359
|
+
/** The index in `messages` of the assistant message answering the turn
|
|
360
|
+
* `parentId` opened, or `-1`.
|
|
361
|
+
*
|
|
362
|
+
* `parentID` is the linkage `groupMessagesIntoTurns` reads and the only
|
|
363
|
+
* authoritative one. A wire assistant message that carries none is matched by
|
|
364
|
+
* TIME instead — `time.created` at or after the prompt's. It used to be
|
|
365
|
+
* matched by `m.id > parentId`, on the rationale "ids ascend, so a greater id
|
|
366
|
+
* is a later message". OpenCode 1.18.15 retired that invariant (turn exit now
|
|
367
|
+
* reads `lastAssistant.parentID === lastUser.id`, and `MessageV2.latest()`
|
|
368
|
+
* orders by `time.created`), and the comparison failed both ways: a real
|
|
369
|
+
* reply with a lower id left a stale `session.error` stub beside it, and an
|
|
370
|
+
* EARLIER turn's parentless reply with a higher id deleted the only record
|
|
371
|
+
* that this turn failed (the 2026-08-19 `ModelNotFound` report).
|
|
372
|
+
*
|
|
373
|
+
* A parentless candidate that cannot be dated — because it carries no `time`,
|
|
374
|
+
* or because the prompt itself is not in `messages` — is not a match: keeping
|
|
375
|
+
* a stub that turns out to be redundant is recoverable on the next snapshot;
|
|
376
|
+
* deleting the only evidence of a failed turn is not.
|
|
377
|
+
*
|
|
378
|
+
* With no parent turn to speak of, any assistant message answers. */
|
|
379
|
+
function findAssistantForTurn(messages, parentId) {
|
|
380
|
+
const parentCreated = parentId
|
|
381
|
+
? messages.find((m) => m.id === parentId)?.time?.created
|
|
382
|
+
: undefined;
|
|
383
|
+
return messages.findIndex((m) => m.role === "assistant" &&
|
|
384
|
+
!isStubShaped(m.id) &&
|
|
385
|
+
(!parentId ||
|
|
386
|
+
m.parentID === parentId ||
|
|
387
|
+
(!m.parentID &&
|
|
388
|
+
parentCreated !== undefined &&
|
|
389
|
+
m.time?.created !== undefined &&
|
|
390
|
+
m.time.created >= parentCreated)));
|
|
391
|
+
}
|
|
285
392
|
/** Does `messages` hold an assistant message answering the turn `parentId`
|
|
286
|
-
* opened?
|
|
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. */
|
|
393
|
+
* opened? See {@link findAssistantForTurn}. */
|
|
290
394
|
function hasAssistantForTurn(messages, parentId) {
|
|
291
|
-
return messages
|
|
292
|
-
!isStubShaped(m.id) &&
|
|
293
|
-
(!parentId || m.parentID === parentId || (!m.parentID && m.id > parentId)));
|
|
395
|
+
return findAssistantForTurn(messages, parentId) !== -1;
|
|
294
396
|
}
|
|
295
397
|
/** Move this session's `session.error` stubs from the optimistic user message
|
|
296
398
|
* `fromId` onto the server's own copy of that prompt, `toId`.
|
|
@@ -543,6 +645,9 @@ export const useSyncStore = create()((set, get) => ({
|
|
|
543
645
|
messages: {},
|
|
544
646
|
parts: {},
|
|
545
647
|
sessionStatus: {},
|
|
648
|
+
sessionStatusOrigin: {},
|
|
649
|
+
sessionStatusAt: {},
|
|
650
|
+
sessionActivityAt: {},
|
|
546
651
|
diffs: {},
|
|
547
652
|
todos: {},
|
|
548
653
|
sessionRevert: {},
|
|
@@ -571,9 +676,15 @@ export const useSyncStore = create()((set, get) => ({
|
|
|
571
676
|
next[linearIdx] = message;
|
|
572
677
|
return { messages: { ...s.messages, [sessionID]: next } };
|
|
573
678
|
}
|
|
574
|
-
// New message —
|
|
679
|
+
// New message — placed by `time.created`, the key the server's own
|
|
680
|
+
// `MessageV2.page()` orders by. It used to go in at the binary index,
|
|
681
|
+
// which was only ever meaningful while `hydrate` re-sorted the
|
|
682
|
+
// transcript by id. It no longer does (the page order IS the order),
|
|
683
|
+
// and ids stopped ascending with time in OpenCode 1.18.15 — so an
|
|
684
|
+
// id-sorted insert dropped a brand-new SSE message into the MIDDLE of
|
|
685
|
+
// the transcript. See `insertIndexByTime`.
|
|
575
686
|
const next = [...list];
|
|
576
|
-
next.splice(
|
|
687
|
+
next.splice(insertIndexByTime(next, message), 0, message);
|
|
577
688
|
return { messages: { ...s.messages, [sessionID]: next } };
|
|
578
689
|
}),
|
|
579
690
|
removeMessage: (sessionID, messageID) => set((s) => {
|
|
@@ -739,16 +850,32 @@ export const useSyncStore = create()((set, get) => ({
|
|
|
739
850
|
return { parts: { ...s.parts, [messageID]: next } };
|
|
740
851
|
});
|
|
741
852
|
},
|
|
742
|
-
setStatus: (sessionID, status) => set((s) => {
|
|
853
|
+
setStatus: (sessionID, status, origin = "wire") => set((s) => {
|
|
743
854
|
// A value that did not change is not news. `useSessionWorking` stamps
|
|
744
855
|
// its stream observation from this object's IDENTITY, so an
|
|
745
856
|
// equal-valued rewrite that minted a new object re-started
|
|
746
857
|
// `STREAM_OBSERVATION_MAX_MS` from zero on every write — the bound
|
|
747
858
|
// that stops a dead stream from deciding was never reached. See
|
|
748
859
|
// `sameSessionStatus`.
|
|
749
|
-
if (sameSessionStatus(s.sessionStatus[sessionID], status))
|
|
750
|
-
|
|
751
|
-
|
|
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
|
+
};
|
|
752
879
|
}),
|
|
753
880
|
setDiff: (sessionID, diffs) => set((s) => ({
|
|
754
881
|
diffs: { ...s.diffs, [sessionID]: diffs },
|
|
@@ -779,12 +906,20 @@ export const useSyncStore = create()((set, get) => ({
|
|
|
779
906
|
return s;
|
|
780
907
|
return { sessionRevert: { ...s.sessionRevert, [sessionId]: null } };
|
|
781
908
|
}),
|
|
782
|
-
applyCommittedRevert: (sessionId, boundaryId, watermark) => set((s) => {
|
|
909
|
+
applyCommittedRevert: (sessionId, boundaryId, watermark, hiddenIds) => set((s) => {
|
|
783
910
|
const list = s.messages[sessionId];
|
|
784
911
|
if (!list || list.length === 0) {
|
|
785
912
|
return { sessionRevert: { ...s.sessionRevert, [sessionId]: null } };
|
|
786
913
|
}
|
|
787
|
-
|
|
914
|
+
// `hiddenIds` is the set captured when the rewind was staged, and it
|
|
915
|
+
// is what decides. `watermark` only still answers for a caller that
|
|
916
|
+
// has no captured set — see `isWithinRewindWindow`.
|
|
917
|
+
const rewind = {
|
|
918
|
+
messageId: boundaryId,
|
|
919
|
+
watermark,
|
|
920
|
+
...(hiddenIds ? { hiddenIds } : {}),
|
|
921
|
+
staged: false,
|
|
922
|
+
};
|
|
788
923
|
const removedIds = [];
|
|
789
924
|
const kept = list.filter((m) => {
|
|
790
925
|
if (isWithinRewindWindow(m.id, rewind)) {
|
|
@@ -883,11 +1018,27 @@ export const useSyncStore = create()((set, get) => ({
|
|
|
883
1018
|
get().removeMessage(sessionID, messageID);
|
|
884
1019
|
},
|
|
885
1020
|
registerOptimisticEcho: (sessionID, optimisticID, echoID) => {
|
|
886
|
-
if (!isOptimistic(sessionID, optimisticID))
|
|
887
|
-
return;
|
|
888
1021
|
if (optimisticID === echoID)
|
|
889
1022
|
return;
|
|
890
|
-
|
|
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);
|
|
891
1042
|
},
|
|
892
1043
|
reclaimRemovedMessage: (sessionID, messageID) => {
|
|
893
1044
|
// See `controlPlaneOwnedIds`: a removal of a message the control plane
|
|
@@ -933,6 +1084,18 @@ export const useSyncStore = create()((set, get) => ({
|
|
|
933
1084
|
};
|
|
934
1085
|
});
|
|
935
1086
|
},
|
|
1087
|
+
noteSessionActivity: (sessionID, atMs) => {
|
|
1088
|
+
if (!sessionID)
|
|
1089
|
+
return;
|
|
1090
|
+
const now = atMs ?? Date.now();
|
|
1091
|
+
const previous = get().sessionActivityAt[sessionID];
|
|
1092
|
+
// Quantized: a busy runtime emits parts every few tens of milliseconds and
|
|
1093
|
+
// nothing reads this to the millisecond. Without the floor, every
|
|
1094
|
+
// subscriber re-renders at the stream's own rate for a timestamp.
|
|
1095
|
+
if (previous !== undefined && now - previous < ACTIVITY_STAMP_RESOLUTION_MS)
|
|
1096
|
+
return;
|
|
1097
|
+
set((s) => ({ sessionActivityAt: { ...s.sessionActivityAt, [sessionID]: now } }));
|
|
1098
|
+
},
|
|
936
1099
|
clearOptimisticMessages: (sessionID) => {
|
|
937
1100
|
set((s) => {
|
|
938
1101
|
const list = s.messages[sessionID];
|
|
@@ -970,10 +1133,18 @@ export const useSyncStore = create()((set, get) => ({
|
|
|
970
1133
|
for (const message of existingMessages)
|
|
971
1134
|
delete nextParts[message.id];
|
|
972
1135
|
deleteOrphanPartBuckets(nextParts, [sessionID]);
|
|
1136
|
+
const nextActivity = { ...s.sessionActivityAt };
|
|
1137
|
+
delete nextActivity[sessionID];
|
|
973
1138
|
return {
|
|
974
1139
|
messages: { ...s.messages, [sessionID]: [] },
|
|
975
1140
|
parts: nextParts,
|
|
1141
|
+
sessionActivityAt: nextActivity,
|
|
976
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() },
|
|
977
1148
|
diffs: { ...s.diffs, [sessionID]: [] },
|
|
978
1149
|
todos: { ...s.todos, [sessionID]: [] },
|
|
979
1150
|
sessionRevert: { ...s.sessionRevert, [sessionID]: null },
|
|
@@ -1055,394 +1226,464 @@ export const useSyncStore = create()((set, get) => ({
|
|
|
1055
1226
|
touchSessionMessageRows(sessionID, { msgs, partRefs, result });
|
|
1056
1227
|
return result;
|
|
1057
1228
|
},
|
|
1058
|
-
hydrate: (sessionID, msgs, opts) =>
|
|
1059
|
-
//
|
|
1060
|
-
//
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
.
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
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
|
+
}
|
|
1091
1281
|
}
|
|
1092
1282
|
}
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
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 = [];
|
|
1121
|
-
// Merge incoming messages with existing ones — never delete messages
|
|
1122
|
-
// that exist in the sync store but are missing from the fetch (they may
|
|
1123
|
-
// be from a newer turn that the server hasn't persisted yet).
|
|
1124
|
-
const existingAll = s.messages[sessionID] ?? [];
|
|
1125
|
-
const existing = droppedPhantoms
|
|
1126
|
-
? existingAll.filter((m) => !droppedPhantoms.has(m.id))
|
|
1127
|
-
: existingAll;
|
|
1128
|
-
const merged = [];
|
|
1129
|
-
const seen = new Set();
|
|
1130
|
-
// Which optimistic user messages has the server actually echoed?
|
|
1131
|
-
//
|
|
1132
|
-
// This test used to be EXISTENTIAL: "if this page holds any real user
|
|
1133
|
-
// message at all, every optimistic user message is a duplicate". In an
|
|
1134
|
-
// ongoing conversation that is always true — the page is full of
|
|
1135
|
-
// earlier turns — so a message sent one second ago, which the server
|
|
1136
|
-
// had provably never received, was deleted along with its text. The
|
|
1137
|
-
// window was wide open by construction: the rehydrate that calls this
|
|
1138
|
-
// only runs for sessions whose status is `busy`, and the optimistic
|
|
1139
|
-
// add is what sets `busy`. Sending armed the thing that deleted the
|
|
1140
|
-
// send.
|
|
1141
|
-
//
|
|
1142
|
-
// The rule is now identity-based, in two passes.
|
|
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);
|
|
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);
|
|
1157
1304
|
}
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
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
|
+
}
|
|
1177
1371
|
}
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
// Optimistic messages go at the end to avoid clock-skew sorting issues;
|
|
1186
|
-
// non-optimistic ones are inserted at their sorted position.
|
|
1187
|
-
const deferredOptimistic = [];
|
|
1188
|
-
const supersededOptimistic = [];
|
|
1189
|
-
/** optimistic id → the incoming message that superseded it. */
|
|
1190
|
-
const supersededBy = new Map();
|
|
1191
|
-
const unmatchedOptimisticUsers = [];
|
|
1192
|
-
for (const m of existing) {
|
|
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);
|
|
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))
|
|
1199
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);
|
|
1200
1385
|
}
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
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);
|
|
1204
1427
|
continue;
|
|
1205
1428
|
}
|
|
1206
|
-
|
|
1207
|
-
.
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
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
|
+
}
|
|
1213
1447
|
}
|
|
1214
1448
|
else {
|
|
1215
|
-
//
|
|
1216
|
-
//
|
|
1217
|
-
|
|
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);
|
|
1218
1452
|
}
|
|
1219
1453
|
}
|
|
1220
|
-
else {
|
|
1221
|
-
const r = Binary.search(merged, m.id, (x) => x.id);
|
|
1222
|
-
merged.splice(r.index, 0, m);
|
|
1223
|
-
}
|
|
1224
|
-
}
|
|
1225
|
-
}
|
|
1226
|
-
// Pass 2 — ordinal fallback, for an echo the server accepted but whose
|
|
1227
|
-
// parts have not landed yet (no part ids to match on). Restricted to
|
|
1228
|
-
// DISPATCHED messages: one that has not been POSTed cannot be a
|
|
1229
|
-
// duplicate of anything the server holds, so it is never eligible.
|
|
1230
|
-
// That restriction is what keeps a message sent from another tab from
|
|
1231
|
-
// consuming this tab's in-flight bubble.
|
|
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
1454
|
}
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
const
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
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
|
+
}
|
|
1256
1470
|
}
|
|
1257
|
-
|
|
1258
|
-
|
|
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
|
+
}
|
|
1259
1489
|
}
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
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);
|
|
1500
|
+
}
|
|
1268
1501
|
}
|
|
1269
|
-
|
|
1270
|
-
|
|
1502
|
+
// Append surviving optimistic messages at the end
|
|
1503
|
+
for (const m of deferredOptimistic) {
|
|
1504
|
+
merged.push(m);
|
|
1271
1505
|
}
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
const
|
|
1307
|
-
const
|
|
1308
|
-
|
|
1309
|
-
|
|
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);
|
|
1310
1556
|
}
|
|
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
1557
|
}
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
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];
|
|
1335
1580
|
delete newParts[id];
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
// bubble; `supersededBy` names the exact echo each optimistic message
|
|
1345
|
-
// lost to.
|
|
1346
|
-
for (const id of supersededOptimistic) {
|
|
1347
|
-
const echoId = supersededBy.get(id);
|
|
1348
|
-
const bridge = newParts[id];
|
|
1349
|
-
delete newParts[id];
|
|
1350
|
-
if (!echoId || !bridge?.length)
|
|
1351
|
-
continue;
|
|
1352
|
-
const echoEntry = msgs.find((m) => m.info.id === echoId);
|
|
1353
|
-
const serverHasParts = (echoEntry?.parts?.length ?? 0) > 0;
|
|
1354
|
-
if (serverHasParts || newParts[echoId]?.length)
|
|
1355
|
-
continue;
|
|
1356
|
-
newParts[echoId] = bridge;
|
|
1357
|
-
trackId(bridgedPartIds, sessionID, echoId);
|
|
1358
|
-
}
|
|
1359
|
-
for (const m of msgs) {
|
|
1360
|
-
if (!m?.info?.id)
|
|
1361
|
-
continue;
|
|
1362
|
-
const mid = m.info.id;
|
|
1363
|
-
if (isOptimistic(sessionID, mid))
|
|
1364
|
-
continue; // Don't touch optimistic parts
|
|
1365
|
-
const inParts = m.parts
|
|
1366
|
-
.filter((p) => !!p?.id)
|
|
1367
|
-
.sort((a, b) => cmp(a.id, b.id));
|
|
1368
|
-
// If this message still carries bridged optimistic parts, a hydrate
|
|
1369
|
-
// snapshot with real parts should replace them immediately. Otherwise
|
|
1370
|
-
// reconcile-by-extras can keep both copies and duplicate user text.
|
|
1371
|
-
if (hasTrackedId(bridgedPartIds, sessionID, mid) && inParts.length > 0) {
|
|
1372
|
-
untrackId(bridgedPartIds, sessionID, mid);
|
|
1373
|
-
newParts[mid] = inParts;
|
|
1374
|
-
continue;
|
|
1375
|
-
}
|
|
1376
|
-
const exParts = newParts[mid];
|
|
1377
|
-
if (!exParts || exParts.length === 0) {
|
|
1378
|
-
newParts[mid] = inParts;
|
|
1379
|
-
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);
|
|
1380
1589
|
}
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
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
|
+
}
|
|
1392
1634
|
return inP;
|
|
1393
|
-
|
|
1394
|
-
//
|
|
1395
|
-
//
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
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);
|
|
1400
1671
|
}
|
|
1401
|
-
|
|
1402
|
-
});
|
|
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) {
|
|
1435
|
-
const r = Binary.search(reconciled, ep.id, (p) => p.id);
|
|
1436
|
-
if (!r.found)
|
|
1437
|
-
reconciled.splice(r.index, 0, ep);
|
|
1672
|
+
newParts[mid] = reconciled;
|
|
1438
1673
|
}
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
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
|
+
},
|
|
1446
1687
|
reset: () => {
|
|
1447
1688
|
optimisticIds.clear();
|
|
1448
1689
|
dispatchedOptimisticIds.clear();
|
|
@@ -1463,6 +1704,9 @@ export const useSyncStore = create()((set, get) => ({
|
|
|
1463
1704
|
messages: {},
|
|
1464
1705
|
parts: {},
|
|
1465
1706
|
sessionStatus: {},
|
|
1707
|
+
sessionStatusOrigin: {},
|
|
1708
|
+
sessionStatusAt: {},
|
|
1709
|
+
sessionActivityAt: {},
|
|
1466
1710
|
diffs: {},
|
|
1467
1711
|
todos: {},
|
|
1468
1712
|
sessionRevert: {},
|
|
@@ -1491,6 +1735,12 @@ export const useSyncStore = create()((set, get) => ({
|
|
|
1491
1735
|
const store = get();
|
|
1492
1736
|
switch (event.type) {
|
|
1493
1737
|
case "message.updated": {
|
|
1738
|
+
{
|
|
1739
|
+
const info = event.properties?.info;
|
|
1740
|
+
const sid = info?.sessionID ?? event.properties?.sessionID;
|
|
1741
|
+
if (sid)
|
|
1742
|
+
get().noteSessionActivity(sid);
|
|
1743
|
+
}
|
|
1494
1744
|
const info = event.properties.info;
|
|
1495
1745
|
if (!info?.sessionID)
|
|
1496
1746
|
return;
|
|
@@ -1589,14 +1839,17 @@ export const useSyncStore = create()((set, get) => ({
|
|
|
1589
1839
|
const list = s.messages[info.sessionID] ?? [];
|
|
1590
1840
|
// Remove all optimistic user messages
|
|
1591
1841
|
const without = list.filter((m) => !optIds.includes(m.id));
|
|
1592
|
-
//
|
|
1593
|
-
|
|
1842
|
+
// Place the real message. Linear on a miss, then by TIME: a
|
|
1843
|
+
// false miss spliced a SECOND copy of a message already in
|
|
1844
|
+
// the transcript, at a binary index that means nothing on a
|
|
1845
|
+
// list the server never ordered by id.
|
|
1846
|
+
const at = indexOfId(without, info.id, (m) => m.id);
|
|
1594
1847
|
let next = [...without];
|
|
1595
|
-
if (
|
|
1596
|
-
next[
|
|
1848
|
+
if (at !== -1) {
|
|
1849
|
+
next[at] = info;
|
|
1597
1850
|
}
|
|
1598
1851
|
else {
|
|
1599
|
-
next.splice(
|
|
1852
|
+
next.splice(insertIndexByTime(next, info), 0, info);
|
|
1600
1853
|
}
|
|
1601
1854
|
// A turn that failed before this confirmation arrived
|
|
1602
1855
|
// has a `session.error` stub keyed to the optimistic id
|
|
@@ -1645,6 +1898,14 @@ export const useSyncStore = create()((set, get) => ({
|
|
|
1645
1898
|
const part = event.properties.part;
|
|
1646
1899
|
if (!part?.messageID)
|
|
1647
1900
|
return;
|
|
1901
|
+
// The runtime just produced output. This is the evidence
|
|
1902
|
+
// `projectWorking` trusts above every observer — see
|
|
1903
|
+
// `sessionActivityAt`.
|
|
1904
|
+
{
|
|
1905
|
+
const sid = part.sessionID ?? event.properties?.sessionID;
|
|
1906
|
+
if (sid)
|
|
1907
|
+
get().noteSessionActivity(sid);
|
|
1908
|
+
}
|
|
1648
1909
|
const eventSessionID = event.properties?.sessionID;
|
|
1649
1910
|
let resolvedSessionID = part.sessionID ?? eventSessionID;
|
|
1650
1911
|
if (!resolvedSessionID) {
|
|
@@ -1659,9 +1920,10 @@ export const useSyncStore = create()((set, get) => ({
|
|
|
1659
1920
|
const existingMsgs = resolvedSessionID
|
|
1660
1921
|
? get().messages[resolvedSessionID]
|
|
1661
1922
|
: undefined;
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1923
|
+
// The `.some()` alone is authoritative; the binary search that used
|
|
1924
|
+
// to sit beside it as a first disjunct could only agree with it or
|
|
1925
|
+
// miss on a list that is not id-sorted.
|
|
1926
|
+
const exists = existingMsgs?.some((m) => m.id === part.messageID);
|
|
1665
1927
|
if (!exists && resolvedSessionID) {
|
|
1666
1928
|
store.upsertMessage(resolvedSessionID, {
|
|
1667
1929
|
id: part.messageID,
|
|
@@ -1746,13 +2008,13 @@ export const useSyncStore = create()((set, get) => ({
|
|
|
1746
2008
|
case "session.status": {
|
|
1747
2009
|
const props = event.properties;
|
|
1748
2010
|
if (props.sessionID && props.status)
|
|
1749
|
-
store.setStatus(props.sessionID, props.status);
|
|
2011
|
+
store.setStatus(props.sessionID, props.status, syntheticEventOrigin(event));
|
|
1750
2012
|
return;
|
|
1751
2013
|
}
|
|
1752
2014
|
case "session.idle": {
|
|
1753
2015
|
const sessionID = event.properties.sessionID;
|
|
1754
2016
|
if (sessionID)
|
|
1755
|
-
store.setStatus(sessionID, { type: "idle" });
|
|
2017
|
+
store.setStatus(sessionID, { type: "idle" }, syntheticEventOrigin(event));
|
|
1756
2018
|
// Streaming finished for THIS session — clear only its own delta
|
|
1757
2019
|
// tracking so future message.part.updated snapshots for it are
|
|
1758
2020
|
// accepted normally. Never the whole map: another session may
|
|
@@ -1772,8 +2034,23 @@ export const useSyncStore = create()((set, get) => ({
|
|
|
1772
2034
|
return;
|
|
1773
2035
|
const sid = props.sessionID;
|
|
1774
2036
|
const error = props.error;
|
|
1775
|
-
//
|
|
1776
|
-
|
|
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
|
+
}
|
|
1777
2054
|
// Clear only this session's delta tracking — see the idle handler
|
|
1778
2055
|
// above and the comment above deltaActiveParts.
|
|
1779
2056
|
deltaActiveParts.delete(sid);
|
|
@@ -1871,7 +2148,10 @@ export const useSyncStore = create()((set, get) => ({
|
|
|
1871
2148
|
if (props.sessionID && props.messageID) {
|
|
1872
2149
|
const tracked = get().sessionRevert[props.sessionID];
|
|
1873
2150
|
if (tracked) {
|
|
1874
|
-
|
|
2151
|
+
// The captured set only describes the boundary it was staged for.
|
|
2152
|
+
// A `.committed` naming a different one falls back to the legacy
|
|
2153
|
+
// range rather than deleting the wrong trajectory.
|
|
2154
|
+
store.applyCommittedRevert(props.sessionID, props.messageID, tracked.watermark, tracked.messageId === props.messageID ? tracked.hiddenIds : undefined);
|
|
1875
2155
|
}
|
|
1876
2156
|
else {
|
|
1877
2157
|
// F2 — no tracked local record (fresh mount / second tab that
|