Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e681e9e05c | ||
|
|
d5427926d5 | ||
|
|
bbaa5e8859 |
+1
-24
@@ -605,25 +605,6 @@ durable entries and can advance without visible messages. Use returned cursors;
|
|||||||
do not derive them from message IDs. `limit` bounds source entries, not displayed
|
do not derive them from message IDs. `limit` bounds source entries, not displayed
|
||||||
messages.
|
messages.
|
||||||
|
|
||||||
Every room message, including protected tool rows and `activity.queuedMessages`,
|
|
||||||
has a string `id`, `role`, `content`, and ISO-8601 `createdAt`. Privacy filtering
|
|
||||||
retains this envelope and tool lifecycle metadata (`kind`, `toolCallId`,
|
|
||||||
`toolName`, `entryId`, `status`, and available `startedAt`/`completedAt`). Protected
|
|
||||||
tools use their public tool name as `content`; private arguments, previews, and
|
|
||||||
errors are withheld. Result-only pages may omit initiation metadata.
|
|
||||||
|
|
||||||
Historical entries without a known time use `1970-01-01T00:00:00.000Z` as an
|
|
||||||
unknown-time sentinel. For compatibility with older partial responses, clients
|
|
||||||
may recover missing/invalid `createdAt` from `metadata.startedAt`, then
|
|
||||||
`metadata.completedAt`, or use that sentinel; never substitute the current time.
|
|
||||||
Older redacted tool rows containing only `id`, `role`, and `name` can retain their
|
|
||||||
identity with a tool-name label until a complete upsert arrives. The web client
|
|
||||||
uses the same normalization for REST, WebSocket, and queued activity, retains
|
|
||||||
known times across partial upserts, and orders by time, numeric `entryId` (when
|
|
||||||
present, before absent), then stable `id`. Unknown times display as “Earlier”
|
|
||||||
without a clock time. Invalid identities or non-tool message bodies reject the
|
|
||||||
page before advancing its replay cursor; timestamps never replace cursors.
|
|
||||||
|
|
||||||
### `POST /v1/agent/messages`
|
### `POST /v1/agent/messages`
|
||||||
|
|
||||||
Body: `{ "content": "Check the oven", "requestId": "client-generated-uuid" }`.
|
Body: `{ "content": "Check the oven", "requestId": "client-generated-uuid" }`.
|
||||||
@@ -722,10 +703,6 @@ All push/config/presence routes require a configured `ADMIN_TOKEN` and bearer au
|
|||||||
|
|
||||||
`vapidPublicKey` is present only when Web Push is enabled and its credentials are valid. No private keys, tokens, paths or configured account identifiers are returned. Source discovery and browser presence run independently of transport enablement/installation consent. Clients bind local consent to a known source UUID as well as the authenticated backend; null/unknown source cannot opt in. A source change retires prior local consent/installation and requires a new UUID plus explicit user opt-in, rechecking the source after asynchronous permission/subscription/registration work. `sourceId` is the persisted bound runtime UUID or null before discovery. A changed runtime source or regressed durable cursor quarantines delivery and cancels pending alerts without resetting the canonical read cursor. There is deliberately no HTTP read-reset/rebaseline endpoint; an operator must reconcile replacement runtime data with source-aware clients before enabling a new baseline.
|
`vapidPublicKey` is present only when Web Push is enabled and its credentials are valid. No private keys, tokens, paths or configured account identifiers are returned. Source discovery and browser presence run independently of transport enablement/installation consent. Clients bind local consent to a known source UUID as well as the authenticated backend; null/unknown source cannot opt in. A source change retires prior local consent/installation and requires a new UUID plus explicit user opt-in, rechecking the source after asynchronous permission/subscription/registration work. `sourceId` is the persisted bound runtime UUID or null before discovery. A changed runtime source or regressed durable cursor quarantines delivery and cancels pending alerts without resetting the canonical read cursor. There is deliberately no HTTP read-reset/rebaseline endpoint; an operator must reconcile replacement runtime data with source-aware clients before enabling a new baseline.
|
||||||
|
|
||||||
`webPushAvailable` describes the **server transport**, not the browser or device's push service. Browser permission `granted` and exposed Push APIs also do not prove that a subscription can be created. The web client distinguishes insecure/unsupported contexts, iOS Home Screen requirements, permission dismissal/denial, worker startup, push subscription errors/timeouts, local storage failures, and authenticated server failures. HTTP 401/403 require sign-in recovery; 400/413 mean a rejected subscription; 409 requires a new installation identity; 429 means retry later; 503 indicates server availability/configuration. These are client diagnostics for existing statuses, with no endpoint or response-shape change. Raw provider messages, response bodies, subscription endpoints and credentials must never be echoed to the UI. Background presence/config refreshes preserve a failed opt-in's diagnosis until an explicit retry or refresh.
|
|
||||||
|
|
||||||
For Android/GrapheneOS setup and device verification limits, see [mobile web QA and notification guidance](../mobile-web-qa.md). Vanadium's Chromium Android push transport depends on Google Play services; a website cannot supply an alternative browser push transport or reliably detect installed Android packages. Keep this distinct from server configuration errors.
|
|
||||||
|
|
||||||
### `PUT /v1/agent/push/installations/:installationId`
|
### `PUT /v1/agent/push/installations/:installationId`
|
||||||
|
|
||||||
Use a stable random UUID per local installation, authenticated with the current shared token. Registration is explicit user opt-in and requires enabled/configured transport. Native body:
|
Use a stable random UUID per local installation, authenticated with the current shared token. Registration is explicit user opt-in and requires enabled/configured transport. Native body:
|
||||||
@@ -786,7 +763,7 @@ The web service worker accepts bounded nonempty preview strings, preserving the
|
|||||||
|
|
||||||
Shipping is disabled by default: `AGENT_PUSH_ENABLED=false`. Native transport requires `AGENT_PUSH_APNS_KEY_FILE` (APNs-enabled ES256 `.p8`, regular secret file 0400/0600), `AGENT_PUSH_APNS_KEY_ID`, `AGENT_PUSH_APNS_TEAM_ID`, optional `AGENT_PUSH_APNS_TOPIC` (default `net.buzzert.sybil2`) and `AGENT_PUSH_APNS_ENVIRONMENT` (`production` by default, or `sandbox`; exactly one credential environment per deployment). Web Push requires `AGENT_PUSH_VAPID_FILE`, a private regular JSON file 0400/0600 containing `publicKey`, `privateKey` (base64url P-256) and `subject` (mailto/HTTPS contact). Invalid/missing/unsafe files leave only that adapter unavailable; files and parse errors are never exposed. This implementation neither creates production keys nor changes Apple portal capabilities. Correct native team/topic is `DQQH5H6GBD` / `net.buzzert.sybil2`; parent portal work, push entitlements/provisioning and real device registration are separate prerequisites. An environment-scoped production key cannot send sandbox notifications: sandbox registrations are rejected when production is configured. Concurrent sandbox/production credentials require a future explicit per-environment credential map; do not reuse one scoped key across environments.
|
Shipping is disabled by default: `AGENT_PUSH_ENABLED=false`. Native transport requires `AGENT_PUSH_APNS_KEY_FILE` (APNs-enabled ES256 `.p8`, regular secret file 0400/0600), `AGENT_PUSH_APNS_KEY_ID`, `AGENT_PUSH_APNS_TEAM_ID`, optional `AGENT_PUSH_APNS_TOPIC` (default `net.buzzert.sybil2`) and `AGENT_PUSH_APNS_ENVIRONMENT` (`production` by default, or `sandbox`; exactly one credential environment per deployment). Web Push requires `AGENT_PUSH_VAPID_FILE`, a private regular JSON file 0400/0600 containing `publicKey`, `privateKey` (base64url P-256) and `subject` (mailto/HTTPS contact). Invalid/missing/unsafe files leave only that adapter unavailable; files and parse errors are never exposed. This implementation neither creates production keys nor changes Apple portal capabilities. Correct native team/topic is `DQQH5H6GBD` / `net.buzzert.sybil2`; parent portal work, push entitlements/provisioning and real device registration are separate prerequisites. An environment-scoped production key cannot send sandbox notifications: sandbox registrations are rejected when production is configured. Concurrent sandbox/production credentials require a future explicit per-environment credential map; do not reuse one scoped key across environments.
|
||||||
|
|
||||||
Backend subscribes to the token-authenticated private runtime WebSocket `GET /api/notification-events`. Its initial and coalesced `{type:"notification_changed",version:1}` frames trigger cursor-based reconciliation through `GET /api/notification-context?after=<cursor>&limit=200`; reconnects recover missed changes from the saved durable cursor. The socket uses bearer header authentication, rejects query strings, carries no content, and uses ping/pong without database access. There is no fixed two-second source poll. Local presence/read/prompt changes also wake reconciliation. One-shot timers cover debounce, grace and lease expiry, provider retry and abandoned send intents; idle maintenance runs hourly. Source failures retry with bounded backoff (1–30 seconds). The coordinator still fetches fresh context and checks every eligibility condition before each provider boundary. The backend sends `X-Sybil-Client: backend-proxy-v1` on private proxy REST/WS traffic and `backend-observer-v1` on the notification observer. The marker classifies the proxy for the native attention census and never replaces runtime authentication. The runtime source is version 1 with `{sourceId,conversationId,cursor,hasMore,latestUserMessageAt,nativeReadCursor,uncertain,nextChangeAt?,webPresence:{active,unknown,observedAt},candidates:[{messageId,cursor,createdAt,trigger}]}`; trigger is `user|external_event|timer|worker|unknown`. `nextChangeAt` is an optional nullable ISO deadline for the next runtime attention/uncertainty expiry; pending work schedules a refresh then and earlier events reschedule it. No transcript text is included. `uncertain` and `webPresence.unknown` fail closed. Candidate pages preserve all candidates belonging to each consumed cursor (max 200 candidates/page), advancing across non-candidates; omitted `after` is high-water bootstrap with no old candidates. The source identity survives normal runtime restarts. If an incremental context request returns 409 because its cursor exceeds a replaced/restored runtime high-water mark, the backend makes exactly one authenticated no-after probe under the same 10-second deadline. Only a candidate-free, nonpaginated snapshot proving a different source or lower cursor is accepted, solely to quarantine and cancel old pending alerts. It never resets reads/checkpoints, rebaselines, or enumerates historical candidates. Failed/invalid probes and same-source nonregressions remain unavailable and suppress delivery. Runtime native presence/read is an acknowledgment input to the existing canonical backend read state, not a second independently authoritative read store.
|
Backend independently polls token-authenticated runtime `GET /api/notification-context?after=<cursor>&limit=200` and sends `X-Sybil-Client: backend-proxy-v1` on private proxy REST/WS traffic. The marker classifies the proxy for the native attention census and never replaces runtime authentication. The runtime source is version 1 with `{sourceId,conversationId,cursor,hasMore,latestUserMessageAt,nativeReadCursor,uncertain,webPresence:{active,unknown,observedAt},candidates:[{messageId,cursor,createdAt,trigger}]}`; trigger is `user|external_event|timer|worker|unknown`. No transcript text is included. `uncertain` and `webPresence.unknown` fail closed. Candidate pages preserve all candidates belonging to each consumed cursor (max 200 candidates/page), advancing across non-candidates; omitted `after` is high-water bootstrap with no old candidates. The source identity survives normal runtime restarts. If an incremental context request returns 409 because its cursor exceeds a replaced/restored runtime high-water mark, the backend makes exactly one authenticated no-after probe under the same 10-second deadline. Only a candidate-free, nonpaginated snapshot proving a different source or lower cursor is accepted, solely to quarantine and cancel old pending alerts. It never resets reads/checkpoints, rebaselines, or enumerates historical candidates. Failed/invalid probes and same-source nonregressions remain unavailable and suppress delivery. Runtime native presence/read is an acknowledgment input to the existing canonical backend read state, not a second independently authoritative read store.
|
||||||
|
|
||||||
For an otherwise dispatchable message only, the backend resolves its text through the existing authenticated root public endpoint: `GET /api/conversations/root/messages?after=<candidate.cursor-1>&limit=1&includeTools=false`. The URL/path is fixed to the configured runtime, redirects are rejected, the response is limited to 512 KiB and the request to 10 seconds. The page must match the observed root conversation and exact entry cursor; exactly one message must match the candidate ID, assistant role, `metadata.kind:"message"`, `status:"complete"` and entry ID. Only that message's string `content` is used. The runtime's existing root-candidate/provenance rules remain authoritative; a `worker` trigger qualifies only a later public root reply, never the worker's own output. Source/read/presence and all delivery conditions are rechecked after lookup and before committing send intent. Preview text is not persisted in backend outbox/receipt rows. No runtime change or database migration is required.
|
For an otherwise dispatchable message only, the backend resolves its text through the existing authenticated root public endpoint: `GET /api/conversations/root/messages?after=<candidate.cursor-1>&limit=1&includeTools=false`. The URL/path is fixed to the configured runtime, redirects are rejected, the response is limited to 512 KiB and the request to 10 seconds. The page must match the observed root conversation and exact entry cursor; exactly one message must match the candidate ID, assistant role, `metadata.kind:"message"`, `status:"complete"` and entry ID. Only that message's string `content` is used. The runtime's existing root-candidate/provenance rules remain authoritative; a `worker` trigger qualifies only a later public root reply, never the worker's own output. Source/read/presence and all delivery conditions are rechecked after lookup and before committing send intent. Preview text is not persisted in backend outbox/receipt rows. No runtime change or database migration is required.
|
||||||
|
|
||||||
|
|||||||
@@ -326,14 +326,6 @@ with `metadata.kind: "tool_call"`, matching Sybil's tool renderer. REST pages an
|
|||||||
socket messages are upserts keyed by stable `id`. Save each returned cursor after
|
socket messages are upserts keyed by stable `id`. Save each returned cursor after
|
||||||
applying its entire frame, even when it contains no visible messages.
|
applying its entire frame, even when it contains no visible messages.
|
||||||
|
|
||||||
Both message frames and queued activity use the required message envelope and
|
|
||||||
historical timestamp compatibility rules in [REST room history](./rest.md#get-v1agentmessages).
|
|
||||||
Protected tool redaction preserves `createdAt`, a safe string `content`, and
|
|
||||||
available lifecycle metadata; it does not remove the row's identity or replay
|
|
||||||
metadata. Partial tool results may omit `args`/`startedAt`; merge them without
|
|
||||||
losing a known creation time or downgrading a completed tool on replay. Validate
|
|
||||||
and normalize all rows before applying a frame or advancing its cursor.
|
|
||||||
|
|
||||||
Clients may send `{ "type": "ping" }` (receiving `{ "type": "pong" }`) or
|
Clients may send `{ "type": "ping" }` (receiving `{ "type": "pong" }`) or
|
||||||
`{ "type": "subscribe", "after": "44" }` to change the replay cursor. Transport
|
`{ "type": "subscribe", "after": "44" }` to change the replay cursor. Transport
|
||||||
ping/pong heartbeats detect dead connections. Other writes belong on REST.
|
ping/pong heartbeats detect dead connections. Other writes belong on REST.
|
||||||
@@ -402,6 +394,3 @@ Distinguish an unbound room from one already bound to a legacy absent/null sourc
|
|||||||
Older clients may ignore unknown server frame types. `read_state` and presence are not client subscribe messages and are never forwarded to runtime as generic frames. Push consent, revoke, presence, retry/expiry and notification payload contracts live in [rest.md](./rest.md#agent-push-notifications-v1). The runtime built-in browser has its own authenticated presence/read inputs and bounded anonymous grace per runtime source. The separate 60-second restart safety intervals and source/clock uncertainty checks remain in effect. No Web Push is used for invisible read synchronization.
|
Older clients may ignore unknown server frame types. `read_state` and presence are not client subscribe messages and are never forwarded to runtime as generic frames. Push consent, revoke, presence, retry/expiry and notification payload contracts live in [rest.md](./rest.md#agent-push-notifications-v1). The runtime built-in browser has its own authenticated presence/read inputs and bounded anonymous grace per runtime source. The separate 60-second restart safety intervals and source/clock uncertainty checks remain in effect. No Web Push is used for invisible read synchronization.
|
||||||
|
|
||||||
Enabled notifications include a bounded plain-text preview of the eligible completed root public assistant message by default, including on the lock screen when OS settings allow it. The backend resolves only that candidate's exact public `content`, never activity/reasoning/tool/worker fields or linked resources. APNs uses a native string `aps.alert.body`; Web Push uses `body`. This does not change message IDs, stream frames, read acknowledgment or clearing semantics. See the REST contract for Markdown flattening, payload budgets and web worker update ordering.
|
Enabled notifications include a bounded plain-text preview of the eligible completed root public assistant message by default, including on the lock screen when OS settings allow it. The backend resolves only that candidate's exact public `content`, never activity/reasoning/tool/worker fields or linked resources. APNs uses a native string `aps.alert.body`; Web Push uses `body`. This does not change message IDs, stream frames, read acknowledgment or clearing semantics. See the REST contract for Markdown flattening, payload budgets and web worker update ordering.
|
||||||
|
|
||||||
|
|
||||||
The backend notification coordinator has its own private, token-authenticated runtime WebSocket at `/api/notification-events`. It receives content-free `{type:"notification_changed",version:1}` invalidations, including an initial frame on every connection, and reconciles durable notification context from its saved cursor. This replaces the fixed two-second backend poll; it does not change public Agent message/read-state frames or require a client release. Native presence/read changes and local backend presence/read/prompt changes wake the coordinator. Pending notification deadlines use one-shot timers (including the runtime's additive nullable `nextChangeAt` field); idle maintenance runs hourly. Reconnection, source failure backoff, and fresh eligibility checks before delivery preserve cursor recovery and suppression behavior. WebSocket ping/pong performs no database operations.
|
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
# Mobile web QA — 2026-11-02
|
|
||||||
|
|
||||||
This change is limited to the web client and its tests/documentation, with no backend contract or native app release changes. This report records the focused mobile QA and remaining device verification limits.
|
|
||||||
|
|
||||||
## Reproduction and fixes
|
|
||||||
|
|
||||||
The production bundle was exercised with local API/socket fixtures in touch-enabled Chromium at 390×844. Before/after measurements use identical messages and viewport dimensions.
|
|
||||||
|
|
||||||
| Area | Before | After |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| Installed display | Manifest preferred `fullscreen`, including `display_override` | `standalone`, no fullscreen override; normal Apple status-bar style |
|
|
||||||
| Horizontal table pan | `touch-action: pan-y` on the workspace; table stayed at `scrollLeft=0`, swipe created a new chat | Native table scrolled to `scrollLeft=160`, current chat retained |
|
|
||||||
| Regular assistant width | 304.30px of a 358px transcript at 390px viewport | 358px; desktop width rule retained |
|
|
||||||
| Agent message width | 287.52px | 287.52px; bubble/avatar styling retained |
|
|
||||||
| Compact model | Hidden | Provider and active model beneath title; updates after changing model in settings |
|
|
||||||
| Context menu | Assistant context-menu event canceled for a Fork-only menu | Native event allowed for text, links and composer; explicit message-actions button opens Fork |
|
|
||||||
| Zoom | Viewport disabled user scaling | Fixed-scale viewport retained (`maximum-scale=1.0`, `user-scalable=no`) |
|
|
||||||
| Notification failures | Browser exception names and server statuses replaced by a generic error; background refresh erased errors | Stage-specific, redacted recovery guidance, retained until explicit retry/refresh |
|
|
||||||
|
|
||||||
Text owns gestures across regular chat, search markdown and Agent. Dynamic horizontal scrollers and code/table/quote elements are protected even at both ends. Selections, multi-touch, long-press starts and touch cancellation abandon navigation tracking. Blank workspace chrome still supports sidebar/new-chat swipes. Notification help is bounded and scrollable; sidebar content remains reachable on shorter screens.
|
|
||||||
|
|
||||||
## Notification diagnosis and official sources
|
|
||||||
|
|
||||||
A read-only authenticated request to the running server's `GET /v1/agent/push/config` returned HTTP 200 with `enabled=true`, `webPushAvailable=true`, a known source, `quarantined=false`, and a public key present. Credentials/key values were not printed. This rules out disabled transport configuration at the time of inspection; it does not prove delivery or identify the actual phone's failed step.
|
|
||||||
|
|
||||||
Chromium's Android [GCM driver](https://chromium.googlesource.com/chromium/src/+/main/components/gcm_driver/android/java/src/org/chromium/components/gcm_driver/GCMDriver.java) uses [GoogleCloudMessagingV2](https://chromium.googlesource.com/chromium/src/+/main/components/gcm_driver/android/java/src/org/chromium/components/gcm_driver/GoogleCloudMessagingV2.java), which checks for `com.google.android.gms` and rejects registration when Google Play services is absent. Vanadium is GrapheneOS's Chromium browser, as documented in the [official browsing guide](https://grapheneos.org/usage#web-browsing). Missing/unreachable Play services is therefore a plausible cause on GrapheneOS; the old generic message cannot establish that cause.
|
|
||||||
|
|
||||||
The [official sandboxed Google Play guide](https://grapheneos.org/usage#sandboxed-google-play) requires dependent apps and Play in the same profile and recommends a battery optimization exception for background push. Check network access, Sybil's site notification permission and Vanadium's Android notification permission, then reload and explicitly enable again. Users choosing not to install Play need a browser with an independent push service. Sybil cannot replace that browser service or inspect installed Android packages, so it does not disable Vanadium based on user-agent detection.
|
|
||||||
|
|
||||||
Permission and subscription are separate: [`PushManager.subscribe()`](https://developer.mozilla.org/en-US/docs/Web/API/PushManager/subscribe) can fail after permission was granted. Diagnostics identify the failed layer and known exception name without echoing arbitrary browser/provider messages. No permissions are requested in the background; opt-in still requests permission synchronously from the user's click. Registration failures retain rollback and revocation behavior, including storage failure after browser subscription.
|
|
||||||
|
|
||||||
`standalone` can retain the device status bar, unlike fullscreen ([manifest display reference](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest/Reference/display)). Existing installations may retain older manifest metadata until their browser updates it. Chrome documents [manifest update scheduling](https://web.dev/articles/manifest-updates); Vanadium installation/update behavior still needs device verification. The manifest URL, app ID, start URL and service-worker scope remain stable.
|
|
||||||
|
|
||||||
## Automated verification
|
|
||||||
|
|
||||||
- `npm run build` and `npm test` in `web`.
|
|
||||||
- `mobile-workspace.browser.mjs`: trusted CDP touch pans on tables, nested quotation code and code blocks; both scroll edges; selection/clipboard copy and text paste; uncanceled context-menu events; explicit Fork menu; synthetic multi-touch/long-press/cancel scenarios; both navigation directions; model change; widths 320, 390, 430 and 767; 1280px desktop check; 450px-height composer checks; unchanged Agent width; manifest and zoom settings. Screenshots were inspected.
|
|
||||||
- `agent-push-errors.browser.mjs`: mobile permission dismissal/denial, worker failure, `AbortError`, `NotAllowedError`, `NotSupportedError`, `InvalidStateError`, subscription timeout, HTTP 400/401/403/409/429/503, rollback, preserved error after background refresh, successful retry, missing APIs/insecure context, iOS Home Screen guidance, and reachable notification controls at 320×568 and 390×450. API/push-provider outcomes are mocked; worker registration is real except the deliberate failure case.
|
|
||||||
- Existing `agent-push.browser.mjs`: real worker activation/notification display/IndexedDB and read-close, explicit consent, source changes, multiple sessions, credential rotation, revocation and offline logout with mocked push provider/backend.
|
|
||||||
- Existing `pwa.browser.mjs`: production service-worker activation/reload and authentication flows.
|
|
||||||
|
|
||||||
Run browser checks after building, using an existing Playwright/Chromium installation. These fixtures intentionally reject non-loopback targets and mock backend/provider traffic.
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cd web
|
|
||||||
npm run build
|
|
||||||
npm test
|
|
||||||
npm run preview -- --host 127.0.0.1 --port 5197 --strictPort
|
|
||||||
```
|
|
||||||
|
|
||||||
In another shell, with `PLAYWRIGHT_MODULE=/absolute/path/to/playwright-core/index.mjs` and `CHROME_EXECUTABLE=/absolute/path/to/chrome` exported:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cd web
|
|
||||||
node tests/mobile-workspace.browser.mjs
|
|
||||||
node tests/agent-push-errors.browser.mjs
|
|
||||||
node tests/agent-push.browser.mjs
|
|
||||||
node tests/pwa.browser.mjs
|
|
||||||
```
|
|
||||||
|
|
||||||
`MOBILE_ARTIFACT_DIR` saves layout screenshots. `PUSH_ERROR_SCREENSHOT` saves notification settings. `MOBILE_BASELINE=1` records the original layout/gesture failures against a separately served original build.
|
|
||||||
|
|
||||||
## Device verification limits
|
|
||||||
|
|
||||||
No physical GrapheneOS/Vanadium, Android Chrome, or iPhone/Safari device was available. Desktop Chromium touch emulation cannot verify Android system status-bar visibility, native long-press menu rendering/selection handles, actual pinch/keyboard behavior, installed-PWA metadata updates or background FCM delivery. Clipboard tests use the browser engine's keyboard copy/paste and synthetic context-menu dispatch; cancellation/selection assertions are not a claim that Android's system menu was displayed. A shorter viewport approximates keyboard space, without emulating the keyboard itself.
|
|
||||||
|
|
||||||
On the user's device after an authorized deployment: check the installed status bar and fixed-scale viewport, pan both ways through wide tables/quotes/code without navigation, long-press/copy/paste, change model, then explicitly enable notifications and inspect the new error if registration fails. Verify actual background delivery with all Sybil web sessions inactive. The underlying phone-specific notification failure remains unconfirmed until that check.
|
|
||||||
@@ -1,25 +1,38 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import Observation
|
||||||
|
|
||||||
struct SybilAgentView: View {
|
struct SybilAgentView: View {
|
||||||
@Bindable var room: SybilAgentRoom
|
var room: SybilAgentRoom
|
||||||
var topInset: CGFloat = 0
|
var topInset: CGFloat = 0
|
||||||
@Environment(\.scenePhase) private var scenePhase
|
@State private var latestRequest = 0
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
AgentTranscriptView(room: room, topInset: topInset, latestRequest: latestRequest)
|
||||||
|
AgentStatusBar(room: room)
|
||||||
|
AgentComposer(room: room) { latestRequest += 1 }
|
||||||
|
}
|
||||||
|
.onAppear { room.isSelected = true }
|
||||||
|
.onDisappear { room.isSelected = false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draft edits and keyboard focus belong to the composer. The transcript observes
|
||||||
|
// only room/render/scroll state; SwiftUI handles keyboard avoidance.
|
||||||
|
private struct AgentTranscriptView: View {
|
||||||
|
var room: SybilAgentRoom
|
||||||
|
var topInset: CGFloat
|
||||||
|
var latestRequest: Int
|
||||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||||
@FocusState private var composerFocused: Bool
|
|
||||||
@State private var atLatest = true
|
@State private var atLatest = true
|
||||||
@State private var isScrolling = false
|
@State private var scrollPhase: ScrollPhase = .idle
|
||||||
@State private var historyLimit = 100
|
@State private var historyLimit = 100
|
||||||
@State private var historyStartID: String?
|
@State private var historyStartID: String?
|
||||||
@State private var scrollPosition = ScrollPosition()
|
@State private var scrollPosition = ScrollPosition()
|
||||||
@State private var scrollTracking = AgentScrollTracking()
|
@State private var scrollTracking = AgentScrollTracking()
|
||||||
@State private var atBottom = false
|
|
||||||
@State private var restoreAnchor: AgentReadingAnchor?
|
|
||||||
@State private var appeared = false
|
@State private var appeared = false
|
||||||
private let bottomID = "agent-bottom"
|
private var isScrolling: Bool { scrollPhase.isScrolling }
|
||||||
|
|
||||||
private var readKey: String {
|
|
||||||
"\(room.snapshot.room.cursor):\(atLatest):\(atBottom):\(isScrolling):\(room.isObscured):\(room.isModalPresented):\(room.isAttachmentModalPresented):\(scenePhase == .active):\(room.isLoading):\(room.snapshot.revision)"
|
|
||||||
}
|
|
||||||
private var visibleReadingAnchor: AgentReadingAnchor? {
|
private var visibleReadingAnchor: AgentReadingAnchor? {
|
||||||
scrollTracking.rowFrames.filter { $0.value.maxY > topInset && $0.value.minY < scrollTracking.metrics.height }
|
scrollTracking.rowFrames.filter { $0.value.maxY > topInset && $0.value.minY < scrollTracking.metrics.height }
|
||||||
.min { $0.value.minY < $1.value.minY }
|
.min { $0.value.minY < $1.value.minY }
|
||||||
@@ -35,149 +48,168 @@ struct SybilAgentView: View {
|
|||||||
let allMessages = transcript.messages
|
let allMessages = transcript.messages
|
||||||
let startIndex = transcript.startIndex
|
let startIndex = transcript.startIndex
|
||||||
let displayed = transcript.displayed
|
let displayed = transcript.displayed
|
||||||
VStack(spacing: 0) {
|
ScrollView {
|
||||||
ScrollViewReader { proxy in
|
LazyVStack(alignment: .leading, spacing: 15) {
|
||||||
ScrollView {
|
if allMessages.count > displayed.count {
|
||||||
VStack(alignment: .leading, spacing: 15) {
|
Button("Load earlier messages") {
|
||||||
if allMessages.count > displayed.count {
|
// Loading older history may move the viewport as lazy
|
||||||
Button("Load earlier messages") {
|
// row estimates settle; do not chase the old position.
|
||||||
if var anchor = visibleReadingAnchor {
|
scrollTracking.rowFrames = [:]
|
||||||
anchor.expectedCount = min(allMessages.count, displayed.count + 100)
|
atLatest = false
|
||||||
restoreAnchor = anchor
|
historyLimit += 100
|
||||||
}
|
historyStartID = allMessages[max(0, startIndex - 100)].id
|
||||||
atLatest = false
|
|
||||||
historyLimit += 100
|
|
||||||
historyStartID = allMessages[max(0, startIndex - 100)].id
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding(.vertical, 10)
|
|
||||||
}
|
|
||||||
if room.isLoading { ProgressView("Loading messages…").frame(maxWidth: .infinity) }
|
|
||||||
ForEach(Array(displayed.enumerated()), id: \.element.id) { index, message in
|
|
||||||
AgentTranscriptRow(message: message, room: room,
|
|
||||||
startsDay: index == 0 || !Calendar.current.isDate(message.createdAt, inSameDayAs: displayed[index - 1].createdAt))
|
|
||||||
.equatable()
|
|
||||||
.modifier(AgentEntrance(animate: appeared && rendered.entranceIDs.contains(message.id), reduceMotion: reduceMotion))
|
|
||||||
.id(message.id)
|
|
||||||
}
|
|
||||||
if rendered.isTyping {
|
|
||||||
HStack(spacing: 9) {
|
|
||||||
AgentAvatar()
|
|
||||||
ProgressView().controlSize(.small)
|
|
||||||
Text("Sybil is thinking…").font(.sybil(.caption))
|
|
||||||
}
|
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
|
||||||
.accessibilityIdentifier("agent-typing")
|
|
||||||
}
|
|
||||||
Color.clear.frame(height: 1).id(bottomID)
|
|
||||||
}
|
}
|
||||||
.scrollTargetLayout()
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.bottom, 16)
|
|
||||||
// Match normal chat: content clears the header, while the
|
|
||||||
// viewport extends beneath the shared navigation fade.
|
|
||||||
.padding(.top, 16 + topInset)
|
|
||||||
.frame(maxWidth: SybilLayout.webContentMaxWidth)
|
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 10)
|
||||||
}
|
}
|
||||||
.coordinateSpace(name: "agent-scroll")
|
if room.isLoading { ProgressView("Loading messages…").frame(maxWidth: .infinity) }
|
||||||
.scrollPosition($scrollPosition)
|
ForEach(Array(displayed.enumerated()), id: \.element.id) { index, message in
|
||||||
.defaultScrollAnchor(.bottom, for: .initialOffset)
|
AgentTranscriptRow(message: message, room: room, tracksReadingAnchor: !atLatest,
|
||||||
.defaultScrollAnchor(.bottom, for: .alignment)
|
startsDay: index == 0 || !Calendar.current.isDate(message.createdAt, inSameDayAs: displayed[index - 1].createdAt))
|
||||||
.scrollDismissesKeyboard(.interactively)
|
.equatable()
|
||||||
.onScrollPhaseChange { _, phase in
|
.modifier(AgentEntrance(animate: appeared && rendered.entranceIDs.contains(message.id), reduceMotion: reduceMotion))
|
||||||
isScrolling = phase.isScrolling
|
.id(message.id)
|
||||||
if phase == .interacting || phase == .tracking { restoreAnchor = nil }
|
|
||||||
}
|
}
|
||||||
.onScrollGeometryChange(for: AgentScrollMetrics.self) { geometry in
|
if rendered.isTyping {
|
||||||
AgentScrollMetrics(offset: geometry.contentOffset.y,
|
HStack(spacing: 9) {
|
||||||
height: geometry.containerSize.height,
|
AgentAvatar()
|
||||||
contentHeight: geometry.contentSize.height,
|
ProgressView().controlSize(.small)
|
||||||
bottomInset: geometry.contentInsets.bottom, topInset: geometry.contentInsets.top,
|
Text("Sybil is thinking…").font(.sybil(.caption))
|
||||||
visibleBottom: geometry.visibleRect.maxY)
|
|
||||||
} action: { previous, next in
|
|
||||||
#if DEBUG
|
|
||||||
AgentRenderDiagnostics.record("scrollGeometry")
|
|
||||||
#endif
|
|
||||||
scrollTracking.metrics = next
|
|
||||||
if atBottom != next.atBottom { atBottom = next.atBottom }
|
|
||||||
if isScrolling || next.contentHeight <= next.height { atLatest = next.atBottom }
|
|
||||||
if atLatest, !isScrolling, restoreAnchor == nil,
|
|
||||||
previous.height != next.height || previous.contentHeight != next.contentHeight || previous.bottomInset != next.bottomInset {
|
|
||||||
proxy.scrollTo(bottomID, anchor: .bottom)
|
|
||||||
}
|
}
|
||||||
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
.accessibilityIdentifier("agent-typing")
|
||||||
}
|
}
|
||||||
.onPreferenceChange(AgentRowFrames.self) { frames in
|
Color.clear.frame(height: 1)
|
||||||
#if DEBUG
|
|
||||||
AgentRenderDiagnostics.record("rowPreferences")
|
|
||||||
#endif
|
|
||||||
let anchor = restoreAnchor ?? (!atLatest && !isScrolling ? visibleReadingAnchor : nil)
|
|
||||||
if let anchor, let frame = frames[anchor.id], abs(frame.minY - anchor.offset) > 1 {
|
|
||||||
// Restore the exact pixel offset after insertion or text
|
|
||||||
// reflow. Normalize the saved frames to
|
|
||||||
// the requested offset so the next layout cannot undo it.
|
|
||||||
let requested = scrollTracking.metrics.offset + frame.minY - anchor.offset
|
|
||||||
let target = min(max(requested, -scrollTracking.metrics.topInset), scrollTracking.metrics.maximumOffset)
|
|
||||||
let appliedShift = target - scrollTracking.metrics.offset
|
|
||||||
scrollTracking.rowFrames = frames.mapValues { $0.offsetBy(dx: 0, dy: -appliedShift) }
|
|
||||||
if abs(appliedShift) > 1 {
|
|
||||||
// Geometry reports the raw offset; ScrollPosition uses
|
|
||||||
// zero at the top inset, including the safe area.
|
|
||||||
scrollPosition.scrollTo(y: target + scrollTracking.metrics.topInset)
|
|
||||||
}
|
|
||||||
restoreAnchor = nil
|
|
||||||
} else {
|
|
||||||
scrollTracking.rowFrames = frames
|
|
||||||
if let expected = restoreAnchor?.expectedCount, frames.count >= expected { restoreAnchor = nil }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.overlay(alignment: .bottomTrailing) {
|
|
||||||
if !atLatest {
|
|
||||||
Button {
|
|
||||||
atLatest = true
|
|
||||||
withAnimation(reduceMotion ? nil : .easeOut(duration: 0.24)) { proxy.scrollTo(bottomID, anchor: .bottom) }
|
|
||||||
} label: {
|
|
||||||
Label(room.unreadCount > 0 ? "\(room.unreadCount) unread" : "Latest", systemImage: "arrow.down")
|
|
||||||
.font(.sybil(.caption, weight: .semibold))
|
|
||||||
.padding(12)
|
|
||||||
.background(SybilTheme.surfaceStrong, in: Capsule())
|
|
||||||
.overlay(Capsule().stroke(SybilTheme.border))
|
|
||||||
}
|
|
||||||
.padding(14)
|
|
||||||
.accessibilityIdentifier("agent-jump-latest")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onAppear {
|
|
||||||
appeared = true
|
|
||||||
if !room.isLoading { historyStartID = displayed.first?.id }
|
|
||||||
}
|
|
||||||
.onChange(of: room.isLoading) { _, loading in
|
|
||||||
if !loading && historyStartID == nil { historyStartID = displayed.first?.id }
|
|
||||||
}
|
|
||||||
.onChange(of: allMessages.count) { _, _ in
|
|
||||||
if atLatest { proxy.scrollTo(bottomID, anchor: .bottom) }
|
|
||||||
}
|
|
||||||
.onChange(of: composerFocused) { _, _ in
|
|
||||||
if atLatest { proxy.scrollTo(bottomID, anchor: .bottom) }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
statusBar
|
.scrollTargetLayout()
|
||||||
composer
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.bottom, 16)
|
||||||
|
// Match normal chat: content clears the header, while the
|
||||||
|
// viewport extends beneath the shared navigation fade.
|
||||||
|
.padding(.top, 16 + topInset)
|
||||||
|
.frame(maxWidth: SybilLayout.webContentMaxWidth)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
.onAppear { room.isSelected = true }
|
.accessibilityIdentifier("agent-transcript")
|
||||||
.onDisappear { room.isSelected = false }
|
.coordinateSpace(name: "agent-scroll")
|
||||||
|
.scrollPosition($scrollPosition)
|
||||||
|
.defaultScrollAnchor(.bottom, for: .initialOffset)
|
||||||
|
// Let SwiftUI follow size changes, including keyboard avoidance,
|
||||||
|
// without competing scrollTo calls from geometry updates.
|
||||||
|
.defaultScrollAnchor(atLatest ? .bottom : nil, for: .sizeChanges)
|
||||||
|
.defaultScrollAnchor(.bottom, for: .alignment)
|
||||||
|
.scrollDismissesKeyboard(.interactively)
|
||||||
|
.onScrollPhaseChange { _, phase in
|
||||||
|
scrollPhase = phase
|
||||||
|
}
|
||||||
|
.onScrollGeometryChange(for: AgentScrollMetrics.self) { geometry in
|
||||||
|
AgentScrollMetrics(offset: geometry.contentOffset.y,
|
||||||
|
height: geometry.containerSize.height,
|
||||||
|
contentHeight: geometry.contentSize.height,
|
||||||
|
bottomInset: geometry.contentInsets.bottom, topInset: geometry.contentInsets.top,
|
||||||
|
visibleBottom: geometry.visibleRect.maxY)
|
||||||
|
} action: { _, next in
|
||||||
|
#if DEBUG
|
||||||
|
AgentRenderDiagnostics.record("scrollGeometry")
|
||||||
|
#endif
|
||||||
|
scrollTracking.metrics = next
|
||||||
|
if scrollTracking.atBottom != next.atBottom { scrollTracking.atBottom = next.atBottom }
|
||||||
|
// Only user scrolling changes follow intent. Animated Latest
|
||||||
|
// navigation and native keyboard resizing must not cancel it.
|
||||||
|
if (isScrolling && scrollPhase != .animating) || next.contentHeight <= next.height {
|
||||||
|
atLatest = next.atBottom
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onPreferenceChange(AgentRowFrames.self) { frames in
|
||||||
|
#if DEBUG
|
||||||
|
AgentRenderDiagnostics.record("rowPreferences")
|
||||||
|
#endif
|
||||||
|
let anchor = !atLatest && !isScrolling ? visibleReadingAnchor : nil
|
||||||
|
if let anchor, let frame = frames[anchor.id], abs(frame.minY - anchor.offset) > 1 {
|
||||||
|
// Preserve ordinary reading position after new messages or
|
||||||
|
// text reflow. Normalize the saved frames to
|
||||||
|
// the requested offset so the next layout cannot undo it.
|
||||||
|
let requested = scrollTracking.metrics.offset + frame.minY - anchor.offset
|
||||||
|
let target = min(max(requested, -scrollTracking.metrics.topInset), scrollTracking.metrics.maximumOffset)
|
||||||
|
let appliedShift = target - scrollTracking.metrics.offset
|
||||||
|
scrollTracking.rowFrames = frames.mapValues { $0.offsetBy(dx: 0, dy: -appliedShift) }
|
||||||
|
if abs(appliedShift) > 1 {
|
||||||
|
// Geometry reports the raw offset; ScrollPosition uses
|
||||||
|
// zero at the top inset, including the safe area.
|
||||||
|
scrollPosition.scrollTo(y: target + scrollTracking.metrics.topInset)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
scrollTracking.rowFrames = frames
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.overlay(alignment: .bottomTrailing) {
|
||||||
|
if !atLatest {
|
||||||
|
Button {
|
||||||
|
atLatest = true
|
||||||
|
withAnimation(reduceMotion ? nil : .easeOut(duration: 0.24)) { scrollPosition.scrollTo(edge: .bottom) }
|
||||||
|
} label: {
|
||||||
|
Label(room.unreadCount > 0 ? "\(room.unreadCount) unread" : "Latest", systemImage: "arrow.down")
|
||||||
|
.font(.sybil(.caption, weight: .semibold))
|
||||||
|
.padding(12)
|
||||||
|
.background(SybilTheme.surfaceStrong, in: Capsule())
|
||||||
|
.overlay(Capsule().stroke(SybilTheme.border))
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
.accessibilityIdentifier("agent-jump-latest")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
appeared = true
|
||||||
|
if !room.isLoading { historyStartID = displayed.first?.id }
|
||||||
|
}
|
||||||
|
.onChange(of: room.isLoading) { _, loading in
|
||||||
|
if !loading && historyStartID == nil { historyStartID = displayed.first?.id }
|
||||||
|
}
|
||||||
|
.onChange(of: allMessages.count) { _, _ in
|
||||||
|
if atLatest { scrollPosition.scrollTo(edge: .bottom) }
|
||||||
|
}
|
||||||
|
.onChange(of: latestRequest) { _, _ in
|
||||||
|
atLatest = true
|
||||||
|
scrollPosition.scrollTo(edge: .bottom)
|
||||||
|
}
|
||||||
|
.modifier(AgentReadAcknowledgement(room: room, tracking: scrollTracking,
|
||||||
|
atLatest: atLatest, isScrolling: isScrolling))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bottom proximity can change twice per entrance/keyboard frame while the
|
||||||
|
// scroll view follows the growing content. Only the read-settling task needs
|
||||||
|
// those transitions; they must not project and rebuild the transcript hierarchy.
|
||||||
|
private struct AgentReadAcknowledgement: ViewModifier {
|
||||||
|
var room: SybilAgentRoom
|
||||||
|
var tracking: AgentScrollTracking
|
||||||
|
var atLatest: Bool
|
||||||
|
var isScrolling: Bool
|
||||||
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||||
|
|
||||||
|
private var readKey: String {
|
||||||
|
"\(room.snapshot.room.cursor):\(atLatest):\(tracking.atBottom):\(isScrolling):\(room.isObscured):\(room.isModalPresented):\(room.isAttachmentModalPresented):\(scenePhase == .active):\(room.isLoading):\(room.snapshot.revision)"
|
||||||
|
}
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
let cursor = room.snapshot.room.cursor
|
||||||
|
content
|
||||||
.task(id: readKey) {
|
.task(id: readKey) {
|
||||||
// This cursor belongs to the rendered snapshot. New network input cancels
|
// Capture the rendered cursor before scheduling. New network input
|
||||||
// the task, and cannot be acknowledged while still entering the layout.
|
// cancels this task and must settle in layout before acknowledgement.
|
||||||
let cursor = rendered.room.cursor
|
guard atLatest, tracking.metrics.atBottom, !isScrolling, !room.isObscured, !room.isModalPresented, !room.isAttachmentModalPresented, scenePhase == .active, !room.isLoading else { return }
|
||||||
guard atLatest, scrollTracking.metrics.atBottom, !isScrolling, !room.isObscured, !room.isModalPresented, !room.isAttachmentModalPresented, scenePhase == .active, !room.isLoading else { return }
|
|
||||||
try? await Task.sleep(for: .milliseconds(reduceMotion ? 80 : 360))
|
try? await Task.sleep(for: .milliseconds(reduceMotion ? 80 : 360))
|
||||||
guard !Task.isCancelled, atLatest, scrollTracking.metrics.atBottom, !isScrolling, !room.isObscured, !room.isModalPresented, !room.isAttachmentModalPresented, scenePhase == .active else { return }
|
guard !Task.isCancelled, atLatest, tracking.metrics.atBottom, !isScrolling, !room.isObscured, !room.isModalPresented, !room.isAttachmentModalPresented, scenePhase == .active else { return }
|
||||||
room.markRead(renderedCursor: cursor)
|
room.markRead(renderedCursor: cursor)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ViewBuilder private var statusBar: some View {
|
private struct AgentStatusBar: View {
|
||||||
|
var room: SybilAgentRoom
|
||||||
|
|
||||||
|
@ViewBuilder var body: some View {
|
||||||
let activity = room.snapshot.room.activity
|
let activity = room.snapshot.room.activity
|
||||||
if let error = room.error ?? activity.error {
|
if let error = room.error ?? activity.error {
|
||||||
HStack(alignment: .top) {
|
HStack(alignment: .top) {
|
||||||
@@ -199,12 +231,18 @@ struct SybilAgentView: View {
|
|||||||
.padding(.horizontal, 16).padding(.vertical, 6)
|
.padding(.horizontal, 16).padding(.vertical, 6)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct AgentComposer: View {
|
||||||
|
@Bindable var room: SybilAgentRoom
|
||||||
|
var onSubmit: () -> Void
|
||||||
|
@FocusState private var composerFocused: Bool
|
||||||
|
|
||||||
private var canSendComposer: Bool {
|
private var canSendComposer: Bool {
|
||||||
room.attachmentDraft.canSend
|
room.attachmentDraft.canSend
|
||||||
}
|
}
|
||||||
|
|
||||||
private var composer: some View {
|
var body: some View {
|
||||||
AgentAttachmentComposer(room: room) {
|
AgentAttachmentComposer(room: room) {
|
||||||
SybilPromptField(
|
SybilPromptField(
|
||||||
text: $room.composer,
|
text: $room.composer,
|
||||||
@@ -223,7 +261,7 @@ struct SybilAgentView: View {
|
|||||||
|
|
||||||
private func submitComposer() {
|
private func submitComposer() {
|
||||||
guard canSendComposer, room.sendComposer() else { return }
|
guard canSendComposer, room.sendComposer() else { return }
|
||||||
atLatest = true
|
onSubmit()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,16 +274,17 @@ struct AgentAvatar: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Activity/read publications still update the surrounding view. Completed rows
|
// Activity/read publications still update the transcript. Completed rows depend
|
||||||
// depend only on their message values and date heading, so progress changes
|
// only on message values, heading and whether a reading anchor is needed.
|
||||||
// must not rebuild the eager history (whose exact heights preserve anchors).
|
|
||||||
private struct AgentTranscriptRow: View, Equatable {
|
private struct AgentTranscriptRow: View, Equatable {
|
||||||
var message: Message
|
var message: Message
|
||||||
var room: SybilAgentRoom
|
var room: SybilAgentRoom
|
||||||
|
var tracksReadingAnchor: Bool
|
||||||
var startsDay: Bool
|
var startsDay: Bool
|
||||||
|
|
||||||
nonisolated static func == (lhs: Self, rhs: Self) -> Bool {
|
nonisolated static func == (lhs: Self, rhs: Self) -> Bool {
|
||||||
lhs.message == rhs.message && lhs.room === rhs.room && lhs.startsDay == rhs.startsDay
|
lhs.message == rhs.message && lhs.room === rhs.room && lhs.startsDay == rhs.startsDay
|
||||||
|
&& lhs.tracksReadingAnchor == rhs.tracksReadingAnchor
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -261,9 +300,15 @@ private struct AgentTranscriptRow: View, Equatable {
|
|||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
}
|
}
|
||||||
AgentMessageRow(message: message, room: room)
|
AgentMessageRow(message: message, room: room)
|
||||||
.background(GeometryReader { geometry in
|
.background {
|
||||||
Color.clear.preference(key: AgentRowFrames.self, value: [message.id: geometry.frame(in: .named("agent-scroll"))])
|
// Pinned-bottom layout needs only the scroll view's metrics.
|
||||||
})
|
// Rows report frames only while preserving a reading anchor.
|
||||||
|
if tracksReadingAnchor {
|
||||||
|
GeometryReader { geometry in
|
||||||
|
Color.clear.preference(key: AgentRowFrames.self, value: [message.id: geometry.frame(in: .named("agent-scroll"))])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -330,10 +375,11 @@ private struct AgentEntrance: ViewModifier {
|
|||||||
|
|
||||||
// Pixel geometry is imperative anchor bookkeeping, not rendered content. Keeping
|
// Pixel geometry is imperative anchor bookkeeping, not rendered content. Keeping
|
||||||
// it outside observation avoids rebuilding the entire transcript on every scroll
|
// it outside observation avoids rebuilding the entire transcript on every scroll
|
||||||
// frame. Only the at-bottom boundary above invalidates the read acknowledgement.
|
// frame. Only the at-bottom boundary invalidates the read acknowledgement.
|
||||||
private final class AgentScrollTracking {
|
@Observable private final class AgentScrollTracking {
|
||||||
var metrics = AgentScrollMetrics()
|
var atBottom = false
|
||||||
var rowFrames: [String: CGRect] = [:]
|
@ObservationIgnored var metrics = AgentScrollMetrics()
|
||||||
|
@ObservationIgnored var rowFrames: [String: CGRect] = [:]
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct AgentScrollMetrics: Equatable {
|
private struct AgentScrollMetrics: Equatable {
|
||||||
@@ -352,7 +398,6 @@ private struct AgentScrollMetrics: Equatable {
|
|||||||
private struct AgentReadingAnchor {
|
private struct AgentReadingAnchor {
|
||||||
var id: String
|
var id: String
|
||||||
var offset: CGFloat
|
var offset: CGFloat
|
||||||
var expectedCount: Int? = nil
|
|
||||||
}
|
}
|
||||||
private struct AgentRowFrames: PreferenceKey {
|
private struct AgentRowFrames: PreferenceKey {
|
||||||
static let defaultValue: [String: CGRect] = [:]
|
static let defaultValue: [String: CGRect] = [:]
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Agent scrolling benchmark
|
# Agent interaction benchmarks
|
||||||
|
|
||||||
Run the production Agent view in an app-hosted XCTest on the iPhone 16e simulator:
|
Run the production Agent view in an app-hosted XCTest on the iPhone 16e simulator:
|
||||||
|
|
||||||
@@ -7,62 +7,101 @@ just generate
|
|||||||
xcodebuild test -scheme SybilPresentation \
|
xcodebuild test -scheme SybilPresentation \
|
||||||
-destination 'platform=iOS Simulator,name=iPhone 16e,OS=latest' \
|
-destination 'platform=iOS Simulator,name=iPhone 16e,OS=latest' \
|
||||||
-derivedDataPath build/DerivedData -parallel-testing-enabled NO \
|
-derivedDataPath build/DerivedData -parallel-testing-enabled NO \
|
||||||
-only-testing:SybilPresentationTests/AgentPerformanceTests \
|
-only-testing:SybilPresentationTests/AgentPerformanceTests/testInputAndEntranceResponsiveness \
|
||||||
-resultBundlePath build/agent-performance.xcresult
|
-resultBundlePath build/agent-input-performance.xcresult
|
||||||
```
|
```
|
||||||
|
|
||||||
`AgentPerformanceTests` seeds a room through its normal event reducer with 160
|
`testInputAndEntranceResponsiveness` seeds 160 local mixed literal/Markdown
|
||||||
local messages (100 initially displayed). User text is literal; assistant text
|
messages through the normal room reducer (100 initially displayed), then:
|
||||||
contains paragraphs, lists, links, emphasis and code. The real Agent view,
|
|
||||||
composer, avatar, entrance layout and thinking spinner remain enabled. No server,
|
|
||||||
production credentials, notification delivery or network images are needed.
|
|
||||||
|
|
||||||
Three approximately 12-second phases run in order: idle scrolling, thinking at
|
- Receives 12 more messages at 550 ms intervals through the production 100 ms
|
||||||
the bottom, and thinking while scrolling. The test explicitly resets the offset
|
publication batch and 240 ms entrance animation, checking bottom following.
|
||||||
to the bottom before the stationary thinking phase (including in the baseline).
|
- Presents and dismisses the actual software keyboard six times, 600 ms per
|
||||||
Thinking activity changes queue depth, worker count and tool progress every
|
transition. It asserts that the transcript viewport shrinks by over 100 points
|
||||||
500 ms through the normal 100 ms publication
|
and remains at the bottom; a hardware-keyboard-only run is invalid.
|
||||||
batch. The display link sweeps a 2400-point range at 600 points/second, reversing
|
- Calls UIKit `insertText` on the actual focused composer every 30 ms, including
|
||||||
every four seconds. Use the **same test source, simulator, build configuration
|
enough text to wrap and grow to its six-line limit. It verifies the draft,
|
||||||
and host load** for before/after runs. Don't run other builds or profilers during
|
bottom following, and that keyboard/draft changes don't reproject the history.
|
||||||
a measured run. Warm builds and repeat runs help distinguish stable effects from
|
|
||||||
host noise.
|
|
||||||
|
|
||||||
The geometry assertions use iPhone 16e, iOS 26.5, and the default text size. They
|
The real composer, Markdown rows, avatars and entrance animations remain enabled.
|
||||||
retain the unoptimized transcript heights (39181.333 points idle, 39223.333 while
|
No server, production credentials, notification delivery or network images are
|
||||||
thinking), so an isolation wrapper cannot silently change row spacing. Rebaseline
|
needed. The log prints `AGENT_INPUT_PERFORMANCE_JSON`; the result bundle retains
|
||||||
these explicitly when changing simulator/font configuration or the fixture.
|
JSON and screenshots of the actual hosted view before and after typing.
|
||||||
|
Screenshots capture the hosting view, not the separate system keyboard window.
|
||||||
|
|
||||||
The log prints `AGENT_PERFORMANCE_JSON`; the result bundle retains the JSON as an
|
Use the same test source, simulator, build configuration and host load for
|
||||||
attachment. Delivery-gap p50/p95/p99/max and counts over 25/50 ms measure actual
|
before/after measurements. Don't run other builds or profilers during sampling.
|
||||||
main-run-loop responsiveness. `missedBudgetIntervals` estimates gaps relative to
|
Functional assertions about observation boundaries should fail against the old
|
||||||
the reported display-link budget. These are **not compositor FPS, GPU hitches,
|
implementation; use the same sampling procedure when collecting a baseline.
|
||||||
touch latency or physical-device results**. Scrolling is programmatic; native
|
|
||||||
swipe/anchor/read behavior needs the local-fixture UI regression tests as well.
|
|
||||||
The hosted view excludes the surrounding app shell. Physical iPhone scrolling,
|
|
||||||
streaming and thinking animation remain a required follow-up.
|
|
||||||
|
|
||||||
Opt-in `AgentRenderDiagnostics` counters are compiled only in Debug and are off
|
## Measurement limits
|
||||||
outside this test. They explain work (Agent body evaluations, message projection,
|
|
||||||
message text construction, isolated row evaluations, geometry callbacks and
|
|
||||||
snapshot publications). A drop in these counts alone does not establish improved
|
|
||||||
frame responsiveness.
|
|
||||||
|
|
||||||
## Reference result
|
Delivery-gap p50/p95/p99/max and counts over 25/50 ms measure **CADisplayLink
|
||||||
|
main-run-loop callback delivery**, not compositor FPS, GPU hitches, touch-to-key
|
||||||
|
latency or physical-device performance. The hosted view excludes the surrounding
|
||||||
|
app shell. UIKit input exercises editing/layout without synthesizing key taps.
|
||||||
|
The first keyboard presentation includes system warmup; residual system and
|
||||||
|
simulator scheduling gaps remain. Native gestures, read settling, header fade,
|
||||||
|
attachments and reading anchors are covered separately by the loopback UI tests.
|
||||||
|
|
||||||
On monsoon, Xcode 26.5, Debug, iPhone 16e / iOS 26.5, clean repeat runs against
|
`AgentRenderDiagnostics` counters are Debug-only and opt-in. They explain work;
|
||||||
`f534f76` plus counters and against the geometry/row isolation fix gave:
|
a drop in counts alone does not establish improved responsiveness.
|
||||||
|
|
||||||
| Phase | Before p95 / p99 (ms) | After p95 / p99 (ms) | Gaps >50 ms, before / after |
|
The transcript now uses `LazyVStack` with the observation boundaries introduced
|
||||||
|
in `bbaa5e8`. Loading older history may move the viewport; this is the only
|
||||||
|
waived position guarantee. Native regressions still assert reading anchors for
|
||||||
|
incoming messages, bottom following, keyboard behavior, read settling, fade,
|
||||||
|
and attachments. There is no saved prepend anchor or convergence loop.
|
||||||
|
|
||||||
|
Lazy content height estimates change as offscreen rows materialize. The earlier
|
||||||
|
43869→35870 point difference was not shortened content: after-arrival screenshots
|
||||||
|
were byte-identical. Compare visible geometry and native behavior, not estimated
|
||||||
|
whole-history height, when assessing lazy performance.
|
||||||
|
|
||||||
|
## Scrolling harness
|
||||||
|
|
||||||
|
`testThinkingScrollResponsiveness` retains the earlier three approximately
|
||||||
|
12-second phases: idle scroll, stationary thinking, and scrolling with changing
|
||||||
|
queue/worker/tool activity every 500 ms. Its display link sweeps 2400 points at
|
||||||
|
600 points/second. This programmatic sweep does not reproduce native drag phases;
|
||||||
|
use the UI tests for reading-anchor geometry and interactive keyboard dismissal.
|
||||||
|
|
||||||
|
The eager-stack reference in `b9a6f5d` has exact heights of 39181.333
|
||||||
|
points idle and 39223.333 while thinking. That reference harness retains its
|
||||||
|
eager-only total-height assertions; run it at `bbaa5e8` for the eager comparison.
|
||||||
|
Those totals are not valid assertions about lazy estimated content size. Use the
|
||||||
|
input harness and native gesture tests above for the current lazy implementation.
|
||||||
|
That earlier scrolling fix reduced busy-scroll p95 from 88.8 to 19.4 ms; it did
|
||||||
|
not address the input/entrance paths measured here. Physical phone validation
|
||||||
|
remains necessary for hardware performance claims.
|
||||||
|
|
||||||
|
## 2026-10-16 comparison before the prepend waiver
|
||||||
|
|
||||||
|
Single Debug simulator runs on the same iPhone 16e / iOS 26.5, starting at
|
||||||
|
`7510603` (which includes `b9a6f5d`). Values are callback-gap p95 milliseconds:
|
||||||
|
|
||||||
|
| Path | Before | Eager isolation (`bbaa5e8`) | Earlier lazy experiment |
|
||||||
| --- | ---: | ---: | ---: |
|
| --- | ---: | ---: | ---: |
|
||||||
| Idle scroll | 50.01 / 50.02 | 16.68 / 16.71 | 128 / 0 |
|
| 12 message entrances | 40.8 | 33.2 | 16.8 |
|
||||||
| Thinking stationary | 33.33 / 81.92 | 28.91 / 38.35 | 22 / 2 |
|
| Six keyboard cycles | 33.2 | 33.1 | 29.2 |
|
||||||
| Thinking scroll | 88.78 / 90.89 | 19.41 / 30.42 | 123 / 1 |
|
| Multiline composer typing | 24.8 | 25.2 | 21.3 |
|
||||||
|
|
||||||
Each phase lasted approximately 12 seconds. Content heights matched exactly.
|
Eager transcript projections were 12/0/0 versus 220/24/10, with no row-frame
|
||||||
Busy-scroll Agent body/projection evaluations fell from 261 to 27; unchanged
|
preference callbacks in these pinned-bottom phases. Eager content height stayed
|
||||||
text constructions were zero in both versions. Geometry isolation alone removed
|
43869.333 points. Entrance p99 improved 83.4→57.1 ms; keyboard p99 55.9→44.5;
|
||||||
the idle-scroll stalls; individual row isolation reduced activity-related gaps.
|
typing p99 33.2→26.7. Residual gaps remain: entrance/keyboard had 10/3 gaps over
|
||||||
These results support those two boundaries, without a Markdown cache or lazy
|
50 ms; typing had 30 over 25 ms (baseline 19). Keyboard p95 was essentially flat
|
||||||
layout change. Occasional simulator gaps remain; this does not replace device
|
and typing p95 did not improve. These single runs establish removed transcript
|
||||||
validation.
|
work and a safer entrance improvement, not universally smooth input or device FPS.
|
||||||
|
The lazy option was initially rejected because strict prepend anchors failed.
|
||||||
|
The subsequent release accepts that prepend tradeoff; all other guarantees remain.
|
||||||
|
|
||||||
|
## Lazy release after the prepend waiver
|
||||||
|
|
||||||
|
The same input harness passes unchanged: p95 entrance/keyboard/typing gaps are
|
||||||
|
16.9/28.1/20.9 ms, versus the eager run's 33.2/33.1/25.2 ms. Projections stay
|
||||||
|
12/0/0; pinned row-frame preference callbacks stay zero. The after-arrival PNG
|
||||||
|
is byte-identical to the eager baseline. Residual gaps remain (one keyboard gap
|
||||||
|
over 50 ms and one typing gap over 25 ms); these are single simulator samples. Five
|
||||||
|
focused native regressions pass with only the two prepend-offset assertions
|
||||||
|
replaced by checks that the remaining history loaded.
|
||||||
|
|||||||
@@ -72,6 +72,116 @@ final class AgentPerformanceTests: XCTestCase {
|
|||||||
attachment.name = "agent-performance"; attachment.lifetime = .keepAlways; add(attachment)
|
attachment.name = "agent-performance"; attachment.lifetime = .keepAlways; add(attachment)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Exercises UIKit editing/focus and the real message reducer; no synthetic
|
||||||
|
/// view rebuild loop or replacement composer/animation is used.
|
||||||
|
func testInputAndEntranceResponsiveness() async throws {
|
||||||
|
let room = SybilAgentRoom()
|
||||||
|
try room.receive(.messages(AgentPage(conversationId: "performance",
|
||||||
|
messages: (1...160).map(Self.message), cursor: "160", beforeCursor: nil, hasMore: false)), replay: true)
|
||||||
|
room.flush()
|
||||||
|
let host = UIHostingController(rootView: SybilAgentView(room: room, topInset: 64)
|
||||||
|
.background(SybilTheme.background).environment(\.colorScheme, .dark))
|
||||||
|
let scene = try XCTUnwrap(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first)
|
||||||
|
let window = UIWindow(windowScene: scene)
|
||||||
|
window.rootViewController = host
|
||||||
|
window.makeKeyAndVisible()
|
||||||
|
defer {
|
||||||
|
host.view.endEditing(true)
|
||||||
|
AgentRenderDiagnostics.enabled = false
|
||||||
|
window.isHidden = true; window.rootViewController = nil
|
||||||
|
room.stop()
|
||||||
|
}
|
||||||
|
host.view.frame = window.bounds
|
||||||
|
host.view.layoutIfNeeded()
|
||||||
|
try await Task.sleep(for: .seconds(2))
|
||||||
|
let scroll = try XCTUnwrap(findScroll(host.view))
|
||||||
|
let input = try XCTUnwrap(findInput(host.view))
|
||||||
|
AgentRenderDiagnostics.enabled = true
|
||||||
|
var reports: [[String: Any]] = []
|
||||||
|
func report(_ sampler: AgentFrameSampler, phase: String) {
|
||||||
|
var value = sampler.stop()
|
||||||
|
value["phase"] = phase
|
||||||
|
value["procedure"] = "mixed160-input-entrance-v1"
|
||||||
|
value["operations"] = AgentRenderDiagnostics.counts
|
||||||
|
value["contentHeight"] = scroll.contentSize.height
|
||||||
|
value["viewportHeight"] = scroll.bounds.height
|
||||||
|
reports.append(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
AgentRenderDiagnostics.counts = [:]
|
||||||
|
let entrance = AgentFrameSampler(scroll: nil)
|
||||||
|
entrance.start()
|
||||||
|
for index in 161...172 {
|
||||||
|
try room.receive(.messages(AgentPage(conversationId: "performance",
|
||||||
|
messages: [Self.message(index)], cursor: String(index), beforeCursor: nil, hasMore: false)))
|
||||||
|
// Use the production 100 ms publication batch and 240 ms entrance.
|
||||||
|
try await Task.sleep(for: .milliseconds(550))
|
||||||
|
assertAtBottom(scroll)
|
||||||
|
}
|
||||||
|
XCTAssertLessThanOrEqual(AgentRenderDiagnostics.counts["messageProjection", default: 0], 24,
|
||||||
|
"Entrance frames must not repeatedly project the history")
|
||||||
|
report(entrance, phase: "message-entrance")
|
||||||
|
XCTAssertEqual(room.snapshot.room.messages.count, 172)
|
||||||
|
assertAtBottom(scroll)
|
||||||
|
capture(host, "input-entrance-latest")
|
||||||
|
|
||||||
|
let unfocusedHeight = scroll.bounds.height
|
||||||
|
AgentRenderDiagnostics.counts = [:]
|
||||||
|
let keyboard = AgentFrameSampler(scroll: nil)
|
||||||
|
keyboard.start()
|
||||||
|
for _ in 0..<6 {
|
||||||
|
XCTAssertTrue(input.becomeFirstResponder())
|
||||||
|
try await Task.sleep(for: .milliseconds(600))
|
||||||
|
XCTAssertLessThan(scroll.bounds.height, unfocusedHeight - 100, "Software keyboard must actually resize the transcript")
|
||||||
|
assertAtBottom(scroll)
|
||||||
|
XCTAssertTrue(input.resignFirstResponder())
|
||||||
|
try await Task.sleep(for: .milliseconds(600))
|
||||||
|
}
|
||||||
|
XCTAssertEqual(AgentRenderDiagnostics.counts["messageProjection", default: 0], 0,
|
||||||
|
"Keyboard focus and geometry must not project the transcript")
|
||||||
|
report(keyboard, phase: "keyboard-presentation")
|
||||||
|
XCTAssertEqual(scroll.bounds.height, unfocusedHeight, accuracy: 1)
|
||||||
|
XCTAssertTrue(input.becomeFirstResponder())
|
||||||
|
try await Task.sleep(for: .milliseconds(700))
|
||||||
|
let keyInput = try XCTUnwrap(input as? any UIKeyInput)
|
||||||
|
AgentRenderDiagnostics.counts = [:]
|
||||||
|
let typing = AgentFrameSampler(scroll: nil)
|
||||||
|
typing.start()
|
||||||
|
let text = "Typing into the actual composer should keep the long transcript responsive as this sentence wraps across several lines. "
|
||||||
|
for character in text + text {
|
||||||
|
keyInput.insertText(String(character))
|
||||||
|
try await Task.sleep(for: .milliseconds(30))
|
||||||
|
}
|
||||||
|
XCTAssertEqual(AgentRenderDiagnostics.counts["messageProjection", default: 0], 0,
|
||||||
|
"Draft edits and line wrapping must not project the transcript")
|
||||||
|
assertAtBottom(scroll)
|
||||||
|
report(typing, phase: "composer-typing")
|
||||||
|
XCTAssertEqual(room.composer, text + text)
|
||||||
|
capture(host, "input-multiline-keyboard")
|
||||||
|
let data = try JSONSerialization.data(withJSONObject: reports, options: [.sortedKeys])
|
||||||
|
print("AGENT_INPUT_PERFORMANCE_JSON " + String(decoding: data, as: UTF8.self))
|
||||||
|
let attachment = XCTAttachment(data: data, uniformTypeIdentifier: "public.json")
|
||||||
|
attachment.name = "agent-input-performance"; attachment.lifetime = .keepAlways; add(attachment)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func assertAtBottom(_ scroll: UIScrollView, file: StaticString = #filePath, line: UInt = #line) {
|
||||||
|
XCTAssertEqual(scroll.contentOffset.y + scroll.bounds.height - scroll.adjustedContentInset.bottom,
|
||||||
|
scroll.contentSize.height, accuracy: 25, file: file, line: line)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func findInput(_ view: UIView) -> UIView? {
|
||||||
|
if view is UITextView || view is UITextField { return view }
|
||||||
|
return view.subviews.lazy.compactMap { self.findInput($0) }.first
|
||||||
|
}
|
||||||
|
|
||||||
|
private func capture(_ host: UIViewController, _ name: String) {
|
||||||
|
let image = UIGraphicsImageRenderer(bounds: host.view.bounds).image { _ in
|
||||||
|
host.view.drawHierarchy(in: host.view.bounds, afterScreenUpdates: true)
|
||||||
|
}
|
||||||
|
let attachment = XCTAttachment(image: image)
|
||||||
|
attachment.name = name; attachment.lifetime = .keepAlways; add(attachment)
|
||||||
|
}
|
||||||
|
|
||||||
private static func message(_ index: Int) -> Message {
|
private static func message(_ index: Int) -> Message {
|
||||||
let own = index % 3 == 0
|
let own = index % 3 == 0
|
||||||
let content = own
|
let content = own
|
||||||
|
|||||||
@@ -321,15 +321,15 @@ final class AgentUITests: XCTestCase {
|
|||||||
XCTAssertEqual(anchor.frame.minY, before, accuracy: 2)
|
XCTAssertEqual(anchor.frame.minY, before, accuracy: 2)
|
||||||
capture("agent-reading-anchor")
|
capture("agent-reading-anchor")
|
||||||
for _ in 0..<25 {
|
for _ in 0..<25 {
|
||||||
if app.buttons["Load earlier messages"].isHittable { break }
|
if app.buttons["Load earlier messages"].exists && app.buttons["Load earlier messages"].isHittable { break }
|
||||||
scroll.swipeDown(velocity: .fast)
|
scroll.swipeDown(velocity: .fast)
|
||||||
}
|
}
|
||||||
XCTAssertTrue(app.buttons["Load earlier messages"].isHittable)
|
XCTAssertTrue(app.buttons["Load earlier messages"].isHittable)
|
||||||
let first = app.staticTexts["History message 61: local simulator fixture."]
|
|
||||||
let beforeLoad = first.frame.minY
|
|
||||||
app.buttons["Load earlier messages"].tap()
|
app.buttons["Load earlier messages"].tap()
|
||||||
XCTAssertEqual(first.frame.minY, beforeLoad, accuracy: 3)
|
// Loading older lazy history may move the viewport. It must still load
|
||||||
capture("agent-older-history-anchor")
|
// the remaining history and preserve Latest/keyboard behavior below.
|
||||||
|
XCTAssertFalse(app.buttons["Load earlier messages"].exists)
|
||||||
|
capture("agent-older-history-loaded")
|
||||||
app.buttons["agent-jump-latest"].tap()
|
app.buttons["agent-jump-latest"].tap()
|
||||||
XCTAssertTrue(app.staticTexts["Reply while reading earlier history"].isHittable)
|
XCTAssertTrue(app.staticTexts["Reply while reading earlier history"].isHittable)
|
||||||
composer.tap(); composer.typeText("A multiline draft\nwith a second line\nand a third line")
|
composer.tap(); composer.typeText("A multiline draft\nwith a second line\nand a third line")
|
||||||
|
|||||||
@@ -37,8 +37,8 @@ final class TranscriptFadeUITests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var transcript: XCUIElement {
|
private var transcript: XCUIElement {
|
||||||
// The tablet sidebar is also a scroll view; select the detail by its content.
|
// Identify the transcript independently of any particular message row.
|
||||||
app.scrollViews.containing(.staticText, identifier: "History message 160: local simulator fixture.").firstMatch
|
app.scrollViews["agent-transcript"]
|
||||||
}
|
}
|
||||||
|
|
||||||
private func capture(_ name: String) {
|
private func capture(_ name: String) {
|
||||||
@@ -107,23 +107,23 @@ final class TranscriptFadeUITests: XCTestCase {
|
|||||||
capture("agent-fade-reading-position-and-unread")
|
capture("agent-fade-reading-position-and-unread")
|
||||||
|
|
||||||
for _ in 0..<30 {
|
for _ in 0..<30 {
|
||||||
if app.buttons["Load earlier messages"].isHittable { break }
|
if app.buttons["Load earlier messages"].exists && app.buttons["Load earlier messages"].isHittable { break }
|
||||||
scroll.swipeDown(velocity: .fast)
|
scroll.swipeDown(velocity: .fast)
|
||||||
}
|
}
|
||||||
let load = app.buttons["Load earlier messages"]
|
let load = app.buttons["Load earlier messages"]
|
||||||
XCTAssertTrue(load.isHittable)
|
XCTAssertTrue(load.isHittable)
|
||||||
let first = app.staticTexts["History message 61: local simulator fixture."]
|
|
||||||
let beforeLoad = first.frame.minY
|
|
||||||
load.tap()
|
load.tap()
|
||||||
capture("agent-fade-prepend-reading-position")
|
// Prepend position is intentionally not preserved with lazy history.
|
||||||
XCTAssertEqual(first.frame.minY, beforeLoad, accuracy: 3,
|
// New-message reading anchors above and follow/read settling below remain strict.
|
||||||
"Prepending must preserve the readable row's position beneath the header")
|
XCTAssertFalse(load.exists)
|
||||||
|
capture("agent-fade-older-history-loaded")
|
||||||
|
|
||||||
app.buttons["agent-jump-latest"].tap()
|
app.buttons["agent-jump-latest"].tap()
|
||||||
let latest = app.staticTexts["Fade regression reply while reading"]
|
let latest = app.staticTexts["Fade regression reply while reading"]
|
||||||
let followed = XCTNSPredicateExpectation(predicate: NSPredicate(format: "hittable == true"), object: latest)
|
let followed = XCTNSPredicateExpectation(predicate: NSPredicate(format: "hittable == true"), object: latest)
|
||||||
await fulfillment(of: [followed], timeout: 5)
|
await fulfillment(of: [followed], timeout: 5)
|
||||||
composer.tap()
|
composer.tap()
|
||||||
|
XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 5))
|
||||||
XCTAssertTrue(latest.isHittable)
|
XCTAssertTrue(latest.isHittable)
|
||||||
XCTAssertTrue(app.buttons["agent-attach"].isHittable)
|
XCTAssertTrue(app.buttons["agent-attach"].isHittable)
|
||||||
capture("agent-fade-latest-with-keyboard")
|
capture("agent-fade-latest-with-keyboard")
|
||||||
@@ -134,5 +134,27 @@ final class TranscriptFadeUITests: XCTestCase {
|
|||||||
try await Task.sleep(for: .milliseconds(500))
|
try await Task.sleep(for: .milliseconds(500))
|
||||||
let read = try await control(["action": "state"])
|
let read = try await control(["action": "state"])
|
||||||
XCTAssertEqual(read["readCursor"] as? Int, read["cursor"] as? Int)
|
XCTAssertEqual(read["readCursor"] as? Int, read["cursor"] as? Int)
|
||||||
|
|
||||||
|
// A reply still follows and settles as read while the keyboard is up.
|
||||||
|
try await control(["action": "reply", "content": "Reply with keyboard visible"])
|
||||||
|
let keyboardReply = app.staticTexts["Reply with keyboard visible"]
|
||||||
|
let keyboardFollowed = XCTNSPredicateExpectation(predicate: NSPredicate(format: "hittable == true"), object: keyboardReply)
|
||||||
|
await fulfillment(of: [keyboardFollowed], timeout: 5)
|
||||||
|
try await Task.sleep(for: .milliseconds(500))
|
||||||
|
let keyboardRead = try await control(["action": "state"])
|
||||||
|
XCTAssertEqual(keyboardRead["readCursor"] as? Int, keyboardRead["cursor"] as? Int)
|
||||||
|
|
||||||
|
// Interactive dismissal is user navigation; Latest must still return to
|
||||||
|
// the bottom, and presenting the keyboard again must keep it visible.
|
||||||
|
let dismissStart = scroll.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.9))
|
||||||
|
let dismissEnd = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.98))
|
||||||
|
dismissStart.press(forDuration: 0.1, thenDragTo: dismissEnd)
|
||||||
|
let dismissed = XCTNSPredicateExpectation(predicate: NSPredicate(format: "exists == false"), object: app.keyboards.firstMatch)
|
||||||
|
await fulfillment(of: [dismissed], timeout: 5)
|
||||||
|
if app.buttons["agent-jump-latest"].exists { app.buttons["agent-jump-latest"].tap() }
|
||||||
|
XCTAssertTrue(keyboardReply.isHittable)
|
||||||
|
composer.tap()
|
||||||
|
XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 5))
|
||||||
|
XCTAssertTrue(keyboardReply.isHittable)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export const InstallationBody = z.discriminatedUnion('transport', [
|
|||||||
]);
|
]);
|
||||||
export type Registration = z.infer<typeof InstallationBody>;
|
export type Registration = z.infer<typeof InstallationBody>;
|
||||||
export type Subscription = z.infer<typeof WebSubscription>;
|
export type Subscription = z.infer<typeof WebSubscription>;
|
||||||
export const NotificationContext = z.object({ version: z.literal(1), nextChangeAt: z.string().datetime().nullable().optional(), sourceId: z.string().uuid(), conversationId: z.string().min(1).max(128),
|
export const NotificationContext = z.object({ version: z.literal(1), sourceId: z.string().uuid(), conversationId: z.string().min(1).max(128),
|
||||||
cursor: Cursor, hasMore: z.boolean(), latestUserMessageAt: z.string().datetime().nullable(), nativeReadCursor: Cursor,
|
cursor: Cursor, hasMore: z.boolean(), latestUserMessageAt: z.string().datetime().nullable(), nativeReadCursor: Cursor,
|
||||||
uncertain: z.boolean(), webPresence: z.object({ active: z.boolean(), unknown: z.boolean(), observedAt: z.string().datetime() }),
|
uncertain: z.boolean(), webPresence: z.object({ active: z.boolean(), unknown: z.boolean(), observedAt: z.string().datetime() }),
|
||||||
candidates: z.array(z.object({ messageId: z.string().min(1).max(200), cursor: Cursor,
|
candidates: z.array(z.object({ messageId: z.string().min(1).max(200), cursor: Cursor,
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
import WebSocket from 'ws';
|
|
||||||
|
|
||||||
/** Events are hints; every connection starts durable cursor reconciliation. */
|
|
||||||
export function subscribeNotificationEvents(upstream: { baseUrl: string; apiKey?: string }, changed: () => void,
|
|
||||||
options: { retryMs?: number; heartbeatMs?: number } = {}) {
|
|
||||||
let stopped = false, attempts = 0, socket: WebSocket | undefined;
|
|
||||||
let retry: ReturnType<typeof setTimeout> | undefined, heartbeat: ReturnType<typeof setTimeout> | undefined;
|
|
||||||
const connect = () => {
|
|
||||||
if (stopped || !upstream.apiKey) return;
|
|
||||||
const url = new URL('/api/notification-events', upstream.baseUrl);
|
|
||||||
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
||||||
socket = new WebSocket(url, { headers: { Authorization: `Bearer ${upstream.apiKey}`, 'X-Sybil-Client': 'backend-observer-v1' },
|
|
||||||
handshakeTimeout: 10_000, maxPayload: 1024, followRedirects: false });
|
|
||||||
const current = socket;
|
|
||||||
const alive = () => { clearTimeout(heartbeat); heartbeat = setTimeout(() => current.terminate(), options.heartbeatMs ?? 60_000); heartbeat.unref(); };
|
|
||||||
current.on('open', alive);
|
|
||||||
current.on('ping', alive);
|
|
||||||
current.on('message', raw => {
|
|
||||||
try {
|
|
||||||
const event = JSON.parse(raw.toString());
|
|
||||||
if (event.type !== 'notification_changed' || event.version !== 1) { current.close(1002); return; }
|
|
||||||
attempts = 0; alive(); changed();
|
|
||||||
} catch { current.close(1002); }
|
|
||||||
});
|
|
||||||
current.on('error', () => { /* Reconnect without logging headers, URLs, or response bodies. */ });
|
|
||||||
current.on('close', () => {
|
|
||||||
clearTimeout(heartbeat);
|
|
||||||
if (stopped) return;
|
|
||||||
retry = setTimeout(connect, Math.min(30_000, (options.retryMs ?? 1_000) * 2 ** Math.min(attempts++, 5)));
|
|
||||||
retry.unref();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
connect();
|
|
||||||
return () => { stopped = true; clearTimeout(retry); clearTimeout(heartbeat); socket?.terminate(); };
|
|
||||||
}
|
|
||||||
+30
-97
@@ -9,15 +9,14 @@ import { Cursor, InstallationBody, InstallationId, MAX_AGE_MS, MAX_ATTEMPTS, Not
|
|||||||
digest, fail, ownerEpoch, parseSafe, validateSubscription, type Context, type Identity, type Presence, type Registration } from './agent-push-contract.js';
|
digest, fail, ownerEpoch, parseSafe, validateSubscription, type Context, type Identity, type Presence, type Registration } from './agent-push-contract.js';
|
||||||
import { createPushSender, type PushSend } from './agent-push-transports.js';
|
import { createPushSender, type PushSend } from './agent-push-transports.js';
|
||||||
import { createMessageText, type MessageText } from './agent-push-preview.js';
|
import { createMessageText, type MessageText } from './agent-push-preview.js';
|
||||||
import { subscribeNotificationEvents } from './agent-push-events.js';
|
|
||||||
|
|
||||||
type Tx = Prisma.TransactionClient;
|
type Tx = Prisma.TransactionClient;
|
||||||
export type ReadState = { cursor: string; updatedAt: string | null; sourceId: string | null };
|
export type ReadState = { cursor: string; updatedAt: string | null; sourceId: string | null };
|
||||||
type Options = { db: PrismaClient; config: PushConfig; context: (after?: string, sourceId?: string) => Promise<Context>; messageText: MessageText; send: PushSend; now?: () => number;
|
type Options = { db: PrismaClient; config: PushConfig; context: (after?: string, sourceId?: string) => Promise<Context>; messageText: MessageText; send: PushSend; now?: () => number;
|
||||||
broadcast?: (state: ReadState) => void; warmupMs?: number; subscribe?: (changed: () => void) => (() => void) };
|
broadcast?: (state: ReadState) => void; warmupMs?: number };
|
||||||
const date = (ms: number) => new Date(ms);
|
const date = (ms: number) => new Date(ms);
|
||||||
const activeStatuses = ['pending', 'intent'];
|
const activeStatuses = ['pending', 'intent'];
|
||||||
type Suppression = { until?: number; reason: string; clientKind: 'web' | 'unknown' | 'runtime_web' | 'runtime_unknown' | 'system' };
|
type Suppression = { reason: string; clientKind: 'web' | 'unknown' | 'runtime_web' | 'runtime_unknown' | 'system' };
|
||||||
|
|
||||||
/** Sole coordinator: persisted source checkpoint, installations, presence, claims and outcomes. */
|
/** Sole coordinator: persisted source checkpoint, installations, presence, claims and outcomes. */
|
||||||
export class AgentPushCoordinator {
|
export class AgentPushCoordinator {
|
||||||
@@ -28,15 +27,7 @@ export class AgentPushCoordinator {
|
|||||||
private started = false;
|
private started = false;
|
||||||
private untrackedConnections = new Map<string, { at: number; kind: 'web' | 'unknown' }>();
|
private untrackedConnections = new Map<string, { at: number; kind: 'web' | 'unknown' }>();
|
||||||
private running = false;
|
private running = false;
|
||||||
private timer?: ReturnType<typeof setTimeout>;
|
private timer?: ReturnType<typeof setInterval>;
|
||||||
private timerAt = Infinity;
|
|
||||||
private enabled = false;
|
|
||||||
private dirty = false;
|
|
||||||
private reconsider = true;
|
|
||||||
private unsubscribe?: () => void;
|
|
||||||
private nextMaintenance = 0;
|
|
||||||
private nextRuntimeChange?: number;
|
|
||||||
private failures = 0;
|
|
||||||
private stopped = false;
|
private stopped = false;
|
||||||
private observedRoot?: { sourceId: string; conversationId: string };
|
private observedRoot?: { sourceId: string; conversationId: string };
|
||||||
constructor(readonly options: Options) {
|
constructor(readonly options: Options) {
|
||||||
@@ -60,37 +51,11 @@ export class AgentPushCoordinator {
|
|||||||
this.started = true;
|
this.started = true;
|
||||||
}
|
}
|
||||||
start() {
|
start() {
|
||||||
if (this.enabled || !this.options.config.adminToken) return;
|
if (this.timer || !this.options.config.adminToken) return;
|
||||||
this.enabled = true;
|
this.timer = setInterval(() => { void this.tick(); }, 2_000); this.timer.unref();
|
||||||
this.unsubscribe = this.options.subscribe?.(() => this.wake());
|
void this.tick();
|
||||||
this.wake();
|
|
||||||
}
|
|
||||||
/** Coalesce events without dropping one that arrives during reconciliation. */
|
|
||||||
wake() {
|
|
||||||
this.reconsider = true;
|
|
||||||
if (!this.enabled || this.stopped) return;
|
|
||||||
this.dirty = true;
|
|
||||||
if (!this.running) this.schedule(this.now() + 100);
|
|
||||||
}
|
|
||||||
private schedule(at: number) {
|
|
||||||
if (!this.enabled || this.stopped || at >= this.timerAt) return;
|
|
||||||
clearTimeout(this.timer);
|
|
||||||
this.timerAt = at;
|
|
||||||
this.timer = setTimeout(() => { this.timer = undefined; this.timerAt = Infinity; void this.tick(); }, Math.max(0, at - this.now()));
|
|
||||||
this.timer.unref();
|
|
||||||
}
|
|
||||||
private async schedulePending() {
|
|
||||||
const [pending, intent] = await Promise.all([
|
|
||||||
this.options.db.agentPushDelivery.findFirst({ where: { status: 'pending' }, orderBy: { dueAt: 'asc' }, select: { dueAt: true } }),
|
|
||||||
this.options.db.agentPushDelivery.findFirst({ where: { status: 'intent' }, orderBy: { updatedAt: 'asc' }, select: { updatedAt: true } }),
|
|
||||||
]);
|
|
||||||
this.schedule(Math.max(this.now() + 100, Math.min(this.nextMaintenance,
|
|
||||||
pending?.dueAt.getTime() ?? Infinity, intent ? intent.updatedAt.getTime() + 30_001 : Infinity)));
|
|
||||||
}
|
|
||||||
async stop() {
|
|
||||||
this.stopped = true; this.unsubscribe?.(); clearTimeout(this.timer);
|
|
||||||
while (this.running) await new Promise(r => setTimeout(r, 20));
|
|
||||||
}
|
}
|
||||||
|
async stop() { this.stopped = true; if (this.timer) clearInterval(this.timer); while (this.running) await new Promise(r => setTimeout(r, 20)); }
|
||||||
async config() {
|
async config() {
|
||||||
await this.initialize();
|
await this.initialize();
|
||||||
const state = await this.options.db.agentPushCoordinator.findUnique({ where: { id: 'root' } });
|
const state = await this.options.db.agentPushCoordinator.findUnique({ where: { id: 'root' } });
|
||||||
@@ -108,7 +73,7 @@ export class AgentPushCoordinator {
|
|||||||
value = { ...value, token: value.token.toLowerCase() };
|
value = { ...value, token: value.token.toLowerCase() };
|
||||||
} else { if (!config.web) throw fail(503, 'Web Push is unavailable'); validateSubscription(value.subscription, this.now()); }
|
} else { if (!config.web) throw fail(503, 'Web Push is unavailable'); validateSubscription(value.subscription, this.now()); }
|
||||||
const payload = JSON.stringify(value), fingerprint = digest(value.transport === 'apns' ? `${value.environment}:${value.topic}:${value.token}` : value.subscription.endpoint);
|
const payload = JSON.stringify(value), fingerprint = digest(value.transport === 'apns' ? `${value.environment}:${value.topic}:${value.token}` : value.subscription.endpoint);
|
||||||
const result = await this.options.db.$transaction(async tx => {
|
return this.options.db.$transaction(async tx => {
|
||||||
const old = await tx.agentPushInstallation.findUnique({ where: { id } });
|
const old = await tx.agentPushInstallation.findUnique({ where: { id } });
|
||||||
if (old?.revokedAt) throw fail(409, 'Installation was retired; explicit opt-in requires a new installation ID');
|
if (old?.revokedAt) throw fail(409, 'Installation was retired; explicit opt-in requires a new installation ID');
|
||||||
if (old?.enabled && old.ownerEpoch === this.epoch && old.payload === payload) return { installationId: id, transport: old.transport, enabled: true, version: old.version };
|
if (old?.enabled && old.ownerEpoch === this.epoch && old.payload === payload) return { installationId: id, transport: old.transport, enabled: true, version: old.version };
|
||||||
@@ -124,8 +89,6 @@ export class AgentPushCoordinator {
|
|||||||
update: { ownerEpoch: this.epoch, transport: value.transport, payload, fingerprint, enabled: true, version: { increment: 1 } } });
|
update: { ownerEpoch: this.epoch, transport: value.transport, payload, fingerprint, enabled: true, version: { increment: 1 } } });
|
||||||
return { installationId: id, transport: record.transport, enabled: record.enabled, version: record.version };
|
return { installationId: id, transport: record.transport, enabled: record.enabled, version: record.version };
|
||||||
});
|
});
|
||||||
this.wake();
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
async revoke(id: string) {
|
async revoke(id: string) {
|
||||||
await this.options.db.$transaction(async tx => {
|
await this.options.db.$transaction(async tx => {
|
||||||
@@ -135,11 +98,10 @@ export class AgentPushCoordinator {
|
|||||||
update: { enabled: false, revokedAt: date(this.now()), version: { increment: 1 }, payload: '', fingerprint: '' } });
|
update: { enabled: false, revokedAt: date(this.now()), version: { increment: 1 }, payload: '', fingerprint: '' } });
|
||||||
await tx.agentPushDelivery.updateMany({ where: { installationId: id, status: { in: activeStatuses } }, data: { status: 'cancelled', outcome: 'revoked' } });
|
await tx.agentPushDelivery.updateMany({ where: { installationId: id, status: { in: activeStatuses } }, data: { status: 'cancelled', outcome: 'revoked' } });
|
||||||
});
|
});
|
||||||
this.wake();
|
|
||||||
}
|
}
|
||||||
async presence(id: string, value: Presence) {
|
async presence(id: string, value: Presence) {
|
||||||
const now = this.now();
|
const now = this.now();
|
||||||
const result = await this.options.db.$transaction(async tx => {
|
return this.options.db.$transaction(async tx => {
|
||||||
const old = await tx.agentPushPresence.findUnique({ where: { id } });
|
const old = await tx.agentPushPresence.findUnique({ where: { id } });
|
||||||
if (old?.ownerEpoch === this.epoch && value.sequence <= old.sequence) return { expiresAt: old.expiresAt.toISOString() };
|
if (old?.ownerEpoch === this.epoch && value.sequence <= old.sequence) return { expiresAt: old.expiresAt.toISOString() };
|
||||||
if (!old && await tx.agentPushPresence.count({ where: { ownerEpoch: this.epoch, expiresAt: { gt: date(now) } } }) >= 512) throw fail(429, 'Too many presence sessions');
|
if (!old && await tx.agentPushPresence.count({ where: { ownerEpoch: this.epoch, expiresAt: { gt: date(now) } } }) >= 512) throw fail(429, 'Too many presence sessions');
|
||||||
@@ -153,8 +115,6 @@ export class AgentPushCoordinator {
|
|||||||
const row = await tx.agentPushPresence.upsert({ where: { id }, create: { id, ...data }, update: data });
|
const row = await tx.agentPushPresence.upsert({ where: { id }, create: { id, ...data }, update: data });
|
||||||
return { expiresAt: row.expiresAt.toISOString() };
|
return { expiresAt: row.expiresAt.toISOString() };
|
||||||
});
|
});
|
||||||
this.wake();
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
async retirePresence(id: string) {
|
async retirePresence(id: string) {
|
||||||
await this.options.db.$transaction(async tx => {
|
await this.options.db.$transaction(async tx => {
|
||||||
@@ -163,7 +123,6 @@ export class AgentPushCoordinator {
|
|||||||
await tx.agentPushPresence.update({ where: { id }, data: { visible: false, focused: false,
|
await tx.agentPushPresence.update({ where: { id }, data: { visible: false, focused: false,
|
||||||
inactiveUntil: old.visible && old.focused && old.expiresAt.getTime() > this.now() ? date(this.now() + PUSH_POLICY.inactiveGraceMs) : old.inactiveUntil } });
|
inactiveUntil: old.visible && old.focused && old.expiresAt.getTime() > this.now() ? date(this.now() + PUSH_POLICY.inactiveGraceMs) : old.inactiveUntil } });
|
||||||
});
|
});
|
||||||
this.wake();
|
|
||||||
}
|
}
|
||||||
async connect(id: string, kind?: string, sessionId?: string) {
|
async connect(id: string, kind?: string, sessionId?: string) {
|
||||||
const now = this.now();
|
const now = this.now();
|
||||||
@@ -181,19 +140,17 @@ export class AgentPushCoordinator {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
this.untrackedConnections.delete(id);
|
this.untrackedConnections.delete(id);
|
||||||
this.wake();
|
|
||||||
}
|
}
|
||||||
async heartbeat(id: string) {
|
async heartbeat(id: string) {
|
||||||
// Transport liveness never creates a client, changes its attribution or proves attention.
|
// Transport liveness never creates a client, changes its attribution or proves attention.
|
||||||
await this.options.db.agentPushConnection.updateMany({ where: { id, ownerEpoch: this.epoch }, data: { expiresAt: date(this.now() + PUSH_POLICY.presenceLeaseMs) } });
|
await this.options.db.agentPushConnection.updateMany({ where: { id, ownerEpoch: this.epoch }, data: { expiresAt: date(this.now() + PUSH_POLICY.presenceLeaseMs) } });
|
||||||
}
|
}
|
||||||
async disconnect(id: string) { this.untrackedConnections.delete(id); await this.options.db.agentPushConnection.deleteMany({ where: { id, ownerEpoch: this.epoch } }); this.wake(); }
|
async disconnect(id: string) { this.untrackedConnections.delete(id); await this.options.db.agentPushConnection.deleteMany({ where: { id, ownerEpoch: this.epoch } }); }
|
||||||
async notePrompt() {
|
async notePrompt() {
|
||||||
if (!this.options.config.adminToken) return;
|
if (!this.options.config.adminToken) return;
|
||||||
await this.initialize();
|
await this.initialize();
|
||||||
// Covers submissions arriving after the last runtime snapshot but before the send claim.
|
// Covers submissions arriving after the last runtime snapshot but before the send claim.
|
||||||
await this.options.db.agentPushCoordinator.updateMany({ where: { id: 'root', ownerEpoch: this.epoch }, data: { localPromptAt: date(this.now()) } });
|
await this.options.db.agentPushCoordinator.updateMany({ where: { id: 'root', ownerEpoch: this.epoch }, data: { localPromptAt: date(this.now()) } });
|
||||||
this.wake();
|
|
||||||
}
|
}
|
||||||
async readState(tx: Tx | PrismaClient = this.options.db): Promise<ReadState> {
|
async readState(tx: Tx | PrismaClient = this.options.db): Promise<ReadState> {
|
||||||
const row = await tx.agentReadState.findUnique({ where: { id: 'root' } });
|
const row = await tx.agentReadState.findUnique({ where: { id: 'root' } });
|
||||||
@@ -236,7 +193,7 @@ export class AgentPushCoordinator {
|
|||||||
const changed = await this.mergeRead(tx, cursor, coordinator?.sourceId || null);
|
const changed = await this.mergeRead(tx, cursor, coordinator?.sourceId || null);
|
||||||
return { state: changed || await this.readState(tx), changed: Boolean(changed) };
|
return { state: changed || await this.readState(tx), changed: Boolean(changed) };
|
||||||
});
|
});
|
||||||
if (result.changed) { this.options.broadcast?.(result.state); this.wake(); }
|
if (result.changed) this.options.broadcast?.(result.state);
|
||||||
return result.state;
|
return result.state;
|
||||||
}
|
}
|
||||||
private async acquire(): Promise<boolean> {
|
private async acquire(): Promise<boolean> {
|
||||||
@@ -306,26 +263,22 @@ export class AgentPushCoordinator {
|
|||||||
return changedRead;
|
return changedRead;
|
||||||
});
|
});
|
||||||
if (read) this.options.broadcast?.(read);
|
if (read) this.options.broadcast?.(read);
|
||||||
this.nextRuntimeChange = context.nextChangeAt ? Date.parse(context.nextChangeAt) : undefined;
|
|
||||||
this.observedRoot = { sourceId: context.sourceId, conversationId: context.conversationId };
|
this.observedRoot = { sourceId: context.sourceId, conversationId: context.conversationId };
|
||||||
return context.hasMore;
|
return context.hasMore;
|
||||||
}
|
}
|
||||||
private async browserSuppression(tx: Tx, now: number): Promise<Suppression | undefined> {
|
private async browserSuppression(tx: Tx, now: number): Promise<Suppression | undefined> {
|
||||||
const observations = await tx.agentPushPresence.findMany({ where: { ownerEpoch: this.epoch } });
|
const observations = await tx.agentPushPresence.findMany({ where: { ownerEpoch: this.epoch } });
|
||||||
const presences = observations.filter(v => v.expiresAt.getTime() > now);
|
const presences = observations.filter(v => v.expiresAt.getTime() > now);
|
||||||
const focused = presences.filter(v => v.visible && v.focused);
|
if (presences.some(v => v.visible && v.focused)) return { reason: 'foreground_focused', clientKind: 'web' };
|
||||||
if (focused.length) return { reason: 'foreground_focused', clientKind: 'web', until: Math.min(...focused.map(v => v.expiresAt.getTime())) };
|
if (presences.some(v => v.lastInteractionAt && v.lastInteractionAt.getTime() + PUSH_POLICY.interactionGraceMs > now)) return { reason: 'recent_interaction', clientKind: 'web' };
|
||||||
const interactions = presences.map(v => v.lastInteractionAt ? Math.min(v.expiresAt.getTime(), v.lastInteractionAt.getTime() + PUSH_POLICY.interactionGraceMs) : 0).filter(at => at > now);
|
if (presences.some(v => v.inactiveUntil && v.inactiveUntil.getTime() > now)) return { reason: 'inactive_grace', clientKind: 'web' };
|
||||||
if (interactions.length) return { reason: 'recent_interaction', clientKind: 'web', until: Math.min(...interactions) };
|
|
||||||
const inactive = presences.map(v => Math.min(v.expiresAt.getTime(), v.inactiveUntil?.getTime() || 0)).filter(at => at > now);
|
|
||||||
if (inactive.length) return { reason: 'inactive_grace', clientKind: 'web', until: Math.min(...inactive) };
|
|
||||||
const connections = await tx.agentPushConnection.findMany({ where: { ownerEpoch: this.epoch, expiresAt: { gt: date(now) } } });
|
const connections = await tx.agentPushConnection.findMany({ where: { ownerEpoch: this.epoch, expiresAt: { gt: date(now) } } });
|
||||||
const unobserved = connections.filter(v => v.kind !== 'native' && (v.kind !== 'web' || !observations.some(p => p.id === v.sessionId)));
|
const unobserved = connections.filter(v => v.kind !== 'native' && (v.kind !== 'web' || !observations.some(p => p.id === v.sessionId)));
|
||||||
if (!unobserved.length && !this.untrackedConnections.size) return;
|
if (!unobserved.length && !this.untrackedConnections.size) return;
|
||||||
const firstUntracked = [...this.untrackedConnections.values()].sort((a, b) => a.at - b.at)[0];
|
const firstUntracked = [...this.untrackedConnections.values()].sort((a, b) => a.at - b.at)[0];
|
||||||
await tx.agentPushCoordinator.updateMany({ where: { id: 'root', ownerEpoch: this.epoch, unknownSince: null }, data: { unknownSince: date(firstUntracked?.at ?? now) } });
|
await tx.agentPushCoordinator.updateMany({ where: { id: 'root', ownerEpoch: this.epoch, unknownSince: null }, data: { unknownSince: date(firstUntracked?.at ?? now) } });
|
||||||
const state = await tx.agentPushCoordinator.findUniqueOrThrow({ where: { id: 'root' } });
|
const state = await tx.agentPushCoordinator.findUniqueOrThrow({ where: { id: 'root' } });
|
||||||
if (state.unknownSince!.getTime() + PUSH_POLICY.unknownGraceMs > now) return { reason: 'unknown_client_grace', until: state.unknownSince!.getTime() + PUSH_POLICY.unknownGraceMs,
|
if (state.unknownSince!.getTime() + PUSH_POLICY.unknownGraceMs > now) return { reason: 'unknown_client_grace',
|
||||||
clientKind: unobserved[0]?.kind === 'web' ? 'web' : firstUntracked?.kind || 'unknown' };
|
clientKind: unobserved[0]?.kind === 'web' ? 'web' : firstUntracked?.kind || 'unknown' };
|
||||||
}
|
}
|
||||||
private async claim(id: string, commit: boolean) {
|
private async claim(id: string, commit: boolean) {
|
||||||
@@ -334,8 +287,8 @@ export class AgentPushCoordinator {
|
|||||||
const state = await tx.agentPushCoordinator.findUniqueOrThrow({ where: { id: 'root' } });
|
const state = await tx.agentPushCoordinator.findUniqueOrThrow({ where: { id: 'root' } });
|
||||||
const row = await tx.agentPushDelivery.findUnique({ where: { id } });
|
const row = await tx.agentPushDelivery.findUnique({ where: { id } });
|
||||||
if (!row || row.status !== 'pending' || row.dueAt.getTime() > now) return;
|
if (!row || row.status !== 'pending' || row.dueAt.getTime() > now) return;
|
||||||
const suppress = async ({ reason, clientKind, until }: Suppression) => {
|
const suppress = async ({ reason, clientKind }: Suppression) => {
|
||||||
await tx.agentPushDelivery.update({ where: { id }, data: { dueAt: date(Math.min(row.expiresAt.getTime(), Math.max(now + 100, until ?? now + 30_000))),
|
await tx.agentPushDelivery.update({ where: { id }, data: { dueAt: date(now + PUSH_POLICY.debounceMs),
|
||||||
lastSuppressionReason: reason, lastSuppressionClientKind: clientKind, lastSuppressedAt: date(now) } });
|
lastSuppressionReason: reason, lastSuppressionClientKind: clientKind, lastSuppressedAt: date(now) } });
|
||||||
};
|
};
|
||||||
const installation = await tx.agentPushInstallation.findUnique({ where: { id: row.installationId } });
|
const installation = await tx.agentPushInstallation.findUnique({ where: { id: row.installationId } });
|
||||||
@@ -346,15 +299,15 @@ export class AgentPushCoordinator {
|
|||||||
}
|
}
|
||||||
if (!this.options.config.enabled || state.lockOwner !== this.instanceId || !state.lockUntil || state.lockUntil.getTime() <= now
|
if (!this.options.config.enabled || state.lockOwner !== this.instanceId || !state.lockUntil || state.lockUntil.getTime() <= now
|
||||||
|| state.uncertain || !state.contextObservedAt || state.contextObservedAt.getTime() < now - 10_000 || now < this.warmUntil) {
|
|| state.uncertain || !state.contextObservedAt || state.contextObservedAt.getTime() < now - 10_000 || now < this.warmUntil) {
|
||||||
await suppress({ reason: now < this.warmUntil ? 'startup_warmup' : 'source_or_coordinator_unavailable', clientKind: 'system', until: now < this.warmUntil ? this.warmUntil : this.nextRuntimeChange }); return;
|
await suppress({ reason: now < this.warmUntil ? 'startup_warmup' : 'source_or_coordinator_unavailable', clientKind: 'system' }); return;
|
||||||
}
|
}
|
||||||
if (row.kind === 'message') {
|
if (row.kind === 'message') {
|
||||||
const attention: Suppression | undefined = state.nativeActive ? { reason: 'runtime_active', clientKind: 'runtime_web', until: this.nextRuntimeChange }
|
const attention: Suppression | undefined = state.nativeActive ? { reason: 'runtime_active', clientKind: 'runtime_web' }
|
||||||
: state.nativeUnknown ? { reason: 'runtime_unknown', clientKind: 'runtime_unknown', until: this.nextRuntimeChange } : await this.browserSuppression(tx, now);
|
: state.nativeUnknown ? { reason: 'runtime_unknown', clientKind: 'runtime_unknown' } : await this.browserSuppression(tx, now);
|
||||||
if (attention) { await suppress(attention); return; }
|
if (attention) { await suppress(attention); return; }
|
||||||
if ((state.latestUserMessageAt && state.latestUserMessageAt.getTime() + PUSH_POLICY.promptGraceMs > now)
|
if ((state.latestUserMessageAt && state.latestUserMessageAt.getTime() + PUSH_POLICY.promptGraceMs > now)
|
||||||
|| (state.localPromptAt && state.localPromptAt.getTime() + PUSH_POLICY.promptGraceMs > now)) {
|
|| (state.localPromptAt && state.localPromptAt.getTime() + PUSH_POLICY.promptGraceMs > now)) {
|
||||||
await suppress({ reason: 'prompt_grace', clientKind: 'system', until: Math.max(state.latestUserMessageAt?.getTime() || 0, state.localPromptAt?.getTime() || 0) + PUSH_POLICY.promptGraceMs }); return;
|
await suppress({ reason: 'prompt_grace', clientKind: 'system' }); return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (row.attempts >= MAX_ATTEMPTS) { await tx.agentPushDelivery.update({ where: { id }, data: { status: 'failed', outcome: 'attempt_limit' } }); return; }
|
if (row.attempts >= MAX_ATTEMPTS) { await tx.agentPushDelivery.update({ where: { id }, data: { status: 'failed', outcome: 'attempt_limit' } }); return; }
|
||||||
@@ -392,45 +345,32 @@ export class AgentPushCoordinator {
|
|||||||
await this.options.db.$transaction(async tx => {
|
await this.options.db.$transaction(async tx => {
|
||||||
const pending = result.outcome === 'retry' && claim.row.attempts + 1 < MAX_ATTEMPTS;
|
const pending = result.outcome === 'retry' && claim.row.attempts + 1 < MAX_ATTEMPTS;
|
||||||
await tx.agentPushDelivery.updateMany({ where: { id, status: 'intent' }, data: { status: pending ? 'pending' : result.outcome === 'retry' ? 'failed' : result.outcome,
|
await tx.agentPushDelivery.updateMany({ where: { id, status: 'intent' }, data: { status: pending ? 'pending' : result.outcome === 'retry' ? 'failed' : result.outcome,
|
||||||
outcome: result.outcome, ...(pending ? { lastSuppressionReason: null, dueAt: date(this.now() + (result.retryAfterMs ?? Math.min(60_000, 2_000 * 2 ** claim.row.attempts))) } : {}) } });
|
outcome: result.outcome, ...(pending ? { dueAt: date(this.now() + (result.retryAfterMs ?? Math.min(60_000, 2_000 * 2 ** claim.row.attempts))) } : {}) } });
|
||||||
if (result.outcome === 'invalid') await tx.agentPushInstallation.updateMany({ where: { id: claim.row.installationId, version: claim.row.installationVersion, ownerEpoch: this.epoch }, data: { enabled: false } });
|
if (result.outcome === 'invalid') await tx.agentPushInstallation.updateMany({ where: { id: claim.row.installationId, version: claim.row.installationVersion, ownerEpoch: this.epoch }, data: { enabled: false } });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
async tick() {
|
async tick() {
|
||||||
if (this.running || this.stopped || !this.options.config.adminToken) return;
|
if (this.running || this.stopped || !this.options.config.adminToken) return;
|
||||||
this.running = true;
|
this.running = true;
|
||||||
this.dirty = false;
|
|
||||||
const reconsider = this.reconsider; this.reconsider = false;
|
|
||||||
let retryAt: number | undefined;
|
|
||||||
let acquired = false;
|
|
||||||
try {
|
try {
|
||||||
await this.initialize();
|
await this.initialize(); if (!await this.acquire()) return;
|
||||||
acquired = await this.acquire();
|
|
||||||
if (!acquired) { retryAt = this.now() + 30_000; return; }
|
|
||||||
// Only the lease holder recovers expired intents. Never resend a potentially accepted provider request.
|
// Only the lease holder recovers expired intents. Never resend a potentially accepted provider request.
|
||||||
await this.options.db.agentPushDelivery.updateMany({ where: { status: 'intent', updatedAt: { lt: date(this.now() - 30_000) } }, data: { status: 'ambiguous', outcome: 'interrupted_send' } });
|
await this.options.db.agentPushDelivery.updateMany({ where: { status: 'intent', updatedAt: { lt: date(this.now() - 30_000) } }, data: { status: 'ambiguous', outcome: 'interrupted_send' } });
|
||||||
let more = true;
|
let more = true;
|
||||||
for (let i = 0; i < 10 && more; i++) { if (!await this.acquire()) { retryAt = this.now() + 30_000; return; } more = await this.observe(); }
|
for (let i = 0; i < 10 && more; i++) { if (!await this.acquire()) return; more = await this.observe(); }
|
||||||
if (more) { retryAt = this.now() + 100; return; } // Catch up before dispatch.
|
if (more) return; // Current native read/prompt/presence must be caught up before dispatch.
|
||||||
if (reconsider) await this.options.db.agentPushDelivery.updateMany({ where: { status: 'pending', lastSuppressionReason: { not: null }, dueAt: { gt: date(this.now()) } }, data: { dueAt: date(this.now()) } });
|
|
||||||
const pending = await this.options.db.agentPushDelivery.findMany({ where: { status: 'pending', dueAt: { lte: date(this.now()) } }, orderBy: [{ priority: 'desc' }, { dueAt: 'asc' }], take: 20, select: { id: true } });
|
const pending = await this.options.db.agentPushDelivery.findMany({ where: { status: 'pending', dueAt: { lte: date(this.now()) } }, orderBy: [{ priority: 'desc' }, { dueAt: 'asc' }], take: 20, select: { id: true } });
|
||||||
for (const row of pending) {
|
for (const row of pending) {
|
||||||
if (this.stopped) break;
|
if (this.stopped || !await this.acquire()) break;
|
||||||
if (!await this.acquire()) { retryAt = this.now() + 30_000; break; }
|
|
||||||
// Fetch fresh runtime native presence/read/latest prompt before EACH provider boundary.
|
// Fetch fresh runtime native presence/read/latest prompt before EACH provider boundary.
|
||||||
if (await this.observe()) break;
|
if (await this.observe()) break;
|
||||||
await this.dispatch(row.id);
|
await this.dispatch(row.id);
|
||||||
}
|
}
|
||||||
await this.options.db.agentPushDelivery.updateMany({ where: { status: 'pending', expiresAt: { lte: date(this.now()) } }, data: { status: 'cancelled', outcome: 'expired' } });
|
await this.options.db.agentPushDelivery.updateMany({ where: { status: 'pending', expiresAt: { lte: date(this.now()) } }, data: { status: 'cancelled', outcome: 'expired' } });
|
||||||
if (this.now() >= this.nextMaintenance) {
|
await this.options.db.agentPushPresence.deleteMany({ where: { expiresAt: { lt: date(this.now() - 86_400_000) } } });
|
||||||
await this.options.db.agentPushPresence.deleteMany({ where: { expiresAt: { lt: date(this.now() - 86_400_000) } } });
|
await this.options.db.agentPushConnection.deleteMany({ where: { expiresAt: { lt: date(this.now()) } } });
|
||||||
await this.options.db.agentPushConnection.deleteMany({ where: { expiresAt: { lt: date(this.now()) } } });
|
await this.options.db.agentPushDelivery.deleteMany({ where: { status: { notIn: activeStatuses }, updatedAt: { lt: date(this.now() - 7 * 86_400_000) } } });
|
||||||
await this.options.db.agentPushDelivery.deleteMany({ where: { status: { notIn: activeStatuses }, updatedAt: { lt: date(this.now() - 7 * 86_400_000) } } });
|
|
||||||
this.nextMaintenance = this.now() + 3_600_000;
|
|
||||||
}
|
|
||||||
this.failures = 0;
|
|
||||||
} catch {
|
} catch {
|
||||||
retryAt = this.now() + Math.min(30_000, 1_000 * 2 ** Math.min(this.failures++, 5));
|
|
||||||
// An unavailable source, DB, malformed contract or clock uncertainty blocks dispatch without leaking payloads.
|
// An unavailable source, DB, malformed contract or clock uncertainty blocks dispatch without leaking payloads.
|
||||||
try {
|
try {
|
||||||
await this.options.db.$transaction(async tx => {
|
await this.options.db.$transaction(async tx => {
|
||||||
@@ -440,14 +380,8 @@ export class AgentPushCoordinator {
|
|||||||
});
|
});
|
||||||
} catch { /* Retry on next loop. */ }
|
} catch { /* Retry on next loop. */ }
|
||||||
} finally {
|
} finally {
|
||||||
try { if (acquired) await this.options.db.agentPushCoordinator.updateMany({ where: { id: 'root', lockOwner: this.instanceId }, data: { lockOwner: null, lockUntil: null } }); } catch { /* Lease expiry recovers. */ }
|
try { await this.options.db.agentPushCoordinator.updateMany({ where: { id: 'root', lockOwner: this.instanceId }, data: { lockOwner: null, lockUntil: null } }); } catch { /* Lease expiry recovers. */ }
|
||||||
if (retryAt) this.reconsider ||= reconsider;
|
|
||||||
this.running = false;
|
this.running = false;
|
||||||
if (this.enabled && !this.stopped) {
|
|
||||||
if (retryAt) this.schedule(retryAt);
|
|
||||||
else if (this.dirty) this.schedule(this.now() + 100);
|
|
||||||
else try { await this.schedulePending(); } catch { this.schedule(this.now() + 30_000); }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -456,7 +390,6 @@ export async function registerPushRoutes(app: FastifyInstance, db: PrismaClient,
|
|||||||
overrides?: Partial<Options>): Promise<AgentPushCoordinator> {
|
overrides?: Partial<Options>): Promise<AgentPushCoordinator> {
|
||||||
const config = overrides?.config || loadPushConfig();
|
const config = overrides?.config || loadPushConfig();
|
||||||
const coordinator = new AgentPushCoordinator({ db, config, broadcast,
|
const coordinator = new AgentPushCoordinator({ db, config, broadcast,
|
||||||
subscribe: changed => subscribeNotificationEvents(upstream, changed),
|
|
||||||
context: async (after, sourceId) => {
|
context: async (after, sourceId) => {
|
||||||
if (!upstream.apiKey) throw new Error('Runtime unavailable');
|
if (!upstream.apiKey) throw new Error('Runtime unavailable');
|
||||||
// A replaced/restored runtime can reject the old cursor before returning its identity.
|
// A replaced/restored runtime can reject the old cursor before returning its identity.
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import { once } from 'node:events';
|
|
||||||
import { createServer } from 'node:http';
|
|
||||||
import test from 'node:test';
|
|
||||||
import { WebSocketServer } from 'ws';
|
|
||||||
import { subscribeNotificationEvents } from '../src/agent-push-events.js';
|
|
||||||
|
|
||||||
test('notification observer authenticates, reconnects and stops without leaking missed invalidations', async t => {
|
|
||||||
const server = createServer(); const sockets = new WebSocketServer({ server });
|
|
||||||
server.listen(0, '127.0.0.1'); await once(server, 'listening');
|
|
||||||
let connections = 0, wakes = 0;
|
|
||||||
sockets.on('connection', (socket, request) => {
|
|
||||||
connections++;
|
|
||||||
assert.equal(request.url, '/api/notification-events');
|
|
||||||
assert.equal(request.headers.authorization, 'Bearer fixture-key');
|
|
||||||
assert.equal(request.headers['x-sybil-client'], 'backend-observer-v1');
|
|
||||||
socket.send(JSON.stringify({ type: 'notification_changed', version: 1 }));
|
|
||||||
});
|
|
||||||
const stop = subscribeNotificationEvents({ baseUrl: `http://127.0.0.1:${(server.address() as any).port}`, apiKey: 'fixture-key' }, () => { wakes++; }, { retryMs: 20 });
|
|
||||||
t.after(async () => { stop(); for (const socket of sockets.clients) socket.terminate(); sockets.close(); await new Promise<void>(resolve => server.close(() => resolve())); });
|
|
||||||
const waitFor = async (count: number) => { const deadline = Date.now() + 3000; while (wakes < count) { assert.ok(Date.now() < deadline); await new Promise(resolve => setTimeout(resolve, 10)); } };
|
|
||||||
await waitFor(1); for (const socket of sockets.clients) socket.terminate(); await waitFor(2);
|
|
||||||
assert.equal(connections, 2); stop(); await new Promise(resolve => setTimeout(resolve, 80)); assert.equal(connections, 2);
|
|
||||||
});
|
|
||||||
@@ -51,7 +51,7 @@ async function fixture(options: { warmupMs?: number; config?: PushConfig } = {})
|
|||||||
const installationId = randomUUID(); await manager.register(installationId, apns); await manager.tick();
|
const installationId = randomUUID(); await manager.register(installationId, apns); await manager.tick();
|
||||||
return { manager, installationId, source, sent, reads, get now() { return now; }, advance(ms: number) { now += ms; },
|
return { manager, installationId, source, sent, reads, get now() { return now; }, advance(ms: number) { now += ms; },
|
||||||
result(value: SendResult) { result = value; }, failure(value: boolean) { failure = value; },
|
result(value: SendResult) { result = value; }, failure(value: boolean) { failure = value; },
|
||||||
context(value: Partial<Context>) { context = { ...context, ...value }; manager.wake(); },
|
context(value: Partial<Context>) { context = { ...context, ...value }; },
|
||||||
candidate(cursor = '11', trigger: Context['candidates'][number]['trigger'] = 'user') { context = { ...context, cursor,
|
candidate(cursor = '11', trigger: Context['candidates'][number]['trigger'] = 'user') { context = { ...context, cursor,
|
||||||
candidates: [...context.candidates, { cursor, messageId: `outbound:${cursor}`, createdAt: new Date(now).toISOString(), trigger }] }; },
|
candidates: [...context.candidates, { cursor, messageId: `outbound:${cursor}`, createdAt: new Date(now).toISOString(), trigger }] }; },
|
||||||
};
|
};
|
||||||
@@ -439,40 +439,3 @@ test('endpoint rebind also retires disabled registrations from a prior credentia
|
|||||||
await assert.rejects(current.register(f.installationId, apns), { statusCode: 409 });
|
await assert.rejects(current.register(f.installationId, apns), { statusCode: 409 });
|
||||||
await current.revoke(newer); assert.equal(await prisma.agentPushInstallation.count({ where: { enabled: true } }), 0);
|
await current.revoke(newer); assert.equal(await prisma.agentPushInstallation.count({ where: { enabled: true } }), 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
async function eventually(predicate: () => Promise<boolean> | boolean) {
|
|
||||||
const deadline = Date.now() + 5000;
|
|
||||||
while (!await predicate()) { if (Date.now() > deadline) assert.fail('event-driven reconciliation timed out'); await new Promise(resolve => setTimeout(resolve, 10)); }
|
|
||||||
}
|
|
||||||
test('event scheduler stays idle, coalesces wakes, dispatches at the deadline and retains events during a snapshot', async t => {
|
|
||||||
const f = await fixture();
|
|
||||||
let wake!: () => void, observations = 0, unsubscribed = false, wakeDuringFetch = false;
|
|
||||||
const original = f.manager.options.context;
|
|
||||||
const manager = new AgentPushCoordinator({ ...f.manager.options, now: Date.now, subscribe: callback => { wake = callback; return () => { unsubscribed = true; }; },
|
|
||||||
context: async (...args) => { observations++; const result = await original(...args);
|
|
||||||
if (wakeDuringFetch) { wakeDuringFetch = false; wake(); }
|
|
||||||
return { ...result, webPresence: { ...result.webPresence, observedAt: new Date().toISOString() } }; } });
|
|
||||||
t.after(() => manager.stop()); manager.start();
|
|
||||||
await eventually(() => observations === 1); await new Promise(resolve => setTimeout(resolve, 2300));
|
|
||||||
assert.equal(observations, 1, 'no two-second source polling while idle');
|
|
||||||
f.candidate(); wake(); wake(); wake();
|
|
||||||
await eventually(async () => Boolean(await prisma.agentPushDelivery.findFirst({ where: { status: 'pending' } })));
|
|
||||||
await eventually(() => f.sent.length === 1);
|
|
||||||
const settled = observations;
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 300)); assert.equal(observations, settled, 'no repeated polling after the outbox drains');
|
|
||||||
wakeDuringFetch = true; wake(); await eventually(() => observations >= settled + 2);
|
|
||||||
await manager.stop(); assert.equal(unsubscribed, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('presence wake releases long suppression promptly but cannot shorten a provider retry', async () => {
|
|
||||||
const f = await fixture(), tab = randomUUID();
|
|
||||||
await f.manager.presence(tab, { sequence: 1, visibility: 'visible', focused: true, interacted: false });
|
|
||||||
f.candidate(); await f.manager.tick(); f.advance(2000); await f.manager.tick();
|
|
||||||
assert.equal((await prisma.agentPushDelivery.findFirstOrThrow()).dueAt.getTime(), f.now + 58000, 'focus waits for the lease, not another two-second tick');
|
|
||||||
await f.manager.presence(tab, { sequence: 2, visibility: 'hidden', focused: false, interacted: false });
|
|
||||||
await f.manager.tick(); assert.equal((await prisma.agentPushDelivery.findFirstOrThrow()).dueAt.getTime(), f.now + 5000);
|
|
||||||
f.advance(5000); f.result({ outcome: 'retry', retryAfterMs: 10000 }); await f.manager.tick(); assert.equal(f.sent.length, 1);
|
|
||||||
f.advance(100); f.manager.wake(); await f.manager.tick(); assert.equal(f.sent.length, 1, 'event does not bypass provider backoff');
|
|
||||||
f.advance(9900); f.result({ outcome: 'accepted' }); await f.manager.tick(); assert.equal(f.sent.length, 2);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ const upstream = createServer(async (req, res) => {
|
|||||||
const sourceSockets = new WebSocketServer({ noServer: true });
|
const sourceSockets = new WebSocketServer({ noServer: true });
|
||||||
upstream.on('upgrade', (req, socket, head) => {
|
upstream.on('upgrade', (req, socket, head) => {
|
||||||
assert.equal(req.headers.authorization, `Bearer ${privateKey}`);
|
assert.equal(req.headers.authorization, `Bearer ${privateKey}`);
|
||||||
if (req.url === '/api/notification-events') { socket.end('HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n'); return; }
|
|
||||||
assert.equal(req.url, '/api/ws');
|
assert.equal(req.url, '/api/ws');
|
||||||
connections++;
|
connections++;
|
||||||
sourceSockets.handleUpgrade(req, socket, head, ws => {
|
sourceSockets.handleUpgrade(req, socket, head, ws => {
|
||||||
|
|||||||
@@ -60,11 +60,3 @@ Client API contract docs:
|
|||||||
|
|
||||||
- `/`: full Sybil app (sidebar + chat/search workspace)
|
- `/`: full Sybil app (sidebar + chat/search workspace)
|
||||||
- `/search?q=bitcoin+price`: standalone search page that renders only the search query box and results panel
|
- `/search?q=bitcoin+price`: standalone search page that renders only the search query box and results panel
|
||||||
|
|
||||||
## Mobile web
|
|
||||||
|
|
||||||
Installed Sybil uses `standalone` display, retaining the system status bar where the browser supports it. The viewport keeps pinch zoom disabled. Compact regular-chat replies use the transcript width and the active provider/model appears beneath the title; Agent keeps its existing layout.
|
|
||||||
|
|
||||||
Horizontal navigation swipes start on workspace chrome or empty space. Text, tables, quotations, code, controls, existing selections and nested horizontal scrollers keep native gestures, including at scroll edges. Long-press/right-click on transcript text and links uses the native browser menu; the message-actions button provides Fork.
|
|
||||||
|
|
||||||
Agent notification settings include separate browser/server failure guidance and an expandable Android / GrapheneOS setup guide. See [mobile QA results and limitations](../docs/mobile-web-qa.md), including how to run the browser fixtures.
|
|
||||||
|
|||||||
+1
-1
@@ -8,7 +8,7 @@
|
|||||||
<meta name="theme-color" content="#0f172a" />
|
<meta name="theme-color" content="#0f172a" />
|
||||||
<meta name="mobile-web-app-capable" content="yes" />
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<meta name="apple-mobile-web-app-title" content="Sybil" />
|
<meta name="apple-mobile-web-app-title" content="Sybil" />
|
||||||
<meta name="format-detection" content="telephone=no" />
|
<meta name="format-detection" content="telephone=no" />
|
||||||
<link rel="manifest" href="/manifest.webmanifest" />
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
"description": "Sybil chat and search workspace",
|
"description": "Sybil chat and search workspace",
|
||||||
"start_url": "/",
|
"start_url": "/",
|
||||||
"scope": "/",
|
"scope": "/",
|
||||||
"display": "standalone",
|
"display": "fullscreen",
|
||||||
|
"display_override": ["fullscreen", "standalone"],
|
||||||
"background_color": "#0b0718",
|
"background_color": "#0b0718",
|
||||||
"theme_color": "#0f172a",
|
"theme_color": "#0f172a",
|
||||||
"icons": [
|
"icons": [
|
||||||
|
|||||||
+16
-25
@@ -28,7 +28,6 @@ import { ChatComposer } from "@/components/chat/chat-composer";
|
|||||||
import { ChatMessagesPanel } from "@/components/chat/chat-messages-panel";
|
import { ChatMessagesPanel } from "@/components/chat/chat-messages-panel";
|
||||||
import { AgentConversation } from "@/components/agent/agent-conversation";
|
import { AgentConversation } from "@/components/agent/agent-conversation";
|
||||||
import { AgentNotifications } from "@/components/agent/agent-notifications";
|
import { AgentNotifications } from "@/components/agent/agent-notifications";
|
||||||
import { hasTextSelection, ownsMobileGesture } from "@/lib/mobile-workspace-gestures";
|
|
||||||
import { SearchResultsPanel } from "@/components/search/search-results-panel";
|
import { SearchResultsPanel } from "@/components/search/search-results-panel";
|
||||||
import { SearchMatchLabel } from "@/components/search/search-match-label";
|
import { SearchMatchLabel } from "@/components/search/search-match-label";
|
||||||
import { SybilCharacter } from "@/components/sybil-character";
|
import { SybilCharacter } from "@/components/sybil-character";
|
||||||
@@ -202,7 +201,6 @@ type MobileWorkspaceSwipe = {
|
|||||||
touchIdentifier: number;
|
touchIdentifier: number;
|
||||||
startX: number;
|
startX: number;
|
||||||
startY: number;
|
startY: number;
|
||||||
startTimestamp: number;
|
|
||||||
lastX: number;
|
lastX: number;
|
||||||
lastTimestamp: number;
|
lastTimestamp: number;
|
||||||
velocityX: number;
|
velocityX: number;
|
||||||
@@ -1810,11 +1808,8 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleMobileWorkspaceTouchStart = (event: TargetedTouchEvent<HTMLElement>) => {
|
const handleMobileWorkspaceTouchStart = (event: TargetedTouchEvent<HTMLElement>) => {
|
||||||
resetMobileWorkspaceSwipe();
|
|
||||||
if (
|
if (
|
||||||
event.defaultPrevented ||
|
|
||||||
event.touches.length !== 1 ||
|
event.touches.length !== 1 ||
|
||||||
hasTextSelection() ||
|
|
||||||
!isMobileLayout() ||
|
!isMobileLayout() ||
|
||||||
isMobileSidebarOpen ||
|
isMobileSidebarOpen ||
|
||||||
isQuickQuestionOpen ||
|
isQuickQuestionOpen ||
|
||||||
@@ -1824,7 +1819,13 @@ export default function App() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ownsMobileGesture(event.target, event.currentTarget)) return;
|
const target = event.target;
|
||||||
|
if (
|
||||||
|
target instanceof Element &&
|
||||||
|
target.closest("input, textarea, select, button, a, [role='dialog'], [data-mobile-swipe-ignore]")
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const touch = event.touches.item(0);
|
const touch = event.touches.item(0);
|
||||||
if (!touch) return;
|
if (!touch) return;
|
||||||
@@ -1832,7 +1833,6 @@ export default function App() {
|
|||||||
touchIdentifier: touch.identifier,
|
touchIdentifier: touch.identifier,
|
||||||
startX: touch.clientX,
|
startX: touch.clientX,
|
||||||
startY: touch.clientY,
|
startY: touch.clientY,
|
||||||
startTimestamp: event.timeStamp,
|
|
||||||
lastX: touch.clientX,
|
lastX: touch.clientX,
|
||||||
lastTimestamp: event.timeStamp,
|
lastTimestamp: event.timeStamp,
|
||||||
velocityX: 0,
|
velocityX: 0,
|
||||||
@@ -1843,10 +1843,6 @@ export default function App() {
|
|||||||
const handleMobileWorkspaceTouchMove = (event: TargetedTouchEvent<HTMLElement>) => {
|
const handleMobileWorkspaceTouchMove = (event: TargetedTouchEvent<HTMLElement>) => {
|
||||||
const swipe = mobileWorkspaceSwipeRef.current;
|
const swipe = mobileWorkspaceSwipeRef.current;
|
||||||
if (!swipe) return;
|
if (!swipe) return;
|
||||||
if (event.touches.length !== 1 || hasTextSelection() || (swipe.direction === null && event.timeStamp - swipe.startTimestamp > 350)) {
|
|
||||||
resetMobileWorkspaceSwipe();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const touch = findTouch(event.touches, swipe.touchIdentifier);
|
const touch = findTouch(event.touches, swipe.touchIdentifier);
|
||||||
if (!touch) return;
|
if (!touch) return;
|
||||||
|
|
||||||
@@ -1894,7 +1890,7 @@ export default function App() {
|
|||||||
swipe.velocityX = ((touch.clientX - swipe.lastX) / elapsedMs) * 1000;
|
swipe.velocityX = ((touch.clientX - swipe.lastX) / elapsedMs) * 1000;
|
||||||
}
|
}
|
||||||
resetMobileWorkspaceSwipe();
|
resetMobileWorkspaceSwipe();
|
||||||
if (direction === null || hasTextSelection()) return;
|
if (direction === null) return;
|
||||||
|
|
||||||
const directionalDistance = deltaX * direction;
|
const directionalDistance = deltaX * direction;
|
||||||
const directionalVelocity = swipe.velocityX * direction;
|
const directionalVelocity = swipe.velocityX * direction;
|
||||||
@@ -2204,9 +2200,8 @@ export default function App() {
|
|||||||
const menuWidth = 176;
|
const menuWidth = 176;
|
||||||
const menuHeight = 48;
|
const menuHeight = 48;
|
||||||
const padding = 8;
|
const padding = 8;
|
||||||
const anchor = (event.currentTarget as HTMLElement).getBoundingClientRect();
|
const x = Math.min(event.clientX, window.innerWidth - menuWidth - padding);
|
||||||
const x = Math.min(anchor.left, window.innerWidth - menuWidth - padding);
|
const y = Math.min(event.clientY, window.innerHeight - menuHeight - padding);
|
||||||
const y = Math.min(anchor.bottom, window.innerHeight - menuHeight - padding);
|
|
||||||
setContextMenu({
|
setContextMenu({
|
||||||
kind: "message",
|
kind: "message",
|
||||||
chatId: selectedItem.id,
|
chatId: selectedItem.id,
|
||||||
@@ -3509,7 +3504,7 @@ export default function App() {
|
|||||||
|
|
||||||
<aside
|
<aside
|
||||||
className={cn(
|
className={cn(
|
||||||
"workspace-sidebar glass-panel fixed inset-y-0 left-0 z-40 flex w-[86vw] max-w-80 shrink-0 flex-col overflow-y-auto overscroll-contain border-r border-violet-300/18 transition-transform md:static md:z-auto md:w-80 md:max-w-none",
|
"workspace-sidebar glass-panel fixed inset-y-0 left-0 z-40 flex w-[86vw] max-w-80 shrink-0 flex-col border-r border-violet-300/18 transition-transform md:static md:z-auto md:w-80 md:max-w-none",
|
||||||
isMobileSidebarOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"
|
isMobileSidebarOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -3732,12 +3727,11 @@ export default function App() {
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main
|
<main
|
||||||
className="workspace-content glass-panel relative flex min-w-0 flex-1 flex-col overflow-hidden"
|
className="workspace-content glass-panel relative flex min-w-0 flex-1 touch-pan-y flex-col overflow-hidden md:touch-auto"
|
||||||
onTouchStart={handleMobileWorkspaceTouchStart}
|
onTouchStart={handleMobileWorkspaceTouchStart}
|
||||||
onTouchMove={handleMobileWorkspaceTouchMove}
|
onTouchMove={handleMobileWorkspaceTouchMove}
|
||||||
onTouchEnd={handleMobileWorkspaceTouchEnd}
|
onTouchEnd={handleMobileWorkspaceTouchEnd}
|
||||||
onTouchCancel={handleMobileWorkspaceTouchCancel}
|
onTouchCancel={handleMobileWorkspaceTouchCancel}
|
||||||
onContextMenu={resetMobileWorkspaceSwipe}
|
|
||||||
>
|
>
|
||||||
{isAgentMode ? (
|
{isAgentMode ? (
|
||||||
<AgentConversation room={agentRoom} onOpenSidebar={() => setIsMobileSidebarOpen(true)} isVisible={!isMobileSidebarOpen && !isQuickQuestionOpen} />
|
<AgentConversation room={agentRoom} onOpenSidebar={() => setIsMobileSidebarOpen(true)} isVisible={!isMobileSidebarOpen && !isQuickQuestionOpen} />
|
||||||
@@ -3755,11 +3749,8 @@ export default function App() {
|
|||||||
<Menu className="h-4 w-4" />
|
<Menu className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="min-w-0">
|
<div className="flex min-w-0 items-center gap-1.5">
|
||||||
<h1 className="truncate text-sm font-semibold text-violet-50 md:text-base">{selectedTitle}</h1>
|
<h1 className="truncate text-sm font-semibold text-violet-50 md:text-base">{selectedTitle}</h1>
|
||||||
{!isSearchMode ? <p className="truncate text-xs text-violet-100/58 md:hidden" title={`${getProviderLabel(provider)} · ${model || "No model"}`}>
|
|
||||||
{getProviderLabel(provider)} · {model || "No model"}
|
|
||||||
</p> : null}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center justify-end gap-2">
|
<div className="flex shrink-0 items-center justify-end gap-2">
|
||||||
@@ -3773,8 +3764,8 @@ export default function App() {
|
|||||||
aria-label="Open chat settings"
|
aria-label="Open chat settings"
|
||||||
>
|
>
|
||||||
<Settings2 className="h-4 w-4 shrink-0" />
|
<Settings2 className="h-4 w-4 shrink-0" />
|
||||||
<span className="hidden shrink-0 md:inline">Settings</span>
|
<span className="hidden shrink-0 sm:inline">Settings</span>
|
||||||
<span className="hidden min-w-0 max-w-[18rem] truncate text-xs font-medium text-violet-100/58 md:inline">
|
<span className="hidden min-w-0 max-w-[18rem] truncate text-xs font-medium text-violet-100/58 sm:inline">
|
||||||
{getProviderLabel(provider)} · {model || "No model"}
|
{getProviderLabel(provider)} · {model || "No model"}
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -3805,7 +3796,7 @@ export default function App() {
|
|||||||
messages={displayMessages}
|
messages={displayMessages}
|
||||||
isLoading={isLoadingSelection}
|
isLoading={isLoadingSelection}
|
||||||
isSending={isSendingActiveChat}
|
isSending={isSendingActiveChat}
|
||||||
onMessageActions={openMessageContextMenu}
|
onMessageContextMenu={openMessageContextMenu}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<SearchResultsPanel
|
<SearchResultsPanel
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { disableAgentBrowserPush, enableAgentBrowserPush, getAgentPushState, obs
|
|||||||
export function AgentNotifications({ onLogout }: { onLogout: () => Promise<void> }) {
|
export function AgentNotifications({ onLogout }: { onLogout: () => Promise<void> }) {
|
||||||
const [state, setState] = useState(getAgentPushState), [signingOut, setSigningOut] = useState(false);
|
const [state, setState] = useState(getAgentPushState), [signingOut, setSigningOut] = useState(false);
|
||||||
useEffect(() => observeAgentPush(setState), []);
|
useEffect(() => observeAgentPush(setState), []);
|
||||||
return <details className="max-h-[50dvh] shrink-0 overflow-y-auto overscroll-contain border-t border-violet-300/15 px-3 py-2 text-xs text-violet-100/75">
|
return <details className="shrink-0 border-t border-violet-300/15 px-3 py-2 text-xs text-violet-100/75">
|
||||||
<summary className="flex cursor-pointer items-center gap-2 rounded py-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring"><Bell className="h-4 w-4" />Agent notifications</summary>
|
<summary className="flex cursor-pointer items-center gap-2 rounded py-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring"><Bell className="h-4 w-4" />Agent notifications</summary>
|
||||||
<div className="space-y-2 pb-1 pt-2">
|
<div className="space-y-2 pb-1 pt-2">
|
||||||
<p>Notifications include Agent message text and may appear on your lock screen.</p>
|
<p>Notifications include Agent message text and may appear on your lock screen.</p>
|
||||||
@@ -18,15 +18,6 @@ export function AgentNotifications({ onLogout }: { onLogout: () => Promise<void>
|
|||||||
<Button className="h-8 text-xs" variant="ghost" disabled={state.busy} onClick={() => void refreshAgentBrowserPush()}>Refresh</Button>
|
<Button className="h-8 text-xs" variant="ghost" disabled={state.busy} onClick={() => void refreshAgentBrowserPush()}>Refresh</Button>
|
||||||
<Button className="h-8 text-xs" variant="ghost" disabled={signingOut} onClick={() => { setSigningOut(true); void onLogout().finally(() => setSigningOut(false)); }}>Sign out</Button>
|
<Button className="h-8 text-xs" variant="ghost" disabled={signingOut} onClick={() => { setSigningOut(true); void onLogout().finally(() => setSigningOut(false)); }}>Sign out</Button>
|
||||||
</div>
|
</div>
|
||||||
<details>
|
|
||||||
<summary className="cursor-pointer py-2 underline">Android / GrapheneOS help</summary>
|
|
||||||
<div className="space-y-2 pb-2">
|
|
||||||
<p>Vanadium uses Google Play services for Web Push. On GrapheneOS, sandboxed Google Play must be installed in the same profile as Vanadium, with network access and a battery optimization exception for Play services.</p>
|
|
||||||
<p>Allow notifications for Sybil in browser site settings and for Vanadium in Android settings. Then reload Sybil and enable notifications again. Permission alone does not mean the browser can register for push.</p>
|
|
||||||
<p>If you choose not to use Google Play, you need a browser with an independent Web Push service. Sybil cannot replace the browser's push service or detect whether Play services is installed.</p>
|
|
||||||
<p><a className="underline" href="https://grapheneos.org/usage#sandboxed-google-play" target="_blank" rel="noopener noreferrer">GrapheneOS setup guide</a>{" · "}<a className="underline" href="https://chromium.googlesource.com/chromium/src/+/main/components/gcm_driver/android/java/src/org/chromium/components/gcm_driver/GoogleCloudMessagingV2.java" target="_blank" rel="noopener noreferrer">Chromium push implementation</a></p>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
</div>
|
</div>
|
||||||
</details>;
|
</details>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,13 +5,13 @@ import { ChatAttachmentList } from "@/components/chat/chat-attachment-list";
|
|||||||
import { getMessageAttachments, type Message } from "@/lib/api";
|
import { getMessageAttachments, type Message } from "@/lib/api";
|
||||||
import { asStreamingAssistantMetadata } from "@/lib/chat-stream-presentation";
|
import { asStreamingAssistantMetadata } from "@/lib/chat-stream-presentation";
|
||||||
import { MarkdownContent } from "@/components/markdown/markdown-content";
|
import { MarkdownContent } from "@/components/markdown/markdown-content";
|
||||||
import { ChevronDown, ChevronUp, Globe2, Link2, MoreHorizontal, Wrench } from "lucide-preact";
|
import { ChevronDown, ChevronUp, Globe2, Link2, Wrench } from "lucide-preact";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
messages: Message[];
|
messages: Message[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isSending: boolean;
|
isSending: boolean;
|
||||||
onMessageActions?: (event: MouseEvent, messageId: string) => void;
|
onMessageContextMenu?: (event: MouseEvent, messageId: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ToolLogMetadata = {
|
type ToolLogMetadata = {
|
||||||
@@ -533,7 +533,7 @@ export function StreamingAssistantPresentation({ message, isSending }: { message
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatMessagesPanel({ messages, isLoading, isSending, onMessageActions }: Props) {
|
export function ChatMessagesPanel({ messages, isLoading, isSending, onMessageContextMenu }: Props) {
|
||||||
const hasPendingAssistant = messages.some((message) => message.id.startsWith("temp-assistant-"));
|
const hasPendingAssistant = messages.some((message) => message.id.startsWith("temp-assistant-"));
|
||||||
const renderItems = useMemo(() => buildMessageRenderItems(messages), [messages]);
|
const renderItems = useMemo(() => buildMessageRenderItems(messages), [messages]);
|
||||||
const toolCallMessageIDs = useMemo(() => getToolCallMessageIDs(messages), [messages]);
|
const toolCallMessageIDs = useMemo(() => getToolCallMessageIDs(messages), [messages]);
|
||||||
@@ -604,8 +604,13 @@ export function ChatMessagesPanel({ messages, isLoading, isSending, onMessageAct
|
|||||||
"min-w-0 space-y-3",
|
"min-w-0 space-y-3",
|
||||||
isUser
|
isUser
|
||||||
? "message-bubble message-bubble--user max-w-[87%] md:max-w-[78%]"
|
? "message-bubble message-bubble--user max-w-[87%] md:max-w-[78%]"
|
||||||
: "w-full max-w-full md:w-auto md:max-w-[85%] text-base leading-7 text-violet-50"
|
: "max-w-[85%] text-base leading-7 text-violet-50"
|
||||||
)}
|
)}
|
||||||
|
onContextMenu={
|
||||||
|
message.role === "assistant" && !message.id.startsWith("temp-") && onMessageContextMenu
|
||||||
|
? (event) => onMessageContextMenu(event, message.id)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{attachments.length ? <ChatAttachmentList attachments={attachments} tone={isUser ? "user" : "assistant"} /> : null}
|
{attachments.length ? <ChatAttachmentList attachments={attachments} tone={isUser ? "user" : "assistant"} /> : null}
|
||||||
{streamingAssistantMetadata ? (
|
{streamingAssistantMetadata ? (
|
||||||
@@ -623,13 +628,6 @@ export function ChatMessagesPanel({ messages, isLoading, isSending, onMessageAct
|
|||||||
className={cn("[&_a]:text-inherit [&_a]:underline", !isUser && "leading-[1.82] text-violet-50")}
|
className={cn("[&_a]:text-inherit [&_a]:underline", !isUser && "leading-[1.82] text-violet-50")}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{message.role === "assistant" && !message.id.startsWith("temp-") && onMessageActions ? (
|
|
||||||
<button type="button" aria-label="Message actions" title="Message actions"
|
|
||||||
className="flex h-9 w-9 items-center justify-center rounded text-muted-foreground hover:bg-violet-400/12 hover:text-violet-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring"
|
|
||||||
onClick={event => onMessageActions(event, message.id)}>
|
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -462,17 +462,6 @@ textarea {
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.md-table-scroll,
|
|
||||||
.md-content pre,
|
|
||||||
.md-content blockquote {
|
|
||||||
overscroll-behavior-x: contain;
|
|
||||||
}
|
|
||||||
|
|
||||||
.md-content blockquote {
|
|
||||||
max-width: 100%;
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.md-content > :first-child {
|
.md-content > :first-child {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
import type { Message } from "./api";
|
|
||||||
|
|
||||||
// The runtime also uses the Unix epoch for historical entries without a time.
|
|
||||||
// The transcript renders these as "Earlier", without inventing a send time.
|
|
||||||
export const UNKNOWN_AGENT_TIME = "1970-01-01T00:00:00.000Z";
|
|
||||||
const object = (value: unknown): Record<string, unknown> | undefined => value !== null && typeof value === "object" && !Array.isArray(value)
|
|
||||||
? value as Record<string, unknown> : undefined;
|
|
||||||
const timestamp = (value: unknown): string | undefined => {
|
|
||||||
if (typeof value !== "string" || !value.trim()) return undefined;
|
|
||||||
const time = Date.parse(value);
|
|
||||||
return Number.isFinite(time) && time !== 0 ? new Date(time).toISOString() : undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function agentMessageTime(message: Pick<Message, "createdAt" | "metadata">): string | undefined {
|
|
||||||
const info = object(message.metadata);
|
|
||||||
return timestamp(message.createdAt) ?? timestamp(info?.startedAt) ?? timestamp(info?.completedAt);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Normalize historical/partial rows at both transports, before state or cursors change. */
|
|
||||||
export function normalizeAgentMessage(value: unknown): Message {
|
|
||||||
const message = object(value);
|
|
||||||
if (!message || typeof message.id !== "string" || !message.id
|
|
||||||
|| typeof message.role !== "string" || !["system", "user", "assistant", "tool"].includes(message.role)) throw new Error("Invalid agent message.");
|
|
||||||
const info = object(message.metadata);
|
|
||||||
// Older runtime REST redaction emitted protected tool rows with only id/role/name.
|
|
||||||
// Keep their identity for a later complete upsert; never discard a whole page.
|
|
||||||
const content = typeof message.content === "string" ? message.content
|
|
||||||
: message.role === "tool" && message.content == null ? (typeof message.name === "string" ? message.name : "Tool") : undefined;
|
|
||||||
if (content === undefined) throw new Error("Invalid agent message.");
|
|
||||||
const createdAt = agentMessageTime(message as Message) ?? UNKNOWN_AGENT_TIME;
|
|
||||||
const needsToolMetadata = message.role === "tool" && info?.kind !== "tool_call";
|
|
||||||
if (message.createdAt === createdAt && message.content === content && !needsToolMetadata) return message as Message;
|
|
||||||
return { ...message, content, createdAt,
|
|
||||||
...(needsToolMetadata ? { metadata: { ...info, kind: "tool_call" } } : {}),
|
|
||||||
} as Message;
|
|
||||||
}
|
|
||||||
|
|
||||||
function cursor(message: Message): bigint | undefined {
|
|
||||||
const entryId = object(message.metadata)?.entryId;
|
|
||||||
if ((typeof entryId === "number" && Number.isSafeInteger(entryId) && entryId >= 0)
|
|
||||||
|| (typeof entryId === "string" && /^\d+$/.test(entryId))) return BigInt(entryId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One total order for history, queue snapshots and optimistic rows, including ties. */
|
|
||||||
export function compareAgentMessages(left: Message, right: Message): number {
|
|
||||||
const byTime = Date.parse(agentMessageTime(left) ?? UNKNOWN_AGENT_TIME) - Date.parse(agentMessageTime(right) ?? UNKNOWN_AGENT_TIME);
|
|
||||||
if (byTime) return byTime;
|
|
||||||
const a = cursor(left), b = cursor(right);
|
|
||||||
if (a !== b) {
|
|
||||||
if (a === undefined) return 1;
|
|
||||||
if (b === undefined) return -1;
|
|
||||||
return a < b ? -1 : 1;
|
|
||||||
}
|
|
||||||
return left.id < right.id ? -1 : left.id > right.id ? 1 : 0;
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { Message } from "./api";
|
import type { Message } from "./api";
|
||||||
import { agentAttachments, attachmentCaption, parseAgentAttachment, type AgentAttachment } from "./agent-attachments.ts";
|
import { agentAttachments, attachmentCaption, parseAgentAttachment, type AgentAttachment } from "./agent-attachments.ts";
|
||||||
import { compareAgentMessages, normalizeAgentMessage } from "./agent-messages.ts";
|
|
||||||
|
|
||||||
export const AGENT_OUTBOX_STORAGE_KEY = "sybil.agent.optimistic-outbox.v1";
|
export const AGENT_OUTBOX_STORAGE_KEY = "sybil.agent.optimistic-outbox.v1";
|
||||||
export type AgentDeliveryStatus = "sending" | "queued" | "complete" | "failed" | "unconfirmed";
|
export type AgentDeliveryStatus = "sending" | "queued" | "complete" | "failed" | "unconfirmed";
|
||||||
@@ -166,7 +165,7 @@ export function createAgentOptimisticOutbox(options: {
|
|||||||
metadata: { kind: "message", status: row.status, channel: "sybil", requestId: row.requestId, ...(row.attachments?.length ? { agentAttachments: row.attachments } : {}), ...(row.error ? { error: row.error } : {}) },
|
metadata: { kind: "message", status: row.status, channel: "sybil", requestId: row.requestId, ...(row.attachments?.length ? { agentAttachments: row.attachments } : {}), ...(row.error ? { error: row.error } : {}) },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return [...displayed.values()].map(normalizeAgentMessage).sort(compareAgentMessages);
|
return [...displayed.values()].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
||||||
},
|
},
|
||||||
snapshot(): AgentOutboxRow[] { return [...rows.values()].map(row => ({ ...row })); },
|
snapshot(): AgentOutboxRow[] { return [...rows.values()].map(row => ({ ...row })); },
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { getApiBaseUrl, getAuthToken } from "./api";
|
import { getApiBaseUrl, getAuthToken } from "./api";
|
||||||
import { ensureServiceWorker } from "../pwa";
|
import { ensureServiceWorker } from "../pwa";
|
||||||
import { browserPushOperation } from "./agent-push-errors.ts";
|
|
||||||
import {
|
import {
|
||||||
AGENT_PUSH_STORAGE_KEY, AgentPushClient, agentPushAuthorityScope, agentPushCursor, agentPushSourceId,
|
AGENT_PUSH_STORAGE_KEY, AgentPushClient, agentPushAuthorityScope, agentPushCursor, agentPushSourceId,
|
||||||
createAgentPresenceReporter, createAgentPushController, parseAgentPushConsent, type AgentPushView, type PushSubscriptionHandle,
|
createAgentPresenceReporter, createAgentPushController, parseAgentPushConsent, type AgentPushView, type PushSubscriptionHandle,
|
||||||
@@ -25,12 +24,17 @@ async function consentScope() {
|
|||||||
return digest;
|
return digest;
|
||||||
}
|
}
|
||||||
const listeners = new Set<(state: AgentPushView) => void>();
|
const listeners = new Set<(state: AgentPushView) => void>();
|
||||||
|
function bounded<T>(promise: Promise<T>, timeoutMs = 10000): Promise<T> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => reject(new Error("Browser notification operation timed out.")), timeoutMs);
|
||||||
|
promise.then(value => { clearTimeout(timeout); resolve(value); }, () => { clearTimeout(timeout); reject(new Error("Browser notification operation failed.")); });
|
||||||
|
});
|
||||||
|
}
|
||||||
const subscriptionHandle = (subscription: PushSubscription): PushSubscriptionHandle => ({
|
const subscriptionHandle = (subscription: PushSubscription): PushSubscriptionHandle => ({
|
||||||
toJSON: () => subscription.toJSON(), unsubscribe: () => browserPushOperation(() => subscription.unsubscribe(), "subscription"),
|
toJSON: () => subscription.toJSON(), unsubscribe: () => bounded(subscription.unsubscribe()),
|
||||||
});
|
});
|
||||||
const readConsent = () => { try { return parseAgentPushConsent(localStorage.getItem(AGENT_PUSH_STORAGE_KEY)); } catch { return null; } };
|
const readConsent = () => { try { return parseAgentPushConsent(localStorage.getItem(AGENT_PUSH_STORAGE_KEY)); } catch { return null; } };
|
||||||
const support = () => {
|
const support = () => {
|
||||||
if (!window.isSecureContext) return "insecure" as const;
|
|
||||||
const ios = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
|
const ios = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
|
||||||
const standalone = window.matchMedia("(display-mode: standalone)").matches || window.matchMedia("(display-mode: fullscreen)").matches
|
const standalone = window.matchMedia("(display-mode: standalone)").matches || window.matchMedia("(display-mode: fullscreen)").matches
|
||||||
|| (navigator as Navigator & { standalone?: boolean }).standalone === true;
|
|| (navigator as Navigator & { standalone?: boolean }).standalone === true;
|
||||||
@@ -75,14 +79,11 @@ const controller = createAgentPushController({
|
|||||||
requestPermission: () => Notification.requestPermission(),
|
requestPermission: () => Notification.requestPermission(),
|
||||||
subscription: async () => {
|
subscription: async () => {
|
||||||
if (!("serviceWorker" in navigator)) return null;
|
if (!("serviceWorker" in navigator)) return null;
|
||||||
const registration = await browserPushOperation(() => navigator.serviceWorker.getRegistration("/"), "worker");
|
const registration = await bounded(navigator.serviceWorker.getRegistration("/"));
|
||||||
const subscription = registration ? await browserPushOperation(() => registration.pushManager.getSubscription(), "subscription") : null;
|
const subscription = registration ? await bounded(registration.pushManager.getSubscription()) : null;
|
||||||
return subscription ? subscriptionHandle(subscription) : null;
|
return subscription ? subscriptionHandle(subscription) : null;
|
||||||
},
|
},
|
||||||
subscribe: async key => {
|
subscribe: async key => subscriptionHandle(await bounded((await ensureServiceWorker()).pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: decodePublicKey(key) }))),
|
||||||
const registration = await browserPushOperation(ensureServiceWorker, "worker", 20000);
|
|
||||||
return subscriptionHandle(await browserPushOperation(() => registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: decodePublicKey(key) }), "subscription"));
|
|
||||||
},
|
|
||||||
clearNotifications: () => postWorker({ type: "sybil-agent-logout", version: 1 }),
|
clearNotifications: () => postWorker({ type: "sybil-agent-logout", version: 1 }),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -90,7 +91,7 @@ export const getAgentPushState = () => controller.state();
|
|||||||
export function observeAgentPush(listener: (state: AgentPushView) => void) { listeners.add(listener); listener(controller.state()); return () => { listeners.delete(listener); }; }
|
export function observeAgentPush(listener: (state: AgentPushView) => void) { listeners.add(listener); listener(controller.state()); return () => { listeners.delete(listener); }; }
|
||||||
export const enableAgentBrowserPush = () => controller.enable();
|
export const enableAgentBrowserPush = () => controller.enable();
|
||||||
export const disableAgentBrowserPush = () => controller.disable();
|
export const disableAgentBrowserPush = () => controller.disable();
|
||||||
export const refreshAgentBrowserPush = () => controller.refresh(true);
|
export const refreshAgentBrowserPush = () => controller.refresh();
|
||||||
|
|
||||||
const presence = createAgentPresenceReporter({
|
const presence = createAgentPresenceReporter({
|
||||||
snapshot: () => ({ visibility: !document.hidden && document.visibilityState === "visible" ? "visible" : "hidden", focused: document.hasFocus() }),
|
snapshot: () => ({ visibility: !document.hidden && document.visibilityState === "visible" ? "visible" : "hidden", focused: document.hasFocus() }),
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
/** Only messages constructed here may reach the UI; provider bodies can contain secrets. */
|
|
||||||
export class AgentPushError extends Error {}
|
|
||||||
|
|
||||||
export type AgentPushStage = "permission" | "worker" | "subscription" | "storage" | "lock" | "service";
|
|
||||||
|
|
||||||
export function agentPushFailure(cause: unknown, stage: AgentPushStage): AgentPushError {
|
|
||||||
if (cause instanceof AgentPushError) return cause;
|
|
||||||
const name = cause instanceof Error || (typeof DOMException !== "undefined" && cause instanceof DOMException) ? cause.name : "";
|
|
||||||
if (stage === "storage" || name === "QuotaExceededError") return new AgentPushError("Browser storage is unavailable. Allow site storage and free some space, then try again.");
|
|
||||||
if (stage === "worker") return new AgentPushError("The notification worker could not start. Reload Sybil and try again. If this continues, check that this browser allows service workers and site storage.");
|
|
||||||
if (stage === "lock") return new AgentPushError("Another Sybil tab is still updating notifications. Close other Sybil tabs, then try again.");
|
|
||||||
if ((stage === "permission" || stage === "subscription") && ["NotAllowedError", "SecurityError"].includes(name)) {
|
|
||||||
return new AgentPushError("The browser did not allow notifications. Allow notifications for Sybil in site settings and for the browser in system settings, then try again.");
|
|
||||||
}
|
|
||||||
if (stage === "permission") return new AgentPushError("The browser could not request notification permission. Check browser site settings and system settings, then reload Sybil and try again.");
|
|
||||||
if (stage === "subscription") {
|
|
||||||
if (name === "InvalidStateError") return new AgentPushError("The browser has an incompatible notification subscription. Turn off notifications for Sybil in site settings, reload, then enable them again.");
|
|
||||||
if (["InvalidAccessError", "DataError", "TypeError"].includes(name)) return new AgentPushError("The browser rejected the server's notification key. Ask the server administrator to check Web Push configuration.");
|
|
||||||
if (name === "NotSupportedError") return new AgentPushError("This browser exposes notification APIs but cannot create a push subscription. Update it or use a browser with a working Web Push service. See Android / GrapheneOS help below.");
|
|
||||||
const detail = ["AbortError", "NetworkError", "TimeoutError"].includes(name) ? ` (${name})` : "";
|
|
||||||
return new AgentPushError(`The browser could not connect to its push service${detail}. Check your connection and browser push support, then try again. On Vanadium, see Android / GrapheneOS help below.`);
|
|
||||||
}
|
|
||||||
return new AgentPushError("The Sybil notification service could not be reached. Check your connection and try again.");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function agentPushHttpFailure(status: number): AgentPushError {
|
|
||||||
if (status === 401 || status === 403) return new AgentPushError("Your sign-in is no longer authorized. Sign out and sign in again to manage notifications.");
|
|
||||||
if (status === 400 || status === 413) return new AgentPushError("The server rejected the notification subscription. Refresh Sybil and retry; if it continues, ask the server administrator to check Web Push configuration.");
|
|
||||||
if (status === 409) return new AgentPushError("This notification registration has expired. Refresh and enable notifications again to create a new registration.");
|
|
||||||
if (status === 429) return new AgentPushError("The notification service is busy. Wait a moment, then try again.");
|
|
||||||
if (status === 503) return new AgentPushError("Web Push is unavailable on the Sybil server. Ask the server administrator to check notification configuration and service health.");
|
|
||||||
return new AgentPushError("The Sybil server could not save notification settings. Try again; if it continues, ask the server administrator to check service health.");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Keep DOMException names for diagnosis, but never expose their arbitrary messages. */
|
|
||||||
export function browserPushOperation<T>(operation: () => Promise<T>, stage: AgentPushStage, timeoutMs = 10000): Promise<T> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const timeout = setTimeout(() => reject(agentPushFailure(new DOMException("", "TimeoutError"), stage)), timeoutMs);
|
|
||||||
const fail = (cause: unknown) => { clearTimeout(timeout); reject(agentPushFailure(cause, stage)); };
|
|
||||||
try { operation().then(value => { clearTimeout(timeout); resolve(value); }, fail); }
|
|
||||||
catch (cause) { fail(cause); }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
+24
-43
@@ -1,5 +1,3 @@
|
|||||||
import { AgentPushError, agentPushFailure, agentPushHttpFailure, type AgentPushStage } from "./agent-push-errors.ts";
|
|
||||||
|
|
||||||
/** Browser-only push contracts. Secrets stay in page request headers, never worker messages. */
|
/** Browser-only push contracts. Secrets stay in page request headers, never worker messages. */
|
||||||
export const AGENT_PUSH_STORAGE_KEY = "sybil.agent.push.v1";
|
export const AGENT_PUSH_STORAGE_KEY = "sybil.agent.push.v1";
|
||||||
export const AGENT_PUSH_CHANGED_EVENT = "sybil-agent-push-changed";
|
export const AGENT_PUSH_CHANGED_EVENT = "sybil-agent-push-changed";
|
||||||
@@ -20,9 +18,8 @@ export function parseAgentPushConfig(value: unknown): AgentPushConfig {
|
|||||||
|| (config.quarantined !== undefined && typeof config.quarantined !== "boolean") || !policy
|
|| (config.quarantined !== undefined && typeof config.quarantined !== "boolean") || !policy
|
||||||
|| typeof policy.presenceHeartbeatMs !== "number" || policy.presenceHeartbeatMs < 5000 || policy.presenceHeartbeatMs > 30000
|
|| typeof policy.presenceHeartbeatMs !== "number" || policy.presenceHeartbeatMs < 5000 || policy.presenceHeartbeatMs > 30000
|
||||||
|| typeof policy.presenceLeaseMs !== "number" || policy.presenceLeaseMs < 30000 || policy.presenceLeaseMs > 120000
|
|| typeof policy.presenceLeaseMs !== "number" || policy.presenceLeaseMs < 30000 || policy.presenceLeaseMs > 120000
|
||||||
|| (config.webPushAvailable && config.vapidPublicKey === undefined)
|
|
||||||
|| (config.vapidPublicKey !== undefined && (typeof config.vapidPublicKey !== "string" || !/^[A-Za-z0-9_-]{87}$/.test(config.vapidPublicKey)))) {
|
|| (config.vapidPublicKey !== undefined && (typeof config.vapidPublicKey !== "string" || !/^[A-Za-z0-9_-]{87}$/.test(config.vapidPublicKey)))) {
|
||||||
throw new AgentPushError("The server returned invalid notification configuration. Refresh Sybil; if it continues, ask the server administrator to check Web Push configuration.");
|
throw new Error("Notification configuration is unavailable.");
|
||||||
}
|
}
|
||||||
return { ...config, quarantined: config.quarantined === true } as AgentPushConfig;
|
return { ...config, quarantined: config.quarantined === true } as AgentPushConfig;
|
||||||
}
|
}
|
||||||
@@ -39,28 +36,28 @@ export class AgentPushClient {
|
|||||||
constructor(options: { baseUrl: string; token: () => string | null; fetch?: typeof globalThis.fetch }) { this.options = options; }
|
constructor(options: { baseUrl: string; token: () => string | null; fetch?: typeof globalThis.fetch }) { this.options = options; }
|
||||||
private async request(path: string, method = "GET", body?: unknown, keepalive = false, expectedScope?: string): Promise<unknown> {
|
private async request(path: string, method = "GET", body?: unknown, keepalive = false, expectedScope?: string): Promise<unknown> {
|
||||||
const token = this.options.token();
|
const token = this.options.token();
|
||||||
if (!token) throw new AgentPushError("Sign in to manage notifications.");
|
if (!token) throw new Error("Sign in to manage notifications.");
|
||||||
const controller = new AbortController(), timeout = setTimeout(() => controller.abort(), 8000);
|
const controller = new AbortController(), timeout = setTimeout(() => controller.abort(), 8000);
|
||||||
try {
|
try {
|
||||||
// Capture the bearer before hashing, then use that exact bearer for the
|
// Capture the bearer before hashing, then use that exact bearer for the
|
||||||
// request. A concurrent sign-in must not turn old-authority cleanup into
|
// request. A concurrent sign-in must not turn old-authority cleanup into
|
||||||
// a new-authority DELETE whose204 falsely clears a durable tombstone.
|
// a new-authority DELETE whose204 falsely clears a durable tombstone.
|
||||||
if (expectedScope !== undefined && expectedScope !== await agentPushAuthorityScope(this.options.baseUrl, token)) throw new AgentPushError("The signed-in server changed. Refresh and enable notifications again.");
|
if (expectedScope !== undefined && expectedScope !== await agentPushAuthorityScope(this.options.baseUrl, token)) throw new Error("Notification authority changed.");
|
||||||
const response = await (this.options.fetch || globalThis.fetch)(`${this.options.baseUrl.replace(/\/+$/, "")}/v1/agent${path}`, {
|
const response = await (this.options.fetch || globalThis.fetch)(`${this.options.baseUrl.replace(/\/+$/, "")}/v1/agent${path}`, {
|
||||||
method, headers: { Authorization: `Bearer ${token}`, ...(body === undefined ? {} : { "Content-Type": "application/json" }) },
|
method, headers: { Authorization: `Bearer ${token}`, ...(body === undefined ? {} : { "Content-Type": "application/json" }) },
|
||||||
...(body === undefined ? {} : { body: JSON.stringify(body) }), cache: "no-store", redirect: "error", credentials: "omit", signal: controller.signal, keepalive,
|
...(body === undefined ? {} : { body: JSON.stringify(body) }), cache: "no-store", redirect: "error", credentials: "omit", signal: controller.signal, keepalive,
|
||||||
});
|
});
|
||||||
// Never echo server/provider bodies: they may contain endpoint or key material.
|
// Never echo server/provider bodies: they may contain endpoint or key material.
|
||||||
if (!response.ok) throw agentPushHttpFailure(response.status);
|
if (!response.ok) throw new Error(response.status === 401 ? "Sign in to manage notifications." : "Notification settings could not be saved. Try again.");
|
||||||
return response.status === 204 ? undefined : await response.json();
|
return response.status === 204 ? undefined : await response.json();
|
||||||
} catch (cause) { throw agentPushFailure(cause, "service"); }
|
} catch { throw new Error("Notification service is unavailable. Try again when connected."); }
|
||||||
finally { clearTimeout(timeout); }
|
finally { clearTimeout(timeout); }
|
||||||
}
|
}
|
||||||
async config() { return parseAgentPushConfig(await this.request("/push/config")); }
|
async config() { return parseAgentPushConfig(await this.request("/push/config")); }
|
||||||
async register(id: string, subscription: PushSubscriptionJSON, expectedScope?: string) {
|
async register(id: string, subscription: PushSubscriptionJSON, expectedScope?: string) {
|
||||||
if (!agentPushUuid(id)) throw new Error("Invalid notification installation.");
|
if (!agentPushUuid(id)) throw new Error("Invalid notification installation.");
|
||||||
const value = object(await this.request(`/push/installations/${id}`, "PUT", { transport: "web", enabled: true, subscription }, false, expectedScope));
|
const value = object(await this.request(`/push/installations/${id}`, "PUT", { transport: "web", enabled: true, subscription }, false, expectedScope));
|
||||||
if (!value || value.installationId !== id || value.transport !== "web" || value.enabled !== true || !Number.isSafeInteger(value.version) || Number(value.version) < 1) throw new AgentPushError("The Sybil server did not acknowledge the notification registration. Refresh and try again.");
|
if (!value || value.installationId !== id || value.transport !== "web" || value.enabled !== true || !Number.isSafeInteger(value.version) || Number(value.version) < 1) throw new Error("Notification registration was not acknowledged.");
|
||||||
}
|
}
|
||||||
async revoke(id: string, expectedScope?: string) {
|
async revoke(id: string, expectedScope?: string) {
|
||||||
if (!agentPushUuid(id)) throw new Error("Invalid notification installation.");
|
if (!agentPushUuid(id)) throw new Error("Invalid notification installation.");
|
||||||
@@ -97,7 +94,7 @@ export function parseAgentPushConsent(raw: string | null): AgentPushConsent | nu
|
|||||||
export type AgentPushView = { busy: boolean; enabled: boolean; canEnable: boolean; message: string; error: string | null };
|
export type AgentPushView = { busy: boolean; enabled: boolean; canEnable: boolean; message: string; error: string | null };
|
||||||
export type PushSubscriptionHandle = Pick<PushSubscription, "toJSON" | "unsubscribe">;
|
export type PushSubscriptionHandle = Pick<PushSubscription, "toJSON" | "unsubscribe">;
|
||||||
type PushPlatform = {
|
type PushPlatform = {
|
||||||
support: () => "supported" | "unsupported" | "install" | "insecure";
|
support: () => "supported" | "unsupported" | "install";
|
||||||
permission: () => NotificationPermission;
|
permission: () => NotificationPermission;
|
||||||
requestPermission: () => Promise<NotificationPermission>;
|
requestPermission: () => Promise<NotificationPermission>;
|
||||||
subscription: () => Promise<PushSubscriptionHandle | null>;
|
subscription: () => Promise<PushSubscriptionHandle | null>;
|
||||||
@@ -112,16 +109,15 @@ export function createAgentPushController(options: {
|
|||||||
uuid: () => string; lock: <T>(operation: () => Promise<T>) => Promise<T>; changed: (view: AgentPushView) => void;
|
uuid: () => string; lock: <T>(operation: () => Promise<T>) => Promise<T>; changed: (view: AgentPushView) => void;
|
||||||
}) {
|
}) {
|
||||||
let config: AgentPushConfig | null = null, busy = false, error: string | null = null, localRevocation: AgentPushConsent | null = null;
|
let config: AgentPushConfig | null = null, busy = false, error: string | null = null, localRevocation: AgentPushConsent | null = null;
|
||||||
let refreshError: string | null = null;
|
|
||||||
let scope: string | null = null;
|
let scope: string | null = null;
|
||||||
let registeredSubscription: string | null = null;
|
let registeredSubscription: string | null = null;
|
||||||
const currentConsent = () => localRevocation || options.read();
|
const currentConsent = () => localRevocation || options.read();
|
||||||
const matchesContext = (record: AgentPushConsent) => !!scope && record.scope === scope
|
const matchesContext = (record: AgentPushConsent) => !!scope && record.scope === scope
|
||||||
&& agentPushSourceId(record.sourceId) && record.sourceId === config?.sourceId;
|
&& agentPushSourceId(record.sourceId) && record.sourceId === config?.sourceId;
|
||||||
const transportAvailable = () => !!config?.enabled && config.webPushAvailable && !!config.vapidPublicKey && !config.quarantined && agentPushSourceId(config.sourceId);
|
const transportAvailable = () => !!config?.enabled && config.webPushAvailable && !config.quarantined && agentPushSourceId(config.sourceId);
|
||||||
const refreshContext = async () => {
|
const refreshContext = async () => {
|
||||||
const nextScope = await options.scope(), nextConfig = await options.api.config();
|
const nextScope = await options.scope(), nextConfig = await options.api.config();
|
||||||
if (nextScope !== await options.scope()) { scope = null; config = null; throw new AgentPushError("The signed-in server changed. Refresh and enable notifications again."); }
|
if (nextScope !== await options.scope()) { scope = null; config = null; throw new Error("Notification authority changed."); }
|
||||||
scope = nextScope; config = nextConfig;
|
scope = nextScope; config = nextConfig;
|
||||||
};
|
};
|
||||||
const recheckContext = async (record: AgentPushConsent) => {
|
const recheckContext = async (record: AgentPushConsent) => {
|
||||||
@@ -134,17 +130,14 @@ export function createAgentPushController(options: {
|
|||||||
const cleanupFull = (consent?.pendingRevocations?.length || 0) >= MAX_AGENT_PUSH_REVOCATIONS;
|
const cleanupFull = (consent?.pendingRevocations?.length || 0) >= MAX_AGENT_PUSH_REVOCATIONS;
|
||||||
const enabled = consent?.enabled === true && !consent.revocationPending && permission === "granted";
|
const enabled = consent?.enabled === true && !consent.revocationPending && permission === "granted";
|
||||||
const message = support === "install" ? "On iPhone or iPad, add Sybil to your Home Screen, then enable notifications there."
|
const message = support === "install" ? "On iPhone or iPad, add Sybil to your Home Screen, then enable notifications there."
|
||||||
: support === "insecure" ? "Notifications require a secure connection. Open Sybil over HTTPS, then try again."
|
|
||||||
: support === "unsupported" ? "This browser does not support notifications here. Use a supported browser over HTTPS."
|
: support === "unsupported" ? "This browser does not support notifications here. Use a supported browser over HTTPS."
|
||||||
: permission === "denied" ? "Notifications are blocked. Allow Sybil in browser site settings and allow the browser's notifications in system settings, then refresh."
|
: permission === "denied" ? "Notifications are blocked. Allow them in browser settings, then try again."
|
||||||
: cleanupFull ? "Notification cleanup is full. Sign in to a previous server to finish removing old registrations before enabling more."
|
: cleanupFull ? "Notification cleanup is full. Sign in to a previous server to finish removing old registrations before enabling more."
|
||||||
: consent?.revocationPending ? "Notifications are off here. Server removal will retry when connected."
|
: consent?.revocationPending ? "Notifications are off here. Server removal will retry when connected."
|
||||||
: config?.quarantined ? "Notification delivery is paused after an Agent source change. Ask the server administrator to reconcile the source before enabling notifications."
|
|
||||||
: config?.enabled && config.webPushAvailable && !agentPushSourceId(config.sourceId) ? "The Agent notification source is not ready yet. Refresh in a moment; if it continues, ask the server administrator to check the Agent connection."
|
|
||||||
: !available ? "Notifications are not configured on this server yet."
|
: !available ? "Notifications are not configured on this server yet."
|
||||||
: enabled ? "Notifications are on for this browser."
|
: enabled ? "Notifications are on for this browser."
|
||||||
: "Get an alert for unread Agent replies when all your web sessions are inactive.";
|
: "Get an alert for unread Agent replies when all your web sessions are inactive.";
|
||||||
return { busy, enabled, canEnable: !busy && available && support === "supported" && permission !== "denied" && !consent?.revocationPending && !cleanupFull, message, error: error || refreshError };
|
return { busy, enabled, canEnable: !busy && available && support === "supported" && permission !== "denied" && !consent?.revocationPending && !cleanupFull, message, error };
|
||||||
};
|
};
|
||||||
const publish = () => options.changed(state());
|
const publish = () => options.changed(state());
|
||||||
const write = (consent: AgentPushConsent) => { options.write(consent); publish(); };
|
const write = (consent: AgentPushConsent) => { options.write(consent); publish(); };
|
||||||
@@ -202,7 +195,7 @@ export function createAgentPushController(options: {
|
|||||||
await revoke(pending);
|
await revoke(pending);
|
||||||
};
|
};
|
||||||
const disable = async () => {
|
const disable = async () => {
|
||||||
error = null; refreshError = null;
|
error = null;
|
||||||
const record = { ...consent(), enabled: false, revocationPending: true, revision: options.uuid() };
|
const record = { ...consent(), enabled: false, revocationPending: true, revision: options.uuid() };
|
||||||
// Synchronous durable intent is necessary before logout clears page credentials.
|
// Synchronous durable intent is necessary before logout clears page credentials.
|
||||||
registeredSubscription = null;
|
registeredSubscription = null;
|
||||||
@@ -223,9 +216,8 @@ export function createAgentPushController(options: {
|
|||||||
}
|
}
|
||||||
finally { busy = false; publish(); }
|
finally { busy = false; publish(); }
|
||||||
};
|
};
|
||||||
const refresh = async (clearError = false) => {
|
const refresh = async () => {
|
||||||
if (busy) return;
|
if (busy) return;
|
||||||
if (clearError) error = null;
|
|
||||||
try {
|
try {
|
||||||
await refreshContext();
|
await refreshContext();
|
||||||
await options.lock(async () => {
|
await options.lock(async () => {
|
||||||
@@ -254,18 +246,18 @@ export function createAgentPushController(options: {
|
|||||||
const stillMatching = await recheckContext(record);
|
const stillMatching = await recheckContext(record);
|
||||||
if (currentConsent()?.revision !== record.revision) { await retireAtOriginalAuthority(record); return; }
|
if (currentConsent()?.revision !== record.revision) { await retireAtOriginalAuthority(record); return; }
|
||||||
if (!stillMatching) { await invalidate(record); return; }
|
if (!stillMatching) { await invalidate(record); return; }
|
||||||
} catch (cause) { await invalidate(record); throw cause; }
|
} catch { await invalidate(record); throw new Error("Notification service is unavailable."); }
|
||||||
registeredSubscription = serialized;
|
registeredSubscription = serialized;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
refreshError = null;
|
error = null;
|
||||||
} catch (cause) { refreshError = agentPushFailure(cause, "service").message; }
|
} catch { error = "Notification service is unavailable. Try again when connected."; }
|
||||||
publish();
|
publish();
|
||||||
};
|
};
|
||||||
const enable = async () => {
|
const enable = async () => {
|
||||||
if (!state().canEnable || state().enabled || busy || !config?.vapidPublicKey || !scope || !agentPushSourceId(config.sourceId)) return;
|
if (!state().canEnable || state().enabled || busy || !config?.vapidPublicKey || !scope || !agentPushSourceId(config.sourceId)) return;
|
||||||
error = null; refreshError = null;
|
error = null;
|
||||||
// Explicit revocation permanently retires the old UUID on the server. Each
|
// Explicit revocation permanently retires the old UUID on the server. Each
|
||||||
// new opt-in has a new identity, so a delayed old PUT cannot resurrect it.
|
// new opt-in has a new identity, so a delayed old PUT cannot resurrect it.
|
||||||
const record: AgentPushConsent = { version: 1, installationId: options.uuid(), revision: options.uuid(), scope, sourceId: config.sourceId,
|
const record: AgentPushConsent = { version: 1, installationId: options.uuid(), revision: options.uuid(), scope, sourceId: config.sourceId,
|
||||||
@@ -275,46 +267,35 @@ export function createAgentPushController(options: {
|
|||||||
// Must execute in the original click stack, before locks, worker startup or fetches.
|
// Must execute in the original click stack, before locks, worker startup or fetches.
|
||||||
let permission: Promise<NotificationPermission>;
|
let permission: Promise<NotificationPermission>;
|
||||||
try { permission = options.platform.requestPermission(); }
|
try { permission = options.platform.requestPermission(); }
|
||||||
catch (cause) { error = agentPushFailure(cause, "permission").message; publish(); return; }
|
catch { error = "Notifications could not be requested. Check browser settings and try again."; publish(); return; }
|
||||||
busy = true; publish();
|
busy = true; publish();
|
||||||
let stage: AgentPushStage = "permission";
|
|
||||||
try {
|
try {
|
||||||
const decision = await permission;
|
if (await permission !== "granted") { error = "Notifications were not allowed. You can change this in browser settings."; return; }
|
||||||
if (decision !== "granted") { error = decision === "denied"
|
|
||||||
? "Notifications were not allowed. Allow Sybil in browser site settings and allow the browser's notifications in system settings, then refresh."
|
|
||||||
: "The notification prompt was dismissed. Tap Enable notifications again and choose Allow."; return; }
|
|
||||||
stage = "lock";
|
|
||||||
await options.lock(async () => {
|
await options.lock(async () => {
|
||||||
stage = "service";
|
|
||||||
const unchanged = () => options.read()?.revision === record.revision;
|
const unchanged = () => options.read()?.revision === record.revision;
|
||||||
if (!unchanged()) return;
|
if (!unchanged()) return;
|
||||||
const matching = await recheckContext(record);
|
const matching = await recheckContext(record);
|
||||||
if (!unchanged()) return;
|
if (!unchanged()) return;
|
||||||
if (!matching || config?.vapidPublicKey !== publicKey) { await invalidate(record); return; }
|
if (!matching || config?.vapidPublicKey !== publicKey) { await invalidate(record); return; }
|
||||||
stage = "subscription";
|
|
||||||
const subscription = await options.platform.subscription() || await options.platform.subscribe(publicKey);
|
const subscription = await options.platform.subscription() || await options.platform.subscribe(publicKey);
|
||||||
if (!unchanged()) { await subscription.unsubscribe(); return; }
|
if (!unchanged()) { await subscription.unsubscribe(); return; }
|
||||||
// A tombstone also covers a lost registration response or failed local storage write.
|
// A tombstone also covers a lost registration response or failed local storage write.
|
||||||
const pending = { ...record, enabled: false, revocationPending: true };
|
const pending = { ...record, enabled: false, revocationPending: true }; write(pending);
|
||||||
try {
|
try {
|
||||||
stage = "storage"; write(pending);
|
|
||||||
stage = "service";
|
|
||||||
const matchingBeforeRegister = await recheckContext(record);
|
const matchingBeforeRegister = await recheckContext(record);
|
||||||
if (!unchanged() || !matchingBeforeRegister || config?.vapidPublicKey !== publicKey) { await revoke(pending); return; }
|
if (!unchanged() || !matchingBeforeRegister || config?.vapidPublicKey !== publicKey) { await revoke(pending); return; }
|
||||||
await options.api.register(record.installationId, subscription.toJSON(), record.scope);
|
await options.api.register(record.installationId, subscription.toJSON(), record.scope);
|
||||||
if (!unchanged()) { await retireAtOriginalAuthority(record); await subscription.unsubscribe(); return; }
|
if (!unchanged()) { await retireAtOriginalAuthority(record); await subscription.unsubscribe(); return; }
|
||||||
const matchingAfterRegister = await recheckContext(record);
|
const matchingAfterRegister = await recheckContext(record);
|
||||||
if (!unchanged() || !matchingAfterRegister || config?.vapidPublicKey !== publicKey) { await revoke(pending); return; }
|
if (!unchanged() || !matchingAfterRegister || config?.vapidPublicKey !== publicKey) { await revoke(pending); return; }
|
||||||
stage = "storage"; write({ ...record, enabled: true, revocationPending: false });
|
write({ ...record, enabled: true, revocationPending: false });
|
||||||
registeredSubscription = JSON.stringify(subscription.toJSON());
|
registeredSubscription = JSON.stringify(subscription.toJSON());
|
||||||
} catch (cause) {
|
} catch {
|
||||||
// Retain cleanup intent even when storage fails after subscribing.
|
|
||||||
if (options.read()?.revision === record.revision) localRevocation = pending;
|
|
||||||
await revoke(pending);
|
await revoke(pending);
|
||||||
throw agentPushFailure(cause, stage);
|
throw new Error("Notifications could not be enabled. Try again when connected.");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (cause) { error = agentPushFailure(cause, stage).message; }
|
} catch { error = "Notifications could not be enabled. Try again when connected."; }
|
||||||
finally { busy = false; publish(); }
|
finally { busy = false; publish(); }
|
||||||
};
|
};
|
||||||
return { state, refresh, enable, disable };
|
return { state, refresh, enable, disable };
|
||||||
|
|||||||
+23
-37
@@ -1,7 +1,6 @@
|
|||||||
import type { Message } from "./api";
|
import type { Message } from "./api";
|
||||||
import { parseAgentAttachment, type AgentAttachment, type AgentAttachmentCapability } from "./agent-attachments.ts";
|
import { parseAgentAttachment, type AgentAttachment, type AgentAttachmentCapability } from "./agent-attachments.ts";
|
||||||
import { agentPushCursor, agentPushSourceId, agentPushUuid } from "./agent-push.ts";
|
import { agentPushCursor, agentPushSourceId, agentPushUuid } from "./agent-push.ts";
|
||||||
import { agentMessageTime, compareAgentMessages, normalizeAgentMessage, UNKNOWN_AGENT_TIME } from "./agent-messages.ts";
|
|
||||||
|
|
||||||
export type AgentActivity = {
|
export type AgentActivity = {
|
||||||
state: "idle" | "thinking" | "tool" | "waiting" | "error";
|
state: "idle" | "thinking" | "tool" | "waiting" | "error";
|
||||||
@@ -57,9 +56,7 @@ function mergeMessage(previous: Message, incoming: Message): Message {
|
|||||||
const prior = metadata(previous), next = metadata(incoming);
|
const prior = metadata(previous), next = metadata(incoming);
|
||||||
// A delayed activity snapshot must not demote an already placed user message.
|
// A delayed activity snapshot must not demote an already placed user message.
|
||||||
if (prior?.status === "complete" && next?.status === "queued") return previous;
|
if (prior?.status === "complete" && next?.status === "queued") return previous;
|
||||||
const priorTime = agentMessageTime(previous), nextTime = agentMessageTime(incoming);
|
if (prior?.kind !== "tool_call" || next?.kind !== "tool_call") return { ...previous, ...incoming };
|
||||||
if (prior?.kind !== "tool_call" || next?.kind !== "tool_call") return { ...previous, ...incoming,
|
|
||||||
createdAt: nextTime ?? priorTime ?? UNKNOWN_AGENT_TIME };
|
|
||||||
const terminal = prior.status === "completed" || prior.status === "failed";
|
const terminal = prior.status === "completed" || prior.status === "failed";
|
||||||
const staleStart = terminal && next.status === "initiated";
|
const staleStart = terminal && next.status === "initiated";
|
||||||
const merged = staleStart ? { ...defined(next), ...defined(prior) } : { ...defined(prior), ...defined(next) };
|
const merged = staleStart ? { ...defined(next), ...defined(prior) } : { ...defined(prior), ...defined(next) };
|
||||||
@@ -67,32 +64,23 @@ function mergeMessage(previous: Message, incoming: Message): Message {
|
|||||||
const oldArgs = object(prior.args), newArgs = object(next.args);
|
const oldArgs = object(prior.args), newArgs = object(next.args);
|
||||||
if (oldArgs && (!newArgs || Object.keys(newArgs).length === 0)) merged.args = oldArgs;
|
if (oldArgs && (!newArgs || Object.keys(newArgs).length === 0)) merged.args = oldArgs;
|
||||||
return { ...previous, ...incoming, ...(staleStart ? { content: previous.content, name: previous.name } : {}),
|
return { ...previous, ...incoming, ...(staleStart ? { content: previous.content, name: previous.name } : {}),
|
||||||
createdAt: priorTime && nextTime ? (priorTime < nextTime ? priorTime : nextTime) : priorTime ?? nextTime ?? UNKNOWN_AGENT_TIME, metadata: merged };
|
createdAt: previous.createdAt < incoming.createdAt ? previous.createdAt : incoming.createdAt, metadata: merged };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Replays and separate tool-start/result pages update stable rows instead of duplicating them. */
|
/** Replays and separate tool-start/result pages update stable rows instead of duplicating them. */
|
||||||
export function mergeAgentMessages(previous: readonly Message[], incoming: readonly Message[]): Message[] {
|
export function mergeAgentMessages(previous: readonly Message[], incoming: readonly Message[]): Message[] {
|
||||||
const messages = new Map(previous.map(normalizeAgentMessage).map(message => [message.id, message]));
|
const messages = new Map(previous.map(message => [message.id, message]));
|
||||||
for (const message of incoming.map(normalizeAgentMessage)) {
|
for (const message of incoming) {
|
||||||
const prior = messages.get(message.id);
|
const prior = messages.get(message.id);
|
||||||
messages.set(message.id, prior ? mergeMessage(prior, message) : message);
|
messages.set(message.id, prior ? mergeMessage(prior, message) : message);
|
||||||
}
|
}
|
||||||
return [...messages.values()].sort(compareAgentMessages);
|
return [...messages.values()].sort((a, b) => {
|
||||||
}
|
const byTime = a.createdAt.localeCompare(b.createdAt);
|
||||||
|
if (byTime) return byTime;
|
||||||
function parseAgentPage(value: unknown): AgentMessagesPage {
|
const left = messageCursor(a), right = messageCursor(b);
|
||||||
const page = object(value);
|
if (left !== null && right !== null && left !== right) return BigInt(left) < BigInt(right) ? -1 : 1;
|
||||||
if (!page || typeof page.conversationId !== "string" || !Array.isArray(page.messages) || typeof page.hasMore !== "boolean") throw new Error("Invalid agent messages frame.");
|
return a.id.localeCompare(b.id);
|
||||||
if (page.cursor !== null) normalizeAgentCursor(page.cursor);
|
});
|
||||||
if (page.beforeCursor != null) normalizeAgentCursor(page.beforeCursor);
|
|
||||||
return { ...page, beforeCursor: page.beforeCursor ?? null, messages: page.messages.map(normalizeAgentMessage) } as AgentMessagesPage;
|
|
||||||
}
|
|
||||||
function parseAgentActivity(value: unknown): AgentActivity {
|
|
||||||
const activity = object(value);
|
|
||||||
if (!activity || (activity.tools !== undefined && !Array.isArray(activity.tools))
|
|
||||||
|| (activity.queuedMessages !== undefined && !Array.isArray(activity.queuedMessages))) throw new Error("Invalid agent activity frame.");
|
|
||||||
return { ...activity, tools: activity.tools ?? [],
|
|
||||||
queuedMessages: ((activity.queuedMessages ?? []) as unknown[]).map(normalizeAgentMessage) } as AgentActivity;
|
|
||||||
}
|
}
|
||||||
export const createAgentRoomState = (): AgentRoomState => ({
|
export const createAgentRoomState = (): AgentRoomState => ({
|
||||||
conversationId: null, messages: [], cursor: "0", readCursor: "0", activity: null,
|
conversationId: null, messages: [], cursor: "0", readCursor: "0", activity: null,
|
||||||
@@ -109,7 +97,6 @@ function readSource(state: Pick<AgentRoomState, "sourceId" | "cursor" | "readCur
|
|||||||
return incoming;
|
return incoming;
|
||||||
}
|
}
|
||||||
export function applyAgentPage(state: AgentRoomState, page: AgentMessagesPage): AgentRoomState {
|
export function applyAgentPage(state: AgentRoomState, page: AgentMessagesPage): AgentRoomState {
|
||||||
page = parseAgentPage(page);
|
|
||||||
if (state.conversationId !== null && state.conversationId !== page.conversationId) throw new Error("Agent conversation changed; reload the page.");
|
if (state.conversationId !== null && state.conversationId !== page.conversationId) throw new Error("Agent conversation changed; reload the page.");
|
||||||
// Validate before changing state. Failed delivery/decoding cannot advance replay position.
|
// Validate before changing state. Failed delivery/decoding cannot advance replay position.
|
||||||
const cursor = page.cursor === null ? state.cursor : advanceAgentCursor(state.cursor, page.cursor);
|
const cursor = page.cursor === null ? state.cursor : advanceAgentCursor(state.cursor, page.cursor);
|
||||||
@@ -124,7 +111,7 @@ export function applyAgentEvent(state: AgentRoomState, event: AgentRoomEvent): A
|
|||||||
if (event.type === "read_state") return applyAgentReadState(state, event);
|
if (event.type === "read_state") return applyAgentReadState(state, event);
|
||||||
if (event.type !== "activity") return state;
|
if (event.type !== "activity") return state;
|
||||||
if (state.conversationId !== null && state.conversationId !== event.conversationId) throw new Error("Agent activity belongs to a different conversation.");
|
if (state.conversationId !== null && state.conversationId !== event.conversationId) throw new Error("Agent activity belongs to a different conversation.");
|
||||||
return { ...state, conversationId: event.conversationId, activity: parseAgentActivity(event.activity) };
|
return { ...state, conversationId: event.conversationId, activity: event.activity };
|
||||||
}
|
}
|
||||||
export function agentUnreadCount(state: AgentRoomState): number {
|
export function agentUnreadCount(state: AgentRoomState): number {
|
||||||
return state.messages.filter(message => {
|
return state.messages.filter(message => {
|
||||||
@@ -145,12 +132,15 @@ export function parseAgentRoomEvent(raw: string): AgentRoomEvent | null {
|
|||||||
|| (event.updatedAt !== null && typeof event.updatedAt !== "string")
|
|| (event.updatedAt !== null && typeof event.updatedAt !== "string")
|
||||||
|| (event.sourceId !== undefined && event.sourceId !== null && !agentPushSourceId(event.sourceId)))) throw new Error("Invalid Agent read frame.");
|
|| (event.sourceId !== undefined && event.sourceId !== null && !agentPushSourceId(event.sourceId)))) throw new Error("Invalid Agent read frame.");
|
||||||
if (event.type === "messages") {
|
if (event.type === "messages") {
|
||||||
return { ...parseAgentPage(event), type: "messages" };
|
if (typeof event.conversationId !== "string" || !Array.isArray(event.messages) || typeof event.hasMore !== "boolean") throw new Error("Invalid agent messages frame.");
|
||||||
}
|
if (event.cursor !== null) normalizeAgentCursor(event.cursor);
|
||||||
if (event.type === "activity") {
|
for (const item of event.messages) {
|
||||||
if (typeof event.conversationId !== "string") throw new Error("Invalid agent activity frame.");
|
const message = object(item);
|
||||||
return { ...event, activity: parseAgentActivity(event.activity) } as AgentRoomEvent;
|
if (!message || typeof message.id !== "string" || typeof message.content !== "string" || typeof message.createdAt !== "string"
|
||||||
|
|| !["system", "user", "assistant", "tool"].includes(String(message.role))) throw new Error("Invalid agent message.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
if (event.type === "activity" && (typeof event.conversationId !== "string" || !object(event.activity))) throw new Error("Invalid agent activity frame.");
|
||||||
return event as AgentRoomEvent;
|
return event as AgentRoomEvent;
|
||||||
}
|
}
|
||||||
export function agentAuthFrame(token: string | null, cursor: string, presenceSessionId?: string): string {
|
export function agentAuthFrame(token: string | null, cursor: string, presenceSessionId?: string): string {
|
||||||
@@ -184,13 +174,9 @@ export class AgentRoomClient {
|
|||||||
}
|
}
|
||||||
return value as T;
|
return value as T;
|
||||||
}
|
}
|
||||||
async descriptor(signal?: AbortSignal): Promise<AgentDescriptor> {
|
descriptor(signal?: AbortSignal): Promise<AgentDescriptor> { return this.request("", { signal }); }
|
||||||
const value = object(await this.request("", { signal }));
|
messages(after: string, signal?: AbortSignal): Promise<AgentMessagesPage> {
|
||||||
if (!value || typeof value.id !== "string") throw new Error("Invalid agent descriptor.");
|
return this.request(`/messages?${new URLSearchParams({ after: normalizeAgentCursor(after), limit: "200" })}`, { signal });
|
||||||
return { ...value, activity: parseAgentActivity(value.activity) } as AgentDescriptor;
|
|
||||||
}
|
|
||||||
async messages(after: string, signal?: AbortSignal): Promise<AgentMessagesPage> {
|
|
||||||
return parseAgentPage(await this.request(`/messages?${new URLSearchParams({ after: normalizeAgentCursor(after), limit: "200" })}`, { signal }));
|
|
||||||
}
|
}
|
||||||
readState(signal?: AbortSignal): Promise<AgentReadState> { return this.request("/read-state", { signal }); }
|
readState(signal?: AbortSignal): Promise<AgentReadState> { return this.request("/read-state", { signal }); }
|
||||||
markRead(cursor: string, signal?: AbortSignal, sourceId?: string | null): Promise<AgentReadState> {
|
markRead(cursor: string, signal?: AbortSignal, sourceId?: string | null): Promise<AgentReadState> {
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
/** Text, controls and nested scrollers own their gestures, even at a scroll edge. */
|
|
||||||
export function ownsMobileGesture(target: EventTarget | null, workspace: HTMLElement): boolean {
|
|
||||||
if (!(target instanceof Element)) return true;
|
|
||||||
if (target.closest("input, textarea, select, button, a, summary, [contenteditable]:not([contenteditable='false']), [role='button'], [role='dialog'], [data-mobile-swipe-ignore], .md-content, .message-bubble, .agent-room__plain-message, pre, code, table, blockquote")) return true;
|
|
||||||
for (let element: Element | null = target; element && element !== workspace; element = element.parentElement) {
|
|
||||||
if (element.scrollWidth > element.clientWidth + 1 && /auto|scroll/.test(getComputedStyle(element).overflowX)) return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hasTextSelection(): boolean {
|
|
||||||
const selection = window.getSelection();
|
|
||||||
return !!selection && !selection.isCollapsed;
|
|
||||||
}
|
|
||||||
@@ -7,15 +7,6 @@ const canonical = (id, content = "Hello", time = "2026-10-14T12:01:00.000Z") =>
|
|||||||
metadata: { kind: "message", entryId: 123, status: "complete", channel: "sybil" },
|
metadata: { kind: "message", entryId: 123, status: "complete", channel: "sybil" },
|
||||||
});
|
});
|
||||||
const deferred = () => { let resolve, reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); return { promise, resolve, reject }; };
|
const deferred = () => { let resolve, reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); return { promise, resolve, reject }; };
|
||||||
|
|
||||||
test("display sorts partial server rows and tied entry cursors consistently", () => {
|
|
||||||
const box = createAgentOptimisticOutbox({ submit: async () => assert.fail("no send"),
|
|
||||||
storage: { read: () => [], write: () => {} }, changed: () => {} });
|
|
||||||
const rows = [canonical("z"), { id: "tool:1:partial", role: "tool", name: "owner_phone_context" },
|
|
||||||
{ ...canonical("a"), metadata: { entryId: 124 } }];
|
|
||||||
assert.deepEqual(box.display(rows).map(row => row.id), ["tool:1:partial", "z", "a"]);
|
|
||||||
assert.deepEqual(box.display([...rows].reverse()), box.display(rows));
|
|
||||||
});
|
|
||||||
function fixture(submit, legacy = {}) {
|
function fixture(submit, legacy = {}) {
|
||||||
let stored = null;
|
let stored = null;
|
||||||
let serial = 0;
|
let serial = 0;
|
||||||
|
|||||||
@@ -1,140 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
|
|
||||||
// Production bundle/worker, mobile Chromium; all API and push-provider operations are fixtures.
|
|
||||||
// Use the same PLAYWRIGHT_MODULE, CHROME_EXECUTABLE and WEB_TEST_URL as agent-push.browser.mjs.
|
|
||||||
const { chromium } = await import(process.env.PLAYWRIGHT_MODULE || "playwright-core");
|
|
||||||
const target = new URL(process.env.WEB_TEST_URL || "http://127.0.0.1:5197/");
|
|
||||||
assert.ok(target.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(target.hostname));
|
|
||||||
const browser = await chromium.launch({ executablePath: process.env.CHROME_EXECUTABLE || undefined, headless: true,
|
|
||||||
...(process.env.BROWSER_NO_SANDBOX === "1" ? { args: ["--no-sandbox"] } : {}) });
|
|
||||||
const context = await browser.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true });
|
|
||||||
const sourceId = "4599d348-e053-4937-987d-1392746fb2ce";
|
|
||||||
const config = { version: 1, enabled: true, webPushAvailable: true, apnsAvailable: false, sourceId, quarantined: false,
|
|
||||||
vapidPublicKey: "B".repeat(87), policy: { presenceHeartbeatMs: 15000, presenceLeaseMs: 60000 } };
|
|
||||||
let registerStatus = 200, configStatus = 200, puts = 0;
|
|
||||||
const errors = [];
|
|
||||||
await context.addInitScript(() => {
|
|
||||||
localStorage.setItem("sybil_admin_token", "mobile-notification-fixture");
|
|
||||||
window.__permission = "default"; window.__decision = "default"; window.__subscribed = false; window.__subscribes = 0;
|
|
||||||
window.__subscribeError = null; window.__workerError = false; window.__subscribeHang = false; window.__permissionGesture = false;
|
|
||||||
Object.defineProperty(Notification, "permission", { configurable: true, get: () => window.__permission });
|
|
||||||
Notification.requestPermission = () => {
|
|
||||||
window.__permissionGesture = navigator.userActivation.isActive;
|
|
||||||
window.__permission = window.__decision; return Promise.resolve(window.__permission);
|
|
||||||
};
|
|
||||||
const register = navigator.serviceWorker.register.bind(navigator.serviceWorker);
|
|
||||||
navigator.serviceWorker.register = (...args) => window.__workerError ? Promise.reject(new Error("private-worker-detail")) : register(...args);
|
|
||||||
const subscription = { toJSON: () => ({ endpoint: "https://fcm.googleapis.com/fcm/send/private-endpoint", keys: { p256dh: "B".repeat(87), auth: "A".repeat(22) } }),
|
|
||||||
unsubscribe: async () => { window.__subscribed = false; return true; } };
|
|
||||||
PushManager.prototype.getSubscription = async () => window.__subscribed ? subscription : null;
|
|
||||||
PushManager.prototype.subscribe = async () => {
|
|
||||||
window.__subscribes++;
|
|
||||||
if (window.__subscribeError) throw new DOMException("private-provider-key", window.__subscribeError);
|
|
||||||
if (window.__subscribeHang) return await new Promise(() => {});
|
|
||||||
window.__subscribed = true; return subscription;
|
|
||||||
};
|
|
||||||
});
|
|
||||||
await context.route("**/*", async route => {
|
|
||||||
const request = route.request(), url = new URL(request.url()), path = url.pathname;
|
|
||||||
if (url.origin !== target.origin) { await route.abort(); return; }
|
|
||||||
if (!path.startsWith("/api/")) { await route.continue(); return; }
|
|
||||||
let value = {}, status = 200;
|
|
||||||
if (path.endsWith("/push/config")) { value = config; status = configStatus; }
|
|
||||||
else if (path.includes("/push/installations/")) {
|
|
||||||
if (request.method() === "DELETE") { await route.fulfill({ status: 204 }); return; }
|
|
||||||
puts++; status = registerStatus;
|
|
||||||
value = status === 200 ? { installationId: path.split("/").at(-1), transport: "web", enabled: true, version: 1 } : { error: "private-server-token" };
|
|
||||||
} else if (path.endsWith("/auth/session")) value = { mode: "token", authenticated: true };
|
|
||||||
else if (path.endsWith("/workspace-items")) value = { items: [] };
|
|
||||||
else if (path.endsWith("/models")) value = { providers: {} };
|
|
||||||
else if (path.endsWith("/chat-tools")) value = { tools: [] };
|
|
||||||
else if (path.endsWith("/active-runs")) value = { chats: [], searches: [] };
|
|
||||||
else if (path.endsWith("/read-state")) value = { sourceId, cursor: "0", updatedAt: null };
|
|
||||||
await route.fulfill({ status, json: value });
|
|
||||||
});
|
|
||||||
await context.routeWebSocket("**/api/v1/agent/ws", socket => socket.close());
|
|
||||||
const page = await context.newPage(); page.on("pageerror", error => errors.push(error.message));
|
|
||||||
const enable = page.getByRole("button", { name: "Enable notifications", exact: true });
|
|
||||||
const refresh = page.getByRole("button", { name: "Refresh", exact: true });
|
|
||||||
const alert = page.locator(".workspace-sidebar [role=alert]");
|
|
||||||
const waitEnable = () => page.waitForFunction(() => Array.from(document.querySelectorAll("button")).some(el => el.textContent === "Enable notifications" && !el.disabled));
|
|
||||||
async function attempt(pattern) {
|
|
||||||
await refresh.click(); await waitEnable(); await enable.click();
|
|
||||||
await page.waitForFunction(pattern => {
|
|
||||||
const el = document.querySelector(".workspace-sidebar [role=alert]");
|
|
||||||
return el && new RegExp(pattern).test(el.textContent);
|
|
||||||
}, pattern.source);
|
|
||||||
assert.match(await alert.textContent(), pattern);
|
|
||||||
assert.equal(await page.evaluate(() => window.__permissionGesture), true);
|
|
||||||
assert.equal(await page.getByRole("button", { name: "Turn off notifications", exact: true }).count(), 0);
|
|
||||||
assert.doesNotMatch(await page.locator(".workspace-sidebar").textContent(), /private-worker-detail|private-provider-key|private-server-token|private-endpoint/);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await page.goto(target.href, { waitUntil: "networkidle" });
|
|
||||||
await page.getByRole("button", { name: "Open sidebar", exact: true }).click();
|
|
||||||
await page.getByText("Agent notifications", { exact: true }).click();
|
|
||||||
await attempt(/prompt was dismissed/); assert.equal(puts, 0);
|
|
||||||
await page.evaluate(() => { window.__decision = "denied"; });
|
|
||||||
await attempt(/not allowed/); assert.equal(puts, 0); assert.ok(await enable.isDisabled());
|
|
||||||
await page.evaluate(() => { window.__permission = "default"; window.__decision = "granted"; window.__workerError = true; });
|
|
||||||
await attempt(/worker could not start/); assert.equal(puts, 0);
|
|
||||||
await page.evaluate(() => { window.__workerError = false; });
|
|
||||||
for (const [name, pattern] of [["AbortError", /push service \(AbortError\)/], ["NotAllowedError", /site settings.*system settings/],
|
|
||||||
["NotSupportedError", /cannot create a push subscription/], ["InvalidStateError", /incompatible notification subscription/]]) {
|
|
||||||
await page.evaluate(name => { window.__subscribeError = name; }, name);
|
|
||||||
await attempt(pattern); assert.equal(puts, 0);
|
|
||||||
// Successful foreground config refresh must not dismiss an opt-in diagnosis.
|
|
||||||
await page.evaluate(() => window.dispatchEvent(new Event("online")));
|
|
||||||
await page.waitForTimeout(150);
|
|
||||||
assert.match(await alert.textContent(), pattern);
|
|
||||||
}
|
|
||||||
await page.evaluate(() => { window.__subscribeError = null; window.__subscribeHang = true; });
|
|
||||||
await page.clock.install();
|
|
||||||
await refresh.click(); await waitEnable();
|
|
||||||
const before = await page.evaluate(() => window.__subscribes);
|
|
||||||
await enable.click(); await page.waitForFunction(before => window.__subscribes > before, before);
|
|
||||||
await page.clock.fastForward(10001);
|
|
||||||
await alert.filter({ hasText: "TimeoutError" }).waitFor(); assert.equal(puts, 0);
|
|
||||||
await page.clock.resume();
|
|
||||||
await page.evaluate(() => { window.__subscribeHang = false; });
|
|
||||||
for (const [status, pattern] of [[401, /sign-in.*authorized/], [403, /sign-in.*authorized/], [400, /rejected.*subscription/],
|
|
||||||
[409, /expired/], [429, /busy/], [503, /unavailable on the Sybil server/]]) {
|
|
||||||
registerStatus = status; await attempt(pattern);
|
|
||||||
assert.equal(await page.evaluate(() => window.__subscribed), false, "failed registration rolls back the subscription");
|
|
||||||
}
|
|
||||||
registerStatus = 200; configStatus = 503;
|
|
||||||
await refresh.click(); await alert.filter({ hasText: "unavailable on the Sybil server" }).waitFor();
|
|
||||||
configStatus = 200;
|
|
||||||
await refresh.click(); await waitEnable(); await enable.click();
|
|
||||||
await page.getByRole("button", { name: "Turn off notifications", exact: true }).waitFor();
|
|
||||||
assert.equal(await alert.count(), 0);
|
|
||||||
await page.getByText("Android / GrapheneOS help", { exact: true }).click();
|
|
||||||
await page.getByRole("link", { name: "GrapheneOS setup guide" }).waitFor();
|
|
||||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false);
|
|
||||||
if (process.env.PUSH_ERROR_SCREENSHOT) await page.screenshot({ path: process.env.PUSH_ERROR_SCREENSHOT });
|
|
||||||
for (const viewport of [{ width: 320, height: 568 }, { width: 390, height: 450 }]) {
|
|
||||||
await page.setViewportSize(viewport);
|
|
||||||
const signOut = page.getByRole("button", { name: "Sign out", exact: true });
|
|
||||||
await signOut.scrollIntoViewIfNeeded();
|
|
||||||
assert.ok(await signOut.evaluate(el => {
|
|
||||||
const box = el.getBoundingClientRect(); return box.top >= 0 && box.bottom <= innerHeight;
|
|
||||||
}), "notification controls remain reachable with long help and a short viewport");
|
|
||||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false);
|
|
||||||
}
|
|
||||||
await page.setViewportSize({ width: 390, height: 844 });
|
|
||||||
await page.getByRole("button", { name: "Turn off notifications", exact: true }).click(); await waitEnable();
|
|
||||||
await page.evaluate(() => { delete window.PushManager; });
|
|
||||||
await refresh.click(); await page.getByText(/This browser does not support notifications here/).waitFor();
|
|
||||||
assert.ok(await enable.isDisabled());
|
|
||||||
await page.evaluate(() => {
|
|
||||||
Object.defineProperty(window, "isSecureContext", { configurable: true, value: true });
|
|
||||||
Object.defineProperty(navigator, "userAgent", { configurable: true, value: "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)" });
|
|
||||||
});
|
|
||||||
await refresh.click(); await page.getByText(/On iPhone or iPad, add Sybil to your Home Screen/).waitFor();
|
|
||||||
assert.ok(await enable.isDisabled());
|
|
||||||
await page.evaluate(() => { Object.defineProperty(window, "isSecureContext", { configurable: true, value: false }); });
|
|
||||||
await refresh.click(); await page.getByText(/Notifications require a secure connection/).waitFor();
|
|
||||||
assert.ok(await enable.isDisabled());
|
|
||||||
assert.deepEqual(errors, []);
|
|
||||||
console.log("PASS mobile permission dismissal/denial, worker/subscription failures, timeout, HTTP statuses, rollback, retained diagnostics, recovery, unsupported/insecure browsers and GrapheneOS guidance");
|
|
||||||
} finally { await context.close(); await browser.close(); }
|
|
||||||
@@ -75,7 +75,7 @@ const expectEventually = async predicate => { for (let i = 0; i < 100; i++) { if
|
|||||||
try {
|
try {
|
||||||
await page.goto(target.href, { waitUntil: "networkidle" });
|
await page.goto(target.href, { waitUntil: "networkidle" });
|
||||||
await page.getByText("Agent notifications", { exact: true }).click();
|
await page.getByText("Agent notifications", { exact: true }).click();
|
||||||
await page.getByText(/The Agent notification source is not ready yet/).waitFor();
|
await page.getByText("Notifications are not configured on this server yet.", { exact: true }).waitFor();
|
||||||
assert.equal(await page.getByRole("button", { name: "Enable notifications", exact: true }).isDisabled(), true, "unknown source cannot opt in");
|
assert.equal(await page.getByRole("button", { name: "Enable notifications", exact: true }).isDisabled(), true, "unknown source cannot opt in");
|
||||||
assert.deepEqual(await page.evaluate(() => window.__pushCalls), []);
|
assert.deepEqual(await page.evaluate(() => window.__pushCalls), []);
|
||||||
config.sourceId = sourceId;
|
config.sourceId = sourceId;
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
MAX_AGENT_PUSH_REVOCATIONS, parseAgentPushConfig, parseAgentPushConsent,
|
MAX_AGENT_PUSH_REVOCATIONS, parseAgentPushConfig, parseAgentPushConsent,
|
||||||
} from "../src/lib/agent-push.ts";
|
} from "../src/lib/agent-push.ts";
|
||||||
import { agentAuthFrame, applyAgentEvent, createAgentRoomState, parseAgentRoomEvent } from "../src/lib/agent-room.ts";
|
import { agentAuthFrame, applyAgentEvent, createAgentRoomState, parseAgentRoomEvent } from "../src/lib/agent-room.ts";
|
||||||
import { AgentPushError, browserPushOperation } from "../src/lib/agent-push-errors.ts";
|
|
||||||
|
|
||||||
const sourceId = "4599d348-e053-4937-987d-1392746fb2ce";
|
const sourceId = "4599d348-e053-4937-987d-1392746fb2ce";
|
||||||
const replacementSourceId = "22222222-2222-4222-8222-222222222222";
|
const replacementSourceId = "22222222-2222-4222-8222-222222222222";
|
||||||
@@ -121,72 +120,7 @@ test("a stuck cross-tab lock cannot prevent server retirement or local logout cl
|
|||||||
test("synchronous permission API failures remain a recoverable opt-in error", async () => {
|
test("synchronous permission API failures remain a recoverable opt-in error", async () => {
|
||||||
const f = fixture({ platform: { requestPermission: () => { throw new Error("user gesture rejected"); } } });
|
const f = fixture({ platform: { requestPermission: () => { throw new Error("user gesture rejected"); } } });
|
||||||
await f.controller.refresh(); await f.controller.enable();
|
await f.controller.refresh(); await f.controller.enable();
|
||||||
assert.equal(f.record().enabled, false); assert.match(f.controller.state().error, /browser.*system settings/);
|
assert.equal(f.record().enabled, false); assert.match(f.controller.state().error, /could not be requested/);
|
||||||
});
|
|
||||||
|
|
||||||
test("browser subscription failures retain safe diagnoses across background refreshes", async () => {
|
|
||||||
for (const [name, message] of [["AbortError", /push service \(AbortError\)/], ["NotAllowedError", /site settings.*system settings/],
|
|
||||||
["NotSupportedError", /cannot create a push subscription/], ["InvalidStateError", /incompatible notification subscription/], ["InvalidAccessError", /notification key/]]) {
|
|
||||||
const f = fixture({ platform: { subscribe: () => browserPushOperation(() => Promise.reject(new DOMException("private-endpoint-and-key", name)), "subscription") } });
|
|
||||||
await f.controller.refresh(); await f.controller.enable();
|
|
||||||
assert.match(f.controller.state().error, message);
|
|
||||||
assert.equal(f.controller.state().enabled, false);
|
|
||||||
assert.ok(!f.calls.some(([name]) => name === "register"));
|
|
||||||
await f.controller.refresh();
|
|
||||||
assert.match(f.controller.state().error, message, "heartbeat cannot erase a failed opt-in");
|
|
||||||
assert.doesNotMatch(JSON.stringify(f.states), /private-endpoint-and-key/);
|
|
||||||
await f.controller.refresh(true);
|
|
||||||
assert.equal(f.controller.state().error, null, "explicit refresh clears the previous attempt");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("worker startup and subscription timeouts have distinct actionable errors", async () => {
|
|
||||||
for (const [stage, message] of [["worker", /worker could not start/], ["subscription", /push service \(TimeoutError\)/]]) {
|
|
||||||
const f = fixture({ platform: { subscribe: () => browserPushOperation(() => new Promise(() => {}), stage, 5) } });
|
|
||||||
await f.controller.refresh(); await f.controller.enable();
|
|
||||||
assert.match(f.controller.state().error, message); assert.equal(f.controller.state().busy, false);
|
|
||||||
assert.equal(f.controller.state().enabled, false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("dismissed prompts and insecure contexts do not attempt a subscription", async () => {
|
|
||||||
const f = fixture({ platform: { requestPermission: async () => "default" } });
|
|
||||||
await f.controller.refresh(); await f.controller.enable();
|
|
||||||
assert.match(f.controller.state().error, /dismissed/); assert.equal(f.calls.length, 0);
|
|
||||||
f.setSupport("insecure");
|
|
||||||
assert.equal(f.controller.state().canEnable, false); assert.match(f.controller.state().message, /HTTPS/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("notification API errors retain status-specific guidance through rollback without echoing bodies", async () => {
|
|
||||||
for (const [status, message] of [[401, /sign-in.*authorized/], [403, /sign-in.*authorized/], [400, /rejected.*subscription/],
|
|
||||||
[409, /expired/], [429, /busy/], [503, /unavailable on the Sybil server/], [500, /server.*save notification settings/]]) {
|
|
||||||
const api = new AgentPushClient({ baseUrl: "https://sybil.example", token: () => "private-auth-token",
|
|
||||||
fetch: async () => new Response(JSON.stringify({ error: "private-endpoint-and-key" }), { status }) });
|
|
||||||
const f = fixture({ api: { register: (id, subscription) => api.register(id, subscription) } });
|
|
||||||
await f.controller.refresh(); await f.controller.enable();
|
|
||||||
assert.match(f.controller.state().error, message); assert.equal(f.controller.state().enabled, false);
|
|
||||||
assert.ok(f.calls.some(([name]) => name === "unsubscribe"));
|
|
||||||
assert.doesNotMatch(JSON.stringify(f.states), /private-auth-token|private-endpoint-and-key/);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("failed storage after subscribing still unsubscribes and retains cleanup intent", async () => {
|
|
||||||
const f = fixture(); let writes = 0;
|
|
||||||
const write = f.options.write;
|
|
||||||
f.options.write = value => { if (++writes > 1) throw new DOMException("private-storage-detail", "QuotaExceededError"); write(value); };
|
|
||||||
await f.controller.refresh(); await f.controller.enable();
|
|
||||||
assert.match(f.controller.state().error, /storage.*free some space/);
|
|
||||||
assert.equal(f.controller.state().enabled, false);
|
|
||||||
assert.ok(f.calls.some(([name]) => name === "unsubscribe"));
|
|
||||||
assert.ok(f.calls.some(([name]) => name === "revoke"));
|
|
||||||
assert.ok(!f.calls.some(([name]) => name === "register"));
|
|
||||||
assert.doesNotMatch(JSON.stringify(f.states), /private-storage-detail/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("server errors during refresh are distinct from unavailable browser push", async () => {
|
|
||||||
const f = fixture({ api: { config: async () => { throw new AgentPushError("Web Push is unavailable on the Sybil server."); } } });
|
|
||||||
await f.controller.refresh();
|
|
||||||
assert.match(f.controller.state().error, /Sybil server/); assert.equal(f.controller.state().canEnable, false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("subscription rotation upserts the same installation; permission revocation removes it", async () => {
|
test("subscription rotation upserts the same installation; permission revocation removes it", async () => {
|
||||||
@@ -484,7 +418,6 @@ test("read frames validate source/cursor and preserve existing read semantics",
|
|||||||
assert.throws(() => parseAgentRoomEvent(JSON.stringify({ type: "read_state", cursor: "1", updatedAt: null, sourceId: "https://evil.test" })));
|
assert.throws(() => parseAgentRoomEvent(JSON.stringify({ type: "read_state", cursor: "1", updatedAt: null, sourceId: "https://evil.test" })));
|
||||||
assert.equal(parseAgentPushConsent('{"enabled":true}'), null);
|
assert.equal(parseAgentPushConsent('{"enabled":true}'), null);
|
||||||
assert.throws(() => parseAgentPushConfig({ ...configuration(), policy: { presenceHeartbeatMs: 1, presenceLeaseMs: 60000 } }));
|
assert.throws(() => parseAgentPushConfig({ ...configuration(), policy: { presenceHeartbeatMs: 1, presenceLeaseMs: 60000 } }));
|
||||||
assert.throws(() => parseAgentPushConfig({ ...configuration(), vapidPublicKey: undefined }), /invalid notification configuration/);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("API keeps credentials in headers, validates receipts, handles 204 and redacts provider bodies", async () => {
|
test("API keeps credentials in headers, validates receipts, handles 204 and redacts provider bodies", async () => {
|
||||||
|
|||||||
@@ -20,88 +20,6 @@ const sourceA = "4599d348-e053-4937-987d-1392746fb2ce", sourceB = "22222222-2222
|
|||||||
const deferred = () => { let resolve; const promise = new Promise(yes => { resolve = yes; }); return { promise, resolve }; };
|
const deferred = () => { let resolve; const promise = new Promise(yes => { resolve = yes; }); return { promise, resolve }; };
|
||||||
const sourceRoom = () => applyAgentPage(applyAgentReadState(createAgentRoomState(), { sourceId: sourceA, cursor: "90", updatedAt: null }), page("100", [message(100)]));
|
const sourceRoom = () => applyAgentPage(applyAgentReadState(createAgentRoomState(), { sourceId: sourceA, cursor: "90", updatedAt: null }), page("100", [message(100)]));
|
||||||
|
|
||||||
test("redacted historical tool rows survive REST loading, socket replay and outbox rendering", async () => {
|
|
||||||
const redacted = { id: "tool:1:protected", role: "tool", name: "owner_phone_context" };
|
|
||||||
const input = page("3", [message(1), redacted, message(3)]);
|
|
||||||
const client = new AgentRoomClient({ fetch: async () => Response.json(input) });
|
|
||||||
let state = createAgentRoomState();
|
|
||||||
await syncAgentHistory(client, "0", update => { state = applyAgentPage(state, update); });
|
|
||||||
const initial = state.messages;
|
|
||||||
const row = initial.find(row => row.id === redacted.id);
|
|
||||||
assert.equal(row.createdAt, "1970-01-01T00:00:00.000Z", "unknown times are stable, never fabricated as now");
|
|
||||||
assert.equal(row.content, "owner_phone_context");
|
|
||||||
assert.equal(state.cursor, "3");
|
|
||||||
const event = parseAgentRoomEvent(JSON.stringify({ type: "messages", ...input }));
|
|
||||||
state = applyAgentEvent(state, event);
|
|
||||||
assert.deepEqual(state.messages, initial, "replaying partial history is idempotent");
|
|
||||||
const box = createAgentOptimisticOutbox({ submit: async () => assert.fail("no send"),
|
|
||||||
storage: { read: () => [], write: () => {} }, changed: () => {} });
|
|
||||||
assert.deepEqual(box.display(agentDisplayMessages(state)), initial);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("partial tool updates retain known time, terminal metadata and chronological ordering", () => {
|
|
||||||
const id = "tool:1:partial";
|
|
||||||
const start = message(2, "tool", { id, metadata: { kind: "tool_call", entryId: 2, status: "initiated", startedAt: "2026-10-14T10:00:02.000Z" } });
|
|
||||||
const done = message(3, "tool", { id, createdAt: undefined, metadata: { kind: "tool_call", entryId: 3, status: "completed", completedAt: "2026-10-14T10:00:03.000Z" } });
|
|
||||||
const partial = { id, role: "tool", name: "owner_phone_context" };
|
|
||||||
const rows = mergeAgentMessages([message(1), start], [done, partial, start, message(4)]);
|
|
||||||
assert.deepEqual(rows.map(row => row.id), ["entry:1", id, "entry:4"]);
|
|
||||||
assert.equal(rows[1].createdAt, start.createdAt);
|
|
||||||
assert.equal(rows[1].metadata.status, "completed");
|
|
||||||
assert.equal(rows[1].metadata.completedAt, done.metadata.completedAt);
|
|
||||||
assert.equal(mergeAgentMessages([], [done])[0].createdAt, done.metadata.completedAt);
|
|
||||||
assert.equal(mergeAgentMessages([partial], [start])[0].createdAt, start.createdAt);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("an unknown historical tool start never masks a known completion timestamp", () => {
|
|
||||||
const unknown = "1970-01-01T00:00:00.000Z", known = "2026-10-14T10:00:03.000Z";
|
|
||||||
const start = message(2, "tool", { id: "tool:1:historical", createdAt: unknown,
|
|
||||||
metadata: { kind: "tool_call", entryId: 2, status: "initiated", startedAt: unknown } });
|
|
||||||
const done = message(3, "tool", { id: start.id, createdAt: known,
|
|
||||||
metadata: { kind: "tool_call", entryId: 3, status: "completed", completedAt: known } });
|
|
||||||
for (const rows of [[start, done], [done, start], [{ ...start, metadata: { ...start.metadata, ...done.metadata } }]]) {
|
|
||||||
const merged = mergeAgentMessages([], rows);
|
|
||||||
assert.equal(merged[0].createdAt, known);
|
|
||||||
assert.equal(merged[0].metadata.status, "completed");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("missing, null, invalid and tied timestamps have stable ordering independent of arrival order", () => {
|
|
||||||
const rows = [message(11, "assistant", { createdAt: null }), message(2, "assistant", { createdAt: undefined }),
|
|
||||||
message(3, "assistant", { createdAt: "not-a-date" }), message(4, "assistant", { createdAt: 0 }),
|
|
||||||
message(5, "assistant", { createdAt: null, metadata: { kind: "message" } })];
|
|
||||||
const expected = ["entry:2", "entry:3", "entry:4", "entry:11", "entry:5"];
|
|
||||||
assert.deepEqual(mergeAgentMessages([], rows).map(row => row.id), expected);
|
|
||||||
assert.deepEqual(mergeAgentMessages([], [...rows].reverse()).map(row => row.id), expected);
|
|
||||||
const tied = [message(11, "assistant", { createdAt: "2026-10-14T10:00:00Z" }), message(2, "assistant", { createdAt: "2026-10-14T11:00:00+01:00" })];
|
|
||||||
assert.deepEqual(mergeAgentMessages([], tied).map(row => row.id), ["entry:2", "entry:11"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("REST descriptors and socket activity normalize partial queued messages consistently", async () => {
|
|
||||||
const pending = message(1, "user", { id: "inbound:sybil:partial", createdAt: undefined, metadata: { kind: "message", status: "queued" } });
|
|
||||||
const value = { id: "1", activity: activity([pending]) };
|
|
||||||
const client = new AgentRoomClient({ fetch: async () => Response.json(value) });
|
|
||||||
const descriptor = await client.descriptor();
|
|
||||||
const fromRest = applyAgentEvent(createAgentRoomState(), { type: "activity", conversationId: descriptor.id, activity: descriptor.activity });
|
|
||||||
const fromSocket = applyAgentEvent(createAgentRoomState(), parseAgentRoomEvent(JSON.stringify({ type: "activity", conversationId: "1", activity: value.activity })));
|
|
||||||
assert.deepEqual(agentDisplayMessages(fromRest), agentDisplayMessages(fromSocket));
|
|
||||||
assert.equal(agentDisplayMessages(fromRest)[0].createdAt, "1970-01-01T00:00:00.000Z");
|
|
||||||
const malformed = { ...pending, content: undefined };
|
|
||||||
assert.throws(() => applyAgentEvent(fromRest, { type: "activity", conversationId: "1", activity: activity([malformed]) }), /Invalid agent message/);
|
|
||||||
assert.equal(fromRest.cursor, "0");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("invalid message bodies fail at both transport boundaries before advancing replay", async () => {
|
|
||||||
for (const malformed of [null, { role: "assistant", content: "missing ID" }, message(2, "assistant", { content: null }), message(2, ["assistant"])]) {
|
|
||||||
const input = page("3", [message(1), malformed]);
|
|
||||||
const client = new AgentRoomClient({ fetch: async () => Response.json(input) });
|
|
||||||
let applied = false;
|
|
||||||
await assert.rejects(syncAgentHistory(client, "0", () => { applied = true; }), /Invalid agent message/);
|
|
||||||
assert.equal(applied, false);
|
|
||||||
assert.throws(() => parseAgentRoomEvent(JSON.stringify({ type: "messages", ...input })), /Invalid agent message/);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("tool starts and results merge across replay without downgrading completed cards", () => {
|
test("tool starts and results merge across replay without downgrading completed cards", () => {
|
||||||
const start = message(2, "tool", { id: "tool:1:call1", metadata: { kind: "tool_call", entryId: 2, status: "initiated", args: { query: "cookie" }, startedAt: "2026-10-14T10:00:00Z" } });
|
const start = message(2, "tool", { id: "tool:1:call1", metadata: { kind: "tool_call", entryId: 2, status: "initiated", args: { query: "cookie" }, startedAt: "2026-10-14T10:00:00Z" } });
|
||||||
const done = message(3, "tool", { id: start.id, metadata: { kind: "tool_call", entryId: 3, status: "completed", resultPreview: "found" } });
|
const done = message(3, "tool", { id: start.id, metadata: { kind: "tool_call", entryId: 3, status: "completed", resultPreview: "found" } });
|
||||||
@@ -321,7 +239,7 @@ test("client uses Sybil auth and routes while websocket credentials remain in th
|
|||||||
let token = "first-session-token";
|
let token = "first-session-token";
|
||||||
const client = new AgentRoomClient({ baseUrl: "https://sybil.example/api/", token: () => token, fetch: async (url, init) => {
|
const client = new AgentRoomClient({ baseUrl: "https://sybil.example/api/", token: () => token, fetch: async (url, init) => {
|
||||||
calls.push({ url, init }); return new Response(JSON.stringify(init.method === "POST"
|
calls.push({ url, init }); return new Response(JSON.stringify(init.method === "POST"
|
||||||
? { id: 1, status: "queued", messageId: "inbound:sybil:request-1" } : url.includes("/messages?") ? page("0") : { cursor: "0", updatedAt: null }), { status: 200 });
|
? { id: 1, status: "queued", messageId: "inbound:sybil:request-1" } : { cursor: "0", updatedAt: null }), { status: 200 });
|
||||||
} });
|
} });
|
||||||
await client.messages("0");
|
await client.messages("0");
|
||||||
token = "second-session-token";
|
token = "second-session-token";
|
||||||
|
|||||||
@@ -1,226 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import { mkdir } from "node:fs/promises";
|
|
||||||
|
|
||||||
// Run against a local production preview after npm run build. No live API calls.
|
|
||||||
// PLAYWRIGHT_MODULE=/path/playwright-core/index.mjs CHROME_EXECUTABLE=/path/chrome
|
|
||||||
// WEB_TEST_URL=http://127.0.0.1:5197 node tests/mobile-workspace.browser.mjs
|
|
||||||
const { chromium } = await import(process.env.PLAYWRIGHT_MODULE || "playwright-core");
|
|
||||||
const target = new URL(process.env.WEB_TEST_URL || "http://127.0.0.1:5197/");
|
|
||||||
assert.ok(target.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(target.hostname));
|
|
||||||
const baseline = process.env.MOBILE_BASELINE === "1";
|
|
||||||
const artifactDir = process.env.MOBILE_ARTIFACT_DIR;
|
|
||||||
if (artifactDir) await mkdir(artifactDir, { recursive: true });
|
|
||||||
const browser = await chromium.launch({ executablePath: process.env.CHROME_EXECUTABLE || undefined, headless: true,
|
|
||||||
...(process.env.BROWSER_NO_SANDBOX === "1" ? { args: ["--no-sandbox"] } : {}) });
|
|
||||||
const context = await browser.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true,
|
|
||||||
permissions: ["clipboard-read", "clipboard-write"] });
|
|
||||||
const sourceId = "4599d348-e053-4937-987d-1392746fb2ce";
|
|
||||||
const date = "2026-11-02T12:00:00.000Z";
|
|
||||||
const markdown = [
|
|
||||||
"Mobile answer with selectable text. " + "This regular chat response should use the available width. ".repeat(7),
|
|
||||||
"| Name | Description with a very wide column | Another long heading | Final column |\n| --- | --- | --- | --- |\n| Alpha | Wide table content | More content | End |",
|
|
||||||
"> A quotation with selectable text and a nested horizontal code block:\n>\n> ```text\n> " + "quoted-unbroken-code-".repeat(22) + "\n> ```",
|
|
||||||
"```js\nconst longLine = '" + "horizontal-code-".repeat(28) + "';\n```",
|
|
||||||
"A final paragraph with a [native link menu](https://example.test/document).",
|
|
||||||
].join("\n\n");
|
|
||||||
const chat = { id: "mobile-chat", title: "Mobile QA conversation with a long title", parentChatId: null, titleGenerationPending: false,
|
|
||||||
createdAt: date, updatedAt: date, starred: false, starredAt: null, initiatedProvider: "openai", initiatedModel: "mobile-model-one",
|
|
||||||
lastUsedProvider: "openai", lastUsedModel: "mobile-model-one", additionalSystemPrompt: null, enabledTools: [],
|
|
||||||
messages: [{ id: "mobile-assistant", role: "assistant", content: markdown, createdAt: date, name: null, metadata: null }] };
|
|
||||||
const idle = { state: "idle", isThinking: false, queued: 0, backgroundWorkers: 0, tools: [], queuedMessages: [] };
|
|
||||||
const errors = [], calls = [];
|
|
||||||
let readCursor = "0";
|
|
||||||
await context.addInitScript(() => localStorage.setItem("sybil_admin_token", "mobile-fixture-token"));
|
|
||||||
await context.route("**/*", async route => {
|
|
||||||
const url = new URL(route.request().url());
|
|
||||||
if (url.origin !== target.origin) { await route.abort(); return; }
|
|
||||||
if (!url.pathname.startsWith("/api/")) { await route.continue(); return; }
|
|
||||||
const path = url.pathname, method = route.request().method();
|
|
||||||
calls.push({ path, method });
|
|
||||||
let value;
|
|
||||||
if (path.endsWith("/auth/session")) value = { mode: "token", authenticated: true };
|
|
||||||
else if (path.endsWith("/workspace-items")) value = { items: [{ ...chat, type: "chat" }] };
|
|
||||||
else if (path.endsWith("/models")) value = { providers: { openai: { models: ["mobile-model-one", "mobile-model-two"], loadedAt: date, error: null } } };
|
|
||||||
else if (path.endsWith("/chat-tools") || path.endsWith("/agent/tools")) value = { tools: [] };
|
|
||||||
else if (path.endsWith("/active-runs")) value = { chats: [], searches: [] };
|
|
||||||
else if (path.endsWith("/chats/mobile-chat")) {
|
|
||||||
if (method === "PATCH") Object.assign(chat, route.request().postDataJSON());
|
|
||||||
value = { chat };
|
|
||||||
} else if (path.endsWith("/agent/messages")) value = { conversationId: "1", messages: [{ ...chat.messages[0], id: "outbound:1", metadata: { entryId: 1 } }], cursor: "1", beforeCursor: null, hasMore: false };
|
|
||||||
else if (path.endsWith("/agent")) value = { id: "1", title: "Sybil Agent", activity: idle, protocol: "pi-room.v1", websocket: "/v1/agent/ws" };
|
|
||||||
else if (path.endsWith("/read-state")) {
|
|
||||||
if (method === "PUT") readCursor = route.request().postDataJSON().cursor;
|
|
||||||
value = { sourceId, cursor: readCursor, updatedAt: null };
|
|
||||||
}
|
|
||||||
else if (path.endsWith("/push/config")) value = { version: 1, enabled: false, webPushAvailable: false, apnsAvailable: false, sourceId, quarantined: false, policy: { presenceHeartbeatMs: 15000, presenceLeaseMs: 60000 } };
|
|
||||||
else if (path.includes("/presence/")) value = { expiresAt: date };
|
|
||||||
else value = {};
|
|
||||||
await route.fulfill({ json: value });
|
|
||||||
});
|
|
||||||
await context.routeWebSocket("**/api/v1/agent/ws", socket => socket.onMessage(raw => {
|
|
||||||
if (JSON.parse(String(raw)).type === "auth") socket.send(JSON.stringify({ type: "ready", conversationId: "1" }));
|
|
||||||
}));
|
|
||||||
const page = await context.newPage();
|
|
||||||
page.on("pageerror", error => errors.push(error.message));
|
|
||||||
const cdp = await context.newCDPSession(page);
|
|
||||||
async function openSidebar() {
|
|
||||||
const sidebar = page.locator(".workspace-sidebar");
|
|
||||||
if (await sidebar.evaluate(el => el.getBoundingClientRect().right <= 0)) {
|
|
||||||
await page.getByRole("button", { name: "Open sidebar", exact: true }).click();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
async function selectChat() {
|
|
||||||
await openSidebar();
|
|
||||||
await page.getByRole("button", { name: new RegExp(chat.title) }).click();
|
|
||||||
await page.locator("main h1").filter({ hasText: chat.title }).waitFor();
|
|
||||||
await page.locator("main .md-content").waitFor();
|
|
||||||
await page.waitForFunction(() => document.querySelector(".workspace-sidebar").getBoundingClientRect().right <= 1);
|
|
||||||
}
|
|
||||||
async function drag(x, y, dx, dy = 0) {
|
|
||||||
await cdp.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x, y }] });
|
|
||||||
for (let step = 1; step <= 8; step++) {
|
|
||||||
await cdp.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [{ x: x + dx * step / 8, y: y + dy * step / 8 }] });
|
|
||||||
await page.waitForTimeout(25);
|
|
||||||
}
|
|
||||||
await cdp.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
|
|
||||||
await page.waitForTimeout(200);
|
|
||||||
}
|
|
||||||
async function scrollHorizontally(locator) {
|
|
||||||
await locator.scrollIntoViewIfNeeded();
|
|
||||||
await locator.evaluate(el => { el.scrollLeft = 0; });
|
|
||||||
const box = await locator.boundingBox();
|
|
||||||
const x = Math.min(box.x + box.width - 30, 350), y = box.y + Math.min(box.height / 2, 30);
|
|
||||||
await drag(x, y, -130);
|
|
||||||
}
|
|
||||||
async function contextMenuCanceled(locator) {
|
|
||||||
return locator.evaluate(el => !el.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 100, clientY: 200 })));
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await page.goto(target.href, { waitUntil: "networkidle" });
|
|
||||||
await selectChat();
|
|
||||||
const metrics = await page.locator("main .md-content").evaluate(el => ({
|
|
||||||
width: el.getBoundingClientRect().width, parentWidth: el.parentElement.parentElement.getBoundingClientRect().width,
|
|
||||||
touchAction: getComputedStyle(document.querySelector("main")).touchAction,
|
|
||||||
modelVisible: Array.from(document.querySelectorAll("main header p")).some(el => el.textContent.includes("mobile-model-one") && el.getBoundingClientRect().height > 0),
|
|
||||||
}));
|
|
||||||
if (artifactDir) await page.screenshot({ path: `${artifactDir}/regular-390.png` });
|
|
||||||
const canceled = await contextMenuCanceled(page.locator("main .md-content p").first());
|
|
||||||
if (baseline) { await page.getByRole("button", { name: "Fork", exact: true }).waitFor(); await page.keyboard.press("Escape"); }
|
|
||||||
const table = page.locator("main .md-table-scroll");
|
|
||||||
await scrollHorizontally(table);
|
|
||||||
const stayed = await page.locator("main h1").textContent() === chat.title;
|
|
||||||
const scrolled = stayed ? await table.evaluate(el => el.scrollLeft) : 0;
|
|
||||||
console.log(JSON.stringify({ baseline, metrics, contextMenuCanceled: canceled, tableScrollLeft: scrolled, stayedInChat: stayed }));
|
|
||||||
if (baseline) {
|
|
||||||
await openSidebar(); await page.locator('[data-testid="agent-sidebar-item"]').click();
|
|
||||||
await page.locator(".agent-room .md-content").waitFor();
|
|
||||||
await page.waitForFunction(() => document.querySelector(".workspace-sidebar").getBoundingClientRect().right <= 1);
|
|
||||||
console.log("Agent width", await page.locator(".agent-room .md-content").evaluate(el => el.getBoundingClientRect().width));
|
|
||||||
if (artifactDir) await page.screenshot({ path: `${artifactDir}/agent-390.png` });
|
|
||||||
} else {
|
|
||||||
assert.equal(metrics.touchAction, "auto");
|
|
||||||
assert.equal(metrics.modelVisible, true);
|
|
||||||
assert.ok(Math.abs(metrics.width - metrics.parentWidth) < 2, "compact answer uses full transcript width");
|
|
||||||
assert.equal(canceled, false, "native menu is not canceled");
|
|
||||||
assert.ok(stayed && scrolled > 50, "trusted horizontal pan scrolls the table without navigation");
|
|
||||||
// Even at either end of nested scrollers, pans belong to the content.
|
|
||||||
for (const locator of [table, page.locator("main blockquote pre"), page.locator("main .md-content > pre")]) {
|
|
||||||
await scrollHorizontally(locator);
|
|
||||||
assert.equal(await page.locator("main h1").textContent(), chat.title);
|
|
||||||
assert.ok(await locator.evaluate(el => el.scrollLeft > 50));
|
|
||||||
await locator.evaluate(el => { el.scrollLeft = el.scrollWidth; });
|
|
||||||
await locator.scrollIntoViewIfNeeded();
|
|
||||||
let box = await locator.boundingBox();
|
|
||||||
await drag(box.x + box.width - 30, box.y + 20, -130);
|
|
||||||
assert.equal(await page.locator("main h1").textContent(), chat.title);
|
|
||||||
await locator.evaluate(el => { el.scrollLeft = 0; });
|
|
||||||
box = await locator.boundingBox();
|
|
||||||
await drag(box.x + 30, box.y + 20, 130);
|
|
||||||
assert.ok(await page.locator(".workspace-sidebar").evaluate(el => el.getBoundingClientRect().right <= 0));
|
|
||||||
}
|
|
||||||
const paragraph = page.locator("main .md-content p").first();
|
|
||||||
await paragraph.scrollIntoViewIfNeeded();
|
|
||||||
await paragraph.evaluate(el => {
|
|
||||||
const range = document.createRange(); range.selectNodeContents(el);
|
|
||||||
window.getSelection().removeAllRanges(); window.getSelection().addRange(range);
|
|
||||||
});
|
|
||||||
await page.keyboard.press("Control+c");
|
|
||||||
assert.match(await page.evaluate(() => navigator.clipboard.readText()), /Mobile answer/);
|
|
||||||
assert.equal(await contextMenuCanceled(paragraph), false);
|
|
||||||
assert.equal(await contextMenuCanceled(page.locator("main .md-content a")), false);
|
|
||||||
await page.keyboard.press("Escape");
|
|
||||||
// Selection, multi-touch, long-press and touchcancel must not become navigation.
|
|
||||||
for (const scenario of ["selection", "multi", "hold", "cancel"]) {
|
|
||||||
await page.locator("main header").evaluate((el, scenario) => {
|
|
||||||
if (scenario !== "selection") window.getSelection().removeAllRanges();
|
|
||||||
const touch = (x, id = 1) => new Touch({ identifier: id, target: el, clientX: x, clientY: 20 });
|
|
||||||
const send = (type, touches, changed = touches, time = 0) => {
|
|
||||||
const event = new TouchEvent(type, { bubbles: true, cancelable: true, touches, changedTouches: changed });
|
|
||||||
Object.defineProperty(event, "timeStamp", { value: 1000 + time }); el.dispatchEvent(event);
|
|
||||||
};
|
|
||||||
send("touchstart", [touch(120)]);
|
|
||||||
if (scenario === "multi") send("touchstart", [touch(120), touch(160, 2)]);
|
|
||||||
if (scenario === "cancel") send("touchcancel", [], [touch(120)]);
|
|
||||||
send("touchmove", [touch(280)], undefined, scenario === "hold" ? 500 : 100);
|
|
||||||
send("touchend", [], [touch(280)], 600);
|
|
||||||
}, scenario);
|
|
||||||
assert.ok(await page.locator(".workspace-sidebar").evaluate(el => el.getBoundingClientRect().right <= 0), scenario);
|
|
||||||
}
|
|
||||||
await page.evaluate(() => window.getSelection().removeAllRanges());
|
|
||||||
const composer = page.locator("main textarea");
|
|
||||||
assert.equal(await contextMenuCanceled(composer), false);
|
|
||||||
await page.evaluate(() => navigator.clipboard.writeText("native pasted draft"));
|
|
||||||
await composer.focus(); await page.keyboard.press("Control+v");
|
|
||||||
assert.equal(await composer.inputValue(), "native pasted draft");
|
|
||||||
assert.equal(calls.filter(call => call.method === "POST").length, 0);
|
|
||||||
// Explicit message actions preserve Fork without replacing the native menu.
|
|
||||||
await page.getByRole("button", { name: "Message actions", exact: true }).click();
|
|
||||||
await page.getByRole("button", { name: "Fork", exact: true }).waitFor();
|
|
||||||
await page.keyboard.press("Escape");
|
|
||||||
await page.getByRole("button", { name: "Open chat settings" }).click();
|
|
||||||
await page.getByRole("dialog").waitFor();
|
|
||||||
await page.getByPlaceholder("Select or type model", { exact: true }).fill("mobile-model-two");
|
|
||||||
await page.getByPlaceholder("Select or type model", { exact: true }).press("Enter");
|
|
||||||
await page.getByRole("button", { name: "Save", exact: true }).first().click();
|
|
||||||
await page.getByRole("dialog").waitFor({ state: "detached" });
|
|
||||||
assert.match(await page.locator("main header p").textContent(), /mobile-model-two/);
|
|
||||||
for (const width of [320, 390, 430, 767]) {
|
|
||||||
await page.setViewportSize({ width, height: 844 });
|
|
||||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false, `${width}px page overflow`);
|
|
||||||
assert.ok(await page.locator("main header p").isVisible());
|
|
||||||
if (artifactDir) await page.screenshot({ path: `${artifactDir}/regular-${width}.png` });
|
|
||||||
}
|
|
||||||
await page.setViewportSize({ width: 390, height: 844 });
|
|
||||||
// Swipes still work in workspace chrome, away from text and scrollable content.
|
|
||||||
await drag(90, 8, 150);
|
|
||||||
await page.waitForFunction(() => document.querySelector(".workspace-sidebar").getBoundingClientRect().left >= 0);
|
|
||||||
await page.locator('[data-testid="agent-sidebar-item"]').click();
|
|
||||||
await page.locator(".agent-room .md-content").waitFor();
|
|
||||||
await page.waitForFunction(() => document.querySelector(".workspace-sidebar").getBoundingClientRect().right <= 1);
|
|
||||||
console.log("Agent width", await page.locator(".agent-room .md-content").evaluate(el => el.getBoundingClientRect().width));
|
|
||||||
if (artifactDir) await page.screenshot({ path: `${artifactDir}/agent-390.png` });
|
|
||||||
await scrollHorizontally(page.locator(".agent-room .md-table-scroll"));
|
|
||||||
assert.ok(await page.locator(".agent-room .md-table-scroll").evaluate(el => el.scrollLeft > 50));
|
|
||||||
assert.equal(await contextMenuCanceled(page.locator(".agent-room .md-content p").first()), false);
|
|
||||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false);
|
|
||||||
// Keyboard-height approximation: both composers remain inside a shorter viewport.
|
|
||||||
await page.setViewportSize({ width: 390, height: 450 });
|
|
||||||
assert.ok(await page.locator(".agent-room textarea").evaluate(el => el.getBoundingClientRect().bottom <= innerHeight));
|
|
||||||
await selectChat();
|
|
||||||
assert.ok(await composer.evaluate(el => el.getBoundingClientRect().bottom <= innerHeight));
|
|
||||||
await page.setViewportSize({ width: 1280, height: 900 });
|
|
||||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false);
|
|
||||||
assert.ok(await page.locator("main header button").filter({ hasText: "mobile-model-one" }).isVisible());
|
|
||||||
assert.equal(await contextMenuCanceled(paragraph), false);
|
|
||||||
const manifest = await page.evaluate(async () => (await fetch("/manifest.webmanifest")).json());
|
|
||||||
assert.equal(manifest.display, "standalone"); assert.ok(!manifest.display_override?.includes("fullscreen"));
|
|
||||||
const viewport = await page.locator('meta[name="viewport"]').getAttribute("content");
|
|
||||||
assert.match(viewport, /user-scalable=no/);
|
|
||||||
assert.match(viewport, /maximum-scale=1\.0(?:,|$)/);
|
|
||||||
await page.setViewportSize({ width: 390, height: 844 });
|
|
||||||
await drag(300, 8, -160);
|
|
||||||
await page.getByRole("heading", { name: "New chat", exact: true }).waitFor();
|
|
||||||
assert.deepEqual(errors, []);
|
|
||||||
console.log("PASS mobile horizontal scrolling/edges, native menu/copy/paste, selection/multitouch/cancellation, compact widths/models, Agent layout, shorter viewport, desktop and PWA settings");
|
|
||||||
}
|
|
||||||
} finally { await context.close(); await browser.close(); }
|
|
||||||
Reference in New Issue
Block a user